From 807f9a206def0d755a9cc6f18aa0994df90b0b04 Mon Sep 17 00:00:00 2001 From: wonfen Date: Wed, 19 Aug 2026 07:26:57 +0800 Subject: [PATCH] chore: trim excessive comments --- .github/workflows/autobuild-check-test.yml | 17 -- .github/workflows/autobuild.yml | 5 - .../workflows/check-commit-needs-build.yml | 11 -- .github/workflows/clean-old-assets.yml | 13 -- .github/workflows/release.yml | 7 - .github/workflows/rustfmt.yml | 16 -- crates/clash-verge-draft/src/lib.rs | 48 ++---- crates/clash-verge-logging/src/lib.rs | 5 - crates/clash-verge-media-unlock/src/utils.rs | 4 +- .../src/lib.rs | 9 +- scripts-workflow/bump_changelog.sh | 3 - scripts-workflow/get_latest_tauri_commit.bash | 13 +- scripts/fix-alpha_version.mjs | 18 -- scripts/prebuild.mjs | 55 +----- scripts/publish-version.mjs | 5 - scripts/release-version.mjs | 62 +------ scripts/set_dns.sh | 9 - scripts/telegram.mjs | 13 +- scripts/updater-fixed-webview2.mjs | 17 -- scripts/updater.mjs | 42 +---- src-tauri/src/cmd/app.rs | 12 -- src-tauri/src/cmd/backup.rs | 6 - src-tauri/src/cmd/clash.rs | 31 +--- src-tauri/src/cmd/network.rs | 8 - src-tauri/src/cmd/profile.rs | 23 +-- src-tauri/src/cmd/proxy.rs | 12 +- src-tauri/src/cmd/runtime.rs | 6 - src-tauri/src/cmd/system.rs | 7 +- src-tauri/src/cmd/uwp.rs | 3 - src-tauri/src/cmd/webdav.rs | 5 - src-tauri/src/config/config.rs | 24 +-- src-tauri/src/config/encrypt.rs | 10 -- src-tauri/src/config/mixed_port.rs | 29 +--- src-tauri/src/config/prfitem.rs | 47 +----- src-tauri/src/config/profiles.rs | 141 ++-------------- src-tauri/src/config/runtime.rs | 53 +----- src-tauri/src/config/verge.rs | 70 +------- src-tauri/src/core/manager/config.rs | 114 +++---------- src-tauri/src/core/manager/lifecycle.rs | 35 +--- src-tauri/src/core/manager/state.rs | 29 +--- src-tauri/src/core/proxy_control.rs | 16 -- src-tauri/src/core/runstate/env.rs | 58 +------ src-tauri/src/core/runstate/health.rs | 23 +-- src-tauri/src/core/runstate/mod.rs | 159 +++--------------- src-tauri/src/core/runstate/owner.rs | 30 +--- src-tauri/src/core/runtime_bundle.rs | 39 +---- src-tauri/src/core/service.rs | 87 ++-------- src-tauri/src/core/tray/mod.rs | 22 --- src-tauri/src/core/tray/speed_task.rs | 14 +- src-tauri/src/core/updater.rs | 63 ++----- src-tauri/src/core/validate.rs | 37 +--- src-tauri/src/enhance/chain.rs | 36 ---- src-tauri/src/enhance/mod.rs | 16 +- src-tauri/src/enhance/tun.rs | 7 - src-tauri/src/feat/backup.rs | 26 +-- src-tauri/src/feat/clash.rs | 12 +- src-tauri/src/feat/proxy.rs | 17 +- src-tauri/src/utils/connections_stream.rs | 28 +-- src-tauri/src/utils/dirs.rs | 17 +- src-tauri/src/utils/help.rs | 30 +--- src-tauri/src/utils/init.rs | 27 +-- src-tauri/src/utils/resolve/window.rs | 46 +---- src-tauri/src/utils/singleton.rs | 17 +- src-tauri/src/utils/speed.rs | 13 +- src-tauri/src/utils/tmpl.rs | 8 - src-tauri/src/utils/tray_speed.rs | 51 +----- src-tauri/src/utils/window_manager.rs | 39 +---- src/components/home/clash-mode-card.tsx | 20 +-- src/components/home/current-proxy-card.tsx | 23 +-- .../home/enhanced-canvas-traffic-graph.tsx | 125 +++----------- src/components/home/ip-info-card.tsx | 42 +---- src/components/home/system-info-card.tsx | 15 -- src/components/layout/layout-traffic.tsx | 11 +- src/components/profile/profile-item.tsx | 26 --- src/components/profile/profile-viewer.tsx | 18 +- src/components/proxy/provider-button.tsx | 18 -- .../proxy/proxy-empty-state-model.ts | 16 +- src/components/proxy/proxy-groups.tsx | 26 +-- src/components/proxy/use-filter-sort.ts | 11 -- src/components/proxy/use-render-list.test.ts | 10 +- src/components/rule/provider-button.tsx | 14 -- src/components/setting/mods/dns-viewer.tsx | 19 +-- .../setting/mods/external-controller-cors.tsx | 18 +- src/components/setting/mods/guard-state.tsx | 6 - .../setting/mods/sysproxy-viewer.tsx | 20 --- .../shared/traffic-error-boundary.tsx | 19 --- src/hooks/use-group-delays.ts | 29 +--- src/hooks/use-record-selection.test.tsx | 9 +- src/hooks/use-record-selection.ts | 20 +-- src/hooks/use-system-proxy-state.ts | 6 +- src/hooks/use-system-state.ts | 15 +- src/hooks/use-update.ts | 6 - src/pages/_layout/hooks/use-layout-events.ts | 8 +- src/pages/profiles.tsx | 37 +--- src/providers/app-data-provider.test.tsx | 22 +-- src/services/cmds.ts | 29 +--- src/services/delay.test.ts | 21 +-- src/services/delay.ts | 72 ++------ src/services/events.ts | 31 +--- src/services/notice-service.ts | 29 +--- src/types/global.d.ts | 13 -- src/utils/debug.ts | 4 - src/utils/delay.ts | 27 +-- 103 files changed, 293 insertions(+), 2517 deletions(-) diff --git a/.github/workflows/autobuild-check-test.yml b/.github/workflows/autobuild-check-test.yml index 92cce80cb..87baa21e0 100644 --- a/.github/workflows/autobuild-check-test.yml +++ b/.github/workflows/autobuild-check-test.yml @@ -19,21 +19,12 @@ jobs: - name: Check if version or source changed, or assets already exist id: check run: | - # # 仅用于测试逻辑,手动触发自动跳过 - # if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - # echo "should_run=skip" >> $GITHUB_OUTPUT - # echo "🟡 手动触发,跳过 should_run 检查" - # exit 0 - # fi - - # 确保有 HEAD~1 if ! git rev-parse HEAD~1 > /dev/null 2>&1; then echo "should_run=true" >> $GITHUB_OUTPUT echo "🟢 没有前一个提交,默认需要构建" exit 0 fi - # 版本号变更判断 CURRENT_VERSION=$(jq -r '.version' package.json) PREVIOUS_VERSION=$(git show HEAD~1:package.json | jq -r '.version' 2>/dev/null || echo "") @@ -43,7 +34,6 @@ jobs: exit 0 fi - # 检查 src 变更(排除常见产物与缓存) SRC_DIFF=$(git diff --name-only HEAD~1 HEAD -- src/ | grep -Ev '^src/(dist|build|node_modules|\.next|\.cache)' || true) TAURI_DIFF=$(git diff --name-only HEAD~1 HEAD -- src-tauri/ | grep -Ev '^src-tauri/(target|node_modules|dist|\.cache)' || true) @@ -53,16 +43,13 @@ jobs: exit 0 fi - # 找到最后一个修改 Tauri 相关文件的 commit echo "🔍 查找最后一个 Tauri 相关变更的 commit..." LAST_TAURI_COMMIT="" for commit in $(git rev-list HEAD --max-count=50); do - # 检查此 commit 是否修改了 Tauri 相关文件 CHANGED_FILES=$(git show --name-only --pretty=format: $commit | tr '\n' ' ') HAS_TAURI_CHANGES=false - # 检查各个模式 if echo "$CHANGED_FILES" | grep -q "src/" && echo "$CHANGED_FILES" | grep -qvE "src/(dist|build|node_modules|\.next|\.cache)"; then HAS_TAURI_CHANGES=true elif echo "$CHANGED_FILES" | grep -qE "src-tauri/(src|Cargo\.(toml|lock)|tauri\..*\.conf\.json|build\.rs|capabilities)"; then @@ -84,21 +71,18 @@ jobs: echo "📝 最后 Tauri 相关 commit: $LAST_TAURI_COMMIT" echo "📝 当前 commit: $CURRENT_COMMIT" - # 检查 autobuild release 是否存在 AUTOBUILD_RELEASE_EXISTS=$(gh release view "autobuild" --json id -q '.id' 2>/dev/null || echo "") if [ -z "$AUTOBUILD_RELEASE_EXISTS" ]; then echo "should_run=true" >> $GITHUB_OUTPUT echo "🟢 没有 autobuild release,需构建" else - # 检查 latest.json 是否存在 LATEST_JSON_EXISTS=$(gh release view "autobuild" --json assets -q '.assets[] | select(.name == "latest.json") | .name' 2>/dev/null || echo "") if [ -z "$LATEST_JSON_EXISTS" ]; then echo "should_run=true" >> $GITHUB_OUTPUT echo "🟢 没有 latest.json,需构建" else - # 下载并解析 latest.json 检查版本和 commit hash echo "📥 下载 latest.json 检查版本..." LATEST_JSON_URL=$(gh release view "autobuild" --json assets -q '.assets[] | select(.name == "latest.json") | .browser_download_url' 2>/dev/null) @@ -109,7 +93,6 @@ jobs: LATEST_VERSION=$(echo "$LATEST_JSON_CONTENT" | jq -r '.version' 2>/dev/null || echo "") echo "📦 最新 autobuild 版本: $LATEST_VERSION" - # 从版本字符串中提取 commit hash (格式: X.Y.Z+autobuild.MMDD.commit) LATEST_COMMIT=$(echo "$LATEST_VERSION" | sed -n 's/.*+autobuild\.[0-9]\{4\}\.\([a-f0-9]*\)$/\1/p' || echo "") echo "📝 最新 autobuild commit: $LATEST_COMMIT" diff --git a/.github/workflows/autobuild.yml b/.github/workflows/autobuild.yml index d142a3439..5f150cca8 100644 --- a/.github/workflows/autobuild.yml +++ b/.github/workflows/autobuild.yml @@ -203,7 +203,6 @@ jobs: - name: Add Rust Target run: | - # Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }} rustup target list --installed echo "Rust target ${{ matrix.target }} installed." @@ -230,7 +229,6 @@ jobs: prerelease: true tauriScript: pnpm args: --target ${{ matrix.target }} - # includeUpdaterJson: true autobuild-arm-linux: name: Autobuild ARM Linux @@ -346,7 +344,6 @@ jobs: - name: Add Rust Target run: | - # Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }} rustup target list --installed echo "Rust target ${{ matrix.target }} installed." @@ -454,7 +451,6 @@ jobs: - name: Add Rust Target run: | - # Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }} rustup target list --installed echo "Rust target ${{ matrix.target }} installed." @@ -470,7 +466,6 @@ jobs: with: tauriScript: pnpm args: --target ${{ matrix.target }} - # includeUpdaterJson: true - name: Rename run: | diff --git a/.github/workflows/check-commit-needs-build.yml b/.github/workflows/check-commit-needs-build.yml index 4c0b52d86..eca1512ab 100644 --- a/.github/workflows/check-commit-needs-build.yml +++ b/.github/workflows/check-commit-needs-build.yml @@ -62,7 +62,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Force build if requested if [ "${{ inputs.force_build }}" == "true" ]; then echo "🚀 Force build requested" echo "should_run=true" >> $GITHUB_OUTPUT @@ -84,25 +83,20 @@ jobs: exit 0 fi - # Use get_latest_tauri_commit.bash to find the latest Tauri-related commit echo "🔍 Finding last commit with Tauri-related changes using script..." - # Make script executable chmod +x scripts-workflow/get_latest_tauri_commit.bash - # Get the latest Tauri-related commit hash (full hash) LAST_TAURI_COMMIT_FULL=$(./scripts-workflow/get_latest_tauri_commit.bash) if [[ $? -ne 0 ]] || [[ -z "$LAST_TAURI_COMMIT_FULL" ]]; then echo "❌ Failed to get Tauri-related commit, using current commit" LAST_TAURI_COMMIT_FULL=$(git rev-parse HEAD) fi - # Get short hash for display and version tagging LAST_TAURI_COMMIT=$(git rev-parse --short "$LAST_TAURI_COMMIT_FULL") echo "📝 Last Tauri-related commit: $LAST_TAURI_COMMIT" - # Generate autobuild version using autobuild-latest format CURRENT_BASE_VERSION=$(echo "$CURRENT_VERSION" | sed -E 's/-(alpha|beta|rc)(\.[0-9]+)?//g' | sed -E 's/\+[a-zA-Z0-9.-]+//g') MONTH=$(TZ=Asia/Shanghai date +%m) DAY=$(TZ=Asia/Shanghai date +%d) @@ -111,11 +105,9 @@ jobs: echo "🏷️ Autobuild version: $AUTOBUILD_VERSION" echo "📝 Last Tauri commit: $LAST_TAURI_COMMIT" - # Set outputs for other jobs to use echo "last_tauri_commit=$LAST_TAURI_COMMIT" >> $GITHUB_OUTPUT echo "autobuild_version=$AUTOBUILD_VERSION" >> $GITHUB_OUTPUT - # Check if autobuild release exists echo "🔍 Checking autobuild release and latest.json..." AUTOBUILD_RELEASE_EXISTS=$(gh release view "${{ env.TAG_NAME }}" --json id -q '.id' 2>/dev/null || echo "") @@ -123,14 +115,12 @@ jobs: echo "✅ No autobuild release exists, build needed" echo "should_run=true" >> $GITHUB_OUTPUT else - # Check if latest.json exists in the release LATEST_JSON_EXISTS=$(gh release view "${{ env.TAG_NAME }}" --json assets -q '.assets[] | select(.name == "latest.json") | .name' 2>/dev/null || echo "") if [ -z "$LATEST_JSON_EXISTS" ]; then echo "✅ No latest.json found in autobuild release, build needed" echo "should_run=true" >> $GITHUB_OUTPUT else - # Download and parse latest.json to check version and commit hash echo "📥 Downloading latest.json to check version..." LATEST_JSON_URL="https://github.com/clash-verge-rev/clash-verge-rev/releases/download/autobuild/latest.json" @@ -140,7 +130,6 @@ jobs: LATEST_VERSION=$(echo "$LATEST_JSON_CONTENT" | jq -r '.version' 2>/dev/null || echo "") echo "📦 Latest autobuild version: $LATEST_VERSION" - # Extract commit hash from version string (format: X.Y.Z+autobuild.MMDD.commit) LATEST_COMMIT=$(echo "$LATEST_VERSION" | sed -n 's/.*+autobuild\.[0-9]\{4\}\.\([a-f0-9]*\)$/\1/p' || echo "") echo "📝 Latest autobuild commit: $LATEST_COMMIT" diff --git a/.github/workflows/clean-old-assets.yml b/.github/workflows/clean-old-assets.yml index 09c2ad4fd..b8ae5a30e 100644 --- a/.github/workflows/clean-old-assets.yml +++ b/.github/workflows/clean-old-assets.yml @@ -52,20 +52,15 @@ jobs: CURRENT_VERSION=$(cat package.json | jq -r '.version') echo "📦 Current version: $CURRENT_VERSION" - # Find the last commit that changed Tauri-related files echo "🔍 Finding last commit with Tauri-related changes..." - # Define patterns for Tauri-related files TAURI_PATTERNS="src/ src-tauri/src src-tauri/Cargo.toml Cargo.lock src-tauri/tauri.*.conf.json src-tauri/build.rs src-tauri/capabilities" - # Get the last commit that changed any of these patterns (excluding build artifacts) LAST_TAURI_COMMIT="" for commit in $(git rev-list HEAD --max-count=50); do - # Check if this commit changed any Tauri-related files CHANGED_FILES=$(git show --name-only --pretty=format: $commit | tr '\n' ' ') HAS_TAURI_CHANGES=false - # Check each pattern if echo "$CHANGED_FILES" | grep -q "src/" && echo "$CHANGED_FILES" | grep -qvE "src/(dist|build|node_modules|\.next|\.cache)"; then HAS_TAURI_CHANGES=true elif echo "$CHANGED_FILES" | grep -qE "src-tauri/(src|Cargo\.(toml|lock)|tauri\..*\.conf\.json|build\.rs|capabilities)"; then @@ -86,7 +81,6 @@ jobs: echo "📝 Last Tauri-related commit: $LAST_TAURI_COMMIT" echo "📝 Current commit: $(git rev-parse --short HEAD)" - # Generate autobuild version for consistency CURRENT_BASE_VERSION=$(echo "$CURRENT_VERSION" | sed -E 's/-(alpha|beta|rc)(\.[0-9]+)?//g' | sed -E 's/\+[a-zA-Z0-9.-]+//g') MONTH=$(TZ=Asia/Shanghai date +%m) DAY=$(TZ=Asia/Shanghai date +%d) @@ -94,7 +88,6 @@ jobs: echo "🏷️ Current autobuild version: $AUTOBUILD_VERSION" - # Set outputs for other jobs to use echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT echo "last_tauri_commit=$LAST_TAURI_COMMIT" >> $GITHUB_OUTPUT echo "autobuild_version=$AUTOBUILD_VERSION" >> $GITHUB_OUTPUT @@ -113,7 +106,6 @@ jobs: TAG_NAME: ${{ env.TAG_NAME }} DRY_RUN: ${{ inputs.dry_run }} run: | - # Use values from check_current_version job CURRENT_AUTOBUILD_VERSION="${{ needs.check_current_version.outputs.autobuild_version }}" LAST_TAURI_COMMIT="${{ needs.check_current_version.outputs.last_tauri_commit }}" CURRENT_VERSION="${{ needs.check_current_version.outputs.current_version }}" @@ -124,7 +116,6 @@ jobs: echo "🏷️ Target tag: $TAG_NAME" echo "🔍 Dry run mode: $DRY_RUN" - # Check if release exists RELEASE_EXISTS=$(gh release view "$TAG_NAME" --json id -q '.id' 2>/dev/null || echo "") if [ -z "$RELEASE_EXISTS" ]; then @@ -134,7 +125,6 @@ jobs: echo "✅ Found release '$TAG_NAME'" - # Get all assets echo "📋 Getting list of all assets..." assets=$(gh release view "$TAG_NAME" --json assets -q '.assets[].name' || true) @@ -146,12 +136,10 @@ jobs: echo "📋 Found assets:" echo "$assets" | sed 's/^/ - /' - # Count assets to keep and delete ASSETS_TO_KEEP="" ASSETS_TO_DELETE="" for asset in $assets; do - # Keep assets that match current autobuild version or are non-versioned files (like latest.json) if [[ "$asset" == *"$CURRENT_AUTOBUILD_VERSION"* ]] || [[ "$asset" == "latest.json" ]]; then ASSETS_TO_KEEP="$ASSETS_TO_KEEP$asset\n" else @@ -189,7 +177,6 @@ jobs: FAILED_COUNT=0 for asset in $assets; do - # Skip assets that should be kept if [[ "$asset" == *"$CURRENT_AUTOBUILD_VERSION"* ]] || [[ "$asset" == "latest.json" ]]; then continue fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46ad19b63..6ca69c509 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,6 @@ name: Release Build on: - # ! 为了避免重复发布版本,应当通过独特 git tag 触发。 - # ! 不再使用 workflow_dispatch 触发。 - # workflow_dispatch: push: # -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新 tags: @@ -14,7 +11,6 @@ env: RUST_BACKTRACE: short HUSKY: 0 concurrency: - # only allow per workflow per commit (and not pr) to run at a time group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}' cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} @@ -245,7 +241,6 @@ jobs: - name: Add Rust Target run: | - # Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }} rustup target list --installed echo "Rust target ${{ matrix.target }} installed." @@ -391,7 +386,6 @@ jobs: - name: Add Rust Target run: | - # Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }} rustup target list --installed echo "Rust target ${{ matrix.target }} installed." @@ -490,7 +484,6 @@ jobs: - name: Add Rust Target run: | - # Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }} rustup target list --installed echo "Rust target ${{ matrix.target }} installed." diff --git a/.github/workflows/rustfmt.yml b/.github/workflows/rustfmt.yml index 54729e285..28f86cb80 100644 --- a/.github/workflows/rustfmt.yml +++ b/.github/workflows/rustfmt.yml @@ -41,19 +41,3 @@ jobs: - name: run cargo fmt if: steps.check_rust.outputs.rust == 'true' run: cargo fmt --manifest-path ./src-tauri/Cargo.toml --all -- --check - - # taplo: - # name: taplo (.toml files) - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v6 - - # - name: install Rust stable - # uses: dtolnay/rust-toolchain@stable - - # - name: install taplo-cli - # uses: taiki-e/install-action@v2.68.8 - # with: - # tool: taplo-cli - - # - run: taplo fmt --check --diff diff --git a/crates/clash-verge-draft/src/lib.rs b/crates/clash-verge-draft/src/lib.rs index e547a9ead..c9bf560e5 100644 --- a/crates/clash-verge-draft/src/lib.rs +++ b/crates/clash-verge-draft/src/lib.rs @@ -7,7 +7,6 @@ use std::sync::atomic::{ use tokio::sync::Notify; pub type SharedDraft = Arc>; -// (committed_snapshot, optional_draft_snapshot) type DraftData = (SharedDraft, Option>); // Retry one scheduler turn before parking on Notify for long async updates. const DATA_MODIFY_FAST_RETRY_YIELDS: usize = 1; @@ -17,7 +16,6 @@ struct DraftInner { data: Mutex>, data_modifying: AtomicBool, data_modify_notify: Notify, - /// Held by the one [`DraftTransaction`] currently allowed to stage a change. claimed: AtomicBool, } @@ -30,7 +28,7 @@ impl Drop for DataModifyPermit<'_> { } } -/// Draft 管理:committed 与 optional draft 都以 Arc> 存储。 +/// Copy-on-write committed data with an optional draft. #[derive(Debug, Clone)] pub struct Draft { inner: Arc>, @@ -48,24 +46,21 @@ impl Draft { }), } } - /// 以 Arc> 的形式获取当前“已提交(正式)”数据的快照(零拷贝,仅 clone Arc) + /// Returns the committed snapshot without cloning `T`. #[inline] pub fn data_arc(&self) -> SharedDraft { let guard = self.inner.data.lock(); Arc::clone(&guard.0) } - /// 获取当前(草稿若存在则返回草稿,否则返回已提交)的快照 - /// 这也是零拷贝:只 clone Arc,不 clone T + /// Returns the draft when present, otherwise the committed snapshot. #[inline] pub fn latest_arc(&self) -> SharedDraft { let guard = self.inner.data.lock(); guard.1.clone().unwrap_or_else(|| Arc::clone(&guard.0)) } - /// 通过闭包以可变方式编辑草稿(在闭包中我们给出 &mut T) - /// - 延迟拷贝:如果只有这一个 Arc 引用,则直接修改,不会克隆 T; - /// - 若草稿被其他读者共享,Arc::make_mut 会做一次 T.clone(最小必要拷贝)。 + /// Edits the draft, cloning `T` only when the snapshot is shared. #[inline] pub fn edit_draft(&self, f: F) -> R where @@ -79,7 +74,7 @@ impl Draft { result } - /// 将草稿提交到已提交位置(替换),并清除草稿 + /// Commits and clears the draft. #[inline] pub fn apply(&self) { let mut guard = self.inner.data.lock(); @@ -88,15 +83,14 @@ impl Draft { } } - /// 丢弃草稿(如果存在) + /// Discards the draft. #[inline] pub fn discard(&self) { let mut guard = self.inner.data.lock(); guard.1 = None; } - /// 异步地以拥有 Box 的方式修改已提交数据:将克隆一次已提交数据到本地, - /// 异步闭包返回新的 Box(替换已提交数据)和业务返回值 R。 + /// Optimistically replaces committed data after an asynchronous edit. #[inline] pub async fn with_data_modify(&self, f: F) -> Result where @@ -211,20 +205,9 @@ impl std::fmt::Display for DraftBusy { impl std::error::Error for DraftBusy {} -/// Commits a set of draft layers together, or rolls them all back. +/// Exclusively claims several draft layers and rolls them all back unless committed. /// -/// Rolls back when dropped, so an early return — a `?`, a `bail!`, a panic — cannot leave a -/// change half-applied across layers. Committing is the explicit act, because forgetting to -/// commit costs a retry while forgetting to roll back leaves the app describing a state it is -/// not in. -/// -/// Beginning one takes exclusive staging rights over every layer, and overlapping with another -/// transaction is refused rather than allowed to interleave. That is not caution, it is the -/// only honest option: a layer holds exactly one draft, so a second writer's `edit_draft` -/// overwrites the first writer's staged value *in place*. There is no version of "roll back -/// only my edit" that could be implemented — rolling back would discard a change the other -/// writer is about to commit, and it did: the other writer's `apply` then found no draft, did -/// nothing at all, and still reported success while its side effect had already happened. +/// A layer has one draft slot, so concurrent transactions cannot be isolated and are rejected. #[must_use = "a transaction that is dropped without committing rolls back"] pub struct DraftTransaction<'a> { layers: Vec<&'a dyn DraftLayer>, @@ -232,10 +215,7 @@ pub struct DraftTransaction<'a> { } impl<'a> DraftTransaction<'a> { - /// Claim `layers` and begin a transaction over them; they are committed in the order given. - /// - /// Fails if any layer is already claimed, having released the ones taken so far, so a - /// refused attempt leaves nothing held. + /// Claims all layers or releases any partial claim. pub fn begin(layers: Vec<&'a dyn DraftLayer>) -> Result { for (index, layer) in layers.iter().enumerate() { if layer.try_claim() { @@ -254,7 +234,7 @@ impl<'a> DraftTransaction<'a> { }) } - /// Commit every layer. Consumes the transaction so it cannot be committed twice. + /// Commits every layer in the supplied order. pub fn commit(mut self) { for layer in &self.layers { layer.apply_draft(); @@ -262,11 +242,7 @@ impl<'a> DraftTransaction<'a> { self.committed = true; } - /// Roll every layer back now, rather than whenever this value happens to drop. - /// - /// Needed where recovery does more than forget the drafts — restoring files, or - /// regenerating them from the committed configuration. Those read the layers, so the - /// rollback has to have already happened, not be pending on a later drop. + /// Rolls back immediately when recovery must read the committed layers next. pub fn rollback(self) { drop(self); } diff --git a/crates/clash-verge-logging/src/lib.rs b/crates/clash-verge-logging/src/lib.rs index 4c2d88843..4ef83cf7d 100644 --- a/crates/clash-verge-logging/src/lib.rs +++ b/crates/clash-verge-logging/src/lib.rs @@ -62,7 +62,6 @@ impl fmt::Display for Type { #[macro_export] macro_rules! logging { - // 不带 print 参数的版本(默认不打印) ($level:ident, $type:expr, $($arg:tt)*) => { log::$level!(target: "app", "{} {}", $type, format_args!($($arg)*)) }; @@ -70,14 +69,12 @@ macro_rules! logging { #[macro_export] macro_rules! logging_error { - // Handle Result ($type:expr, $expr:expr) => { if let Err(err) = $expr { log::error!(target: "app", "[{}] {}", $type, err); } }; - // Handle formatted message: always print to stdout and log as error ($type:expr, $fmt:literal $(, $arg:expr)*) => { log::error!(target: "app", "[{}] {}", $type, format_args!($fmt $(, $arg)*)); }; @@ -113,14 +110,12 @@ impl<'a> ModuleFilter<'a> { return true; }; - // 优先检查排除(白名单) if let Some(excludes) = &self.exclude && excludes.iter().any(|e| module.starts_with(e)) { return true; } - // 再检查阻止(黑名单) !self.block.iter().any(|b| module.starts_with(b)) } } diff --git a/crates/clash-verge-media-unlock/src/utils.rs b/crates/clash-verge-media-unlock/src/utils.rs index 9f63768cb..9bc003509 100644 --- a/crates/clash-verge-media-unlock/src/utils.rs +++ b/crates/clash-verge-media-unlock/src/utils.rs @@ -8,17 +8,15 @@ pub fn get_local_date_string() -> String { pub fn country_code_to_emoji(country_code: &str) -> String { let uc = country_code.to_ascii_uppercase(); - // 长度校验:仅允许 2 或 3 match uc.len() { 2 => { - // 校验是否是合法 alpha2 if rust_iso3166::from_alpha2(&uc).is_none() { return String::new(); } alpha2_to_emoji(&uc) } 3 => { - // 转换并校验 alpha3 + // Regional indicators require the alpha-2 form. match rust_iso3166::from_alpha3(&uc) { Some(c) => { let alpha2 = c.alpha2.to_ascii_uppercase(); diff --git a/crates/tauri-plugin-clash-verge-sysinfo/src/lib.rs b/crates/tauri-plugin-clash-verge-sysinfo/src/lib.rs index 4dca09631..7e2fdd5dc 100644 --- a/crates/tauri-plugin-clash-verge-sysinfo/src/lib.rs +++ b/crates/tauri-plugin-clash-verge-sysinfo/src/lib.rs @@ -159,14 +159,7 @@ pub fn is_current_app_handle_admin(app: &tauri::AppHandle) -> boo #[inline] pub fn init() -> TauriPlugin { Builder::::new("clash_verge_sysinfo") - // TODO 现在 crate 还不是真正的 tauri 插件,必须由主 lib 自行注册 - // TODO 从 clash-verge 中迁移获取系统信息的 commnand 并实现优雅 structure.field 访问 - // .invoke_handler(tauri::generate_handler![ - // commands::get_system_info, - // commands::get_app_uptime, - // commands::app_is_admin, - // commands::export_diagnostic_info, - // ]) + // TODO: move command registration here once this crate becomes a complete Tauri plugin. .setup(move |app, _api| { let app_version = app.package_info().version.to_string(); let is_admin = is_binary_admin(); diff --git a/scripts-workflow/bump_changelog.sh b/scripts-workflow/bump_changelog.sh index 808058d30..2fa96bc9f 100755 --- a/scripts-workflow/bump_changelog.sh +++ b/scripts-workflow/bump_changelog.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -# bump_changelog.sh # - prepend ./Changelog.md to ./docs/Changelog.history.md # - overwrite ./Changelog.md with ./template/Changelog.md @@ -38,7 +37,6 @@ if [ -f "$HISTORY" ]; then echo "Backed up $HISTORY -> $BACKUP_DIR/Changelog.history.md.bak.$bak_ts" fi -# Prepend current Changelog.md content to top of docs/Changelog.history.md tmpfile=$(mktemp) { cat "$CHANGELOG" @@ -52,7 +50,6 @@ tmpfile=$(mktemp) mv "$tmpfile" "$HISTORY" echo "Prepended $CHANGELOG -> $HISTORY" -# Overwrite Changelog.md with template cp "$TEMPLATE" "$CHANGELOG" echo "Overwrote $CHANGELOG with $TEMPLATE" diff --git a/scripts-workflow/get_latest_tauri_commit.bash b/scripts-workflow/get_latest_tauri_commit.bash index 7fa950b1a..56e840bd2 100755 --- a/scripts-workflow/get_latest_tauri_commit.bash +++ b/scripts-workflow/get_latest_tauri_commit.bash @@ -1,7 +1,6 @@ #!/bin/bash -# 获取最近一个和 Tauri 相关的改动的 commit hash -# This script finds the latest commit that modified Tauri-related files +# Print the latest commit that modified Tauri-related files. # Tauri 相关文件的模式 TAURI_PATTERNS=( @@ -24,7 +23,6 @@ EXCLUDE_PATTERNS=( ".git/" ) -# 构建 git log 的路径过滤参数 PATHS="" for pattern in "${TAURI_PATTERNS[@]}"; do if [[ -e "$pattern" ]]; then @@ -32,25 +30,16 @@ for pattern in "${TAURI_PATTERNS[@]}"; do fi done -# 如果没有找到相关路径,返回错误 if [[ -z "$PATHS" ]]; then echo "Error: No Tauri-related paths found in current directory" >&2 exit 1 fi -# 获取最新的 commit hash -# 使用 git log 查找最近修改了 Tauri 相关文件的提交 LATEST_COMMIT=$(git log --format="%H" -n 1 -- $PATHS) -# 验证是否找到了 commit if [[ -z "$LATEST_COMMIT" ]]; then echo "Error: No commits found for Tauri-related files" >&2 exit 1 fi -# 输出结果 echo "$LATEST_COMMIT" - -# 如果需要更多信息,可以取消注释以下行 -# echo "Latest Tauri-related commit: $LATEST_COMMIT" -# git show --stat --oneline "$LATEST_COMMIT" \ No newline at end of file diff --git a/scripts/fix-alpha_version.mjs b/scripts/fix-alpha_version.mjs index d88e918a0..92acedbcb 100644 --- a/scripts/fix-alpha_version.mjs +++ b/scripts/fix-alpha_version.mjs @@ -3,19 +3,12 @@ import fs from 'fs/promises' import path from 'path' import { promisify } from 'util' -/** - * 为Alpha版本重命名版本号 - */ const execPromise = promisify(exec) -/** - * 标准输出HEAD hash - */ async function getLatestCommitHash() { try { const { stdout } = await execPromise('git rev-parse HEAD') const commitHash = stdout.trim() - // 格式化,只截取前7位字符 const formathash = commitHash.substring(0, 7) console.log(`Found the latest commit hash code: ${commitHash}`) return formathash @@ -24,31 +17,20 @@ async function getLatestCommitHash() { } } -/** - * @param string 传入格式化后的hash - * 将新的版本号写入文件 package.json - */ async function updatePackageVersion(newVersion) { - // 获取内容根目录 const _dirname = process.cwd() const packageJsonPath = path.join(_dirname, 'package.json') try { - // 读取文件 const data = await fs.readFile(packageJsonPath, 'utf8') const packageJson = JSON.parse(data) - // 获取键值替换 let result = packageJson.version.replace('alpha', newVersion) - // 检查当前版本号是否已经包含了 alpha- 后缀 if (!packageJson.version.includes(`alpha-`)) { - // 如果只有 alpha 而没有 alpha-,则替换为 alpha-newVersion result = packageJson.version.replace('alpha', `alpha-${newVersion}`) } else { - // 如果已经是 alpha-xxx 格式,则更新 xxx 部分 result = packageJson.version.replace(/alpha-[^-]*/, `alpha-${newVersion}`) } console.log('[INFO]: Current version is: ', result) packageJson.version = result - // 写入版本号 await fs.writeFile( packageJsonPath, JSON.stringify(packageJson, null, 2), diff --git a/scripts/prebuild.mjs b/scripts/prebuild.mjs index 3099a8b58..d72eaf713 100644 --- a/scripts/prebuild.mjs +++ b/scripts/prebuild.mjs @@ -13,14 +13,7 @@ import { extract } from 'tar' import { resolveServiceRelease } from './service-release.mjs' import { log_debug, log_error, log_info, log_success } from './utils.mjs' -/** - * Prebuild script with optimization features: - * 1. Skip downloading mihomo core if it already exists (unless --force is used) - * 2. Cache version information for 1 hour to avoid repeated version checks - * 3. Use file hash to detect changes and skip unnecessary chmod/copy operations - * 4. Use --force or -f flag to force re-download and update all resources - * - */ +/** Prepares platform resources, caching versions and unchanged files unless `--force` is used. */ const cwd = process.cwd() const TEMP_DIR = path.join(cwd, 'node_modules/.verge') @@ -76,9 +69,7 @@ const SIDECAR_DIR = path.join(cwd, 'src-tauri', 'sidecar') // Linux service binaries are bundled as externalBin sidecars (see tauri.linux.conf.json) const SERVICE_DIR = platform === 'linux' ? SIDECAR_DIR : RESOURCES_DIR -// ======================= -// Version Cache -// ======================= +// Version cache async function loadVersionCache() { try { if (fs.existsSync(VERSION_CACHE_FILE)) { @@ -114,9 +105,7 @@ async function setCachedVersion(key, version) { await saveVersionCache(cache) } -// ======================= -// Hash Cache & File Hash -// ======================= +// File hash cache async function calculateFileHash(filePath) { try { const fileBuffer = await fsp.readFile(filePath) @@ -170,9 +159,7 @@ async function updateHashCache(targetPath) { } } -// ======================= -// Meta maps (stable & alpha) -// ======================= +// Mihomo release maps const META_ALPHA_VERSION_URL = 'https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha/version.txt' const META_ALPHA_URL_PREFIX = `https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha` @@ -211,9 +198,7 @@ const META_MAP = { 'linux-loong64': 'mihomo-linux-loong64', } -// ======================= -// Fetch latest versions -// ======================= +// Release discovery async function getLatestAlphaVersion() { if (!FORCE) { const cached = await getCachedVersion('META_ALPHA_VERSION') @@ -280,9 +265,6 @@ async function getLatestReleaseVersion() { } } -// ======================= -// Validate availability -// ======================= if (!META_MAP[`${platform}-${arch}`]) { throw new Error(`clash meta unsupported platform "${platform}-${arch}"`) } @@ -290,9 +272,6 @@ if (!META_ALPHA_MAP[`${platform}-${arch}`]) { throw new Error(`clash meta alpha unsupported platform "${platform}-${arch}"`) } -// ======================= -// Build meta objects -// ======================= function clashMetaAlpha() { const name = META_ALPHA_MAP[`${platform}-${arch}`] const isWin = platform === 'win32' @@ -319,9 +298,6 @@ function clashMeta() { } } -// ======================= -// download helper (增强:status + magic bytes) -// ======================= async function downloadFile(url, outPath) { const options = {} const httpProxy = @@ -338,7 +314,6 @@ async function downloadFile(url, outPath) { }) if (!response.ok) { const body = await response.text().catch(() => '') - // 将 body 写到文件以便排查(可通过临时目录查看) await fsp.mkdir(path.dirname(outPath), { recursive: true }) await fsp.writeFile(outPath, body) throw new Error(`Failed to download ${url}: status ${response.status}`) @@ -347,7 +322,6 @@ async function downloadFile(url, outPath) { const buf = Buffer.from(await response.arrayBuffer()) await fsp.mkdir(path.dirname(outPath), { recursive: true }) - // 简单 magic 字节检查 if (url.endsWith('.gz') || url.endsWith('.tgz')) { if (!(buf[0] === 0x1f && buf[1] === 0x8b)) { await fsp.writeFile(outPath, buf) @@ -368,9 +342,6 @@ async function downloadFile(url, outPath) { log_success(`download finished: ${url}`) } -// ======================= -// resolveSidecar (支持 zip / tgz / gz) -// ======================= async function resolveSidecar(binInfo) { const { name, targetFile, zipFile, exeFile, downloadURL } = binInfo const sidecarPath = path.join(SIDECAR_DIR, targetFile) @@ -397,11 +368,9 @@ async function resolveSidecar(binInfo) { log_debug(`"${name}" entry: ${entry.entryName}`) }) zip.extractAllTo(tempDir, true) - // 尝试按 exeFile 重命名,否则找第一个可执行文件 if (fs.existsSync(tempExe)) { await fsp.rename(tempExe, sidecarPath) } else { - // 搜索候选 const files = await fsp.readdir(tempDir) const candidate = files.find( (f) => @@ -419,7 +388,6 @@ async function resolveSidecar(binInfo) { await extract({ cwd: tempDir, file: tempZip }) const files = await fsp.readdir(tempDir) log_debug(`"${name}" extracted files:`, files) - // 优先寻找给定 exeFile 或已知前缀 let extracted = files.find( (f) => f === path.basename(exeFile) || @@ -432,7 +400,6 @@ async function resolveSidecar(binInfo) { await fsp.chmod(sidecarPath, 0o755) log_success(`tgz processed: "${name}"`) } else { - // .gz const readStream = fs.createReadStream(tempZip) const writeStream = fs.createWriteStream(sidecarPath) await new Promise((resolve, reject) => { @@ -496,7 +463,7 @@ async function resolveResource(binInfo) { log_success(`${file} finished`) } -// SimpleSC.dll (win plugin) +// Windows NSIS plugin const resolvePlugin = async () => { const url = 'https://nsis.sourceforge.io/mediawiki/images/e/ef/NSIS_Simple_Service_Plugin_Unicode_1.30.zip' @@ -524,7 +491,6 @@ const resolvePlugin = async () => { await fsp.cp(tempDll, pluginPath, { recursive: true, force: true }) log_success(`unzip finished: "SimpleSC"`) } else { - // 如果 dll 名称不同,尝试找到 dll const files = await fsp.readdir(tempDir) const dll = files.find((f) => f.toLowerCase().endsWith('.dll')) if (dll) { @@ -542,7 +508,7 @@ const resolvePlugin = async () => { } } -// service chmod (保留并使用 glob) +// Service executable permissions const resolveServicePermission = async () => { const serviceExecutables = [ 'clash-verge-service*', @@ -578,9 +544,7 @@ const resolveServicePermission = async () => { } } -// ======================= -// Other resource resolvers (service, mmdb, geosite, geoip, enableLoopback) -// ======================= +// Other resources const SERVICE_BINARIES = [ 'clash-verge-service', 'clash-verge-service-install', @@ -698,9 +662,6 @@ const resolveUnSetDnsScript = () => localPath: path.join(cwd, 'scripts/unset_dns.sh'), }) -// ======================= -// Tasks -// ======================= const tasks = [ { name: 'verge-mihomo-alpha', diff --git a/scripts/publish-version.mjs b/scripts/publish-version.mjs index 3e2e75275..85897549b 100644 --- a/scripts/publish-version.mjs +++ b/scripts/publish-version.mjs @@ -1,4 +1,3 @@ -// scripts/publish-version.mjs import { execFileSync, spawn } from 'child_process' import { existsSync, readFileSync } from 'fs' import path from 'path' @@ -17,7 +16,6 @@ if (!versionArg) { process.exit(1) } -// 1. 调用 release-version.mjs const runRelease = () => new Promise((resolve, reject) => { const child = spawn('node', [scriptPath, versionArg], { stdio: 'inherit' }) @@ -27,7 +25,6 @@ const runRelease = () => }) }) -// 2. 判断是否需要打 tag function isSemver(version) { return /^v?\d+\.\d+\.\d+(-(alpha|beta|rc)(\.\d+)?)?(\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?$/i.test( version, @@ -57,7 +54,6 @@ async function run() { let tag = null if (versionArg === 'alpha') { - // 读取 release-version.mjs 写入后的版本 tag = normalizeVersion(readPackageVersion()) } else if (isSemver(versionArg)) { // 1.2.3、v1.2.3、1.2.3-beta.1 或 1.2.3+build.1 @@ -67,7 +63,6 @@ async function run() { if (tag) { validateTag(tag) - // 打 tag 并推送 try { execFileSync('git', ['tag', tag], { stdio: 'inherit' }) execFileSync('git', ['push', 'origin', tag], { stdio: 'inherit' }) diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs index 2586407c5..e878c62ab 100644 --- a/scripts/release-version.mjs +++ b/scripts/release-version.mjs @@ -20,13 +20,6 @@ * pnpm release-version autobuild-latest * pnpm release-version deploytest * - * The script will: - * - Validate and normalize the version argument - * - Update the version field in package.json - * - Update the version field in src-tauri/Cargo.toml - * - Update the version field in src-tauri/tauri.conf.json - * - * Errors are logged and the process exits with code 1 on failure. */ import { execSync } from 'child_process' @@ -35,10 +28,6 @@ import path from 'path' import { program } from 'commander' -/** - * 获取当前 git 短 commit hash - * @returns {string} - */ function getGitShortCommit() { try { return execSync('git rev-parse --short HEAD').toString().trim() @@ -48,10 +37,6 @@ function getGitShortCommit() { } } -/** - * 获取最新 Tauri 相关提交的短 hash - * @returns {string} - */ function getLatestTauriCommit() { try { const fullHash = execSync( @@ -73,13 +58,7 @@ function getLatestTauriCommit() { } } -/** - * 生成短时间戳(格式:MMDD)或带 commit(格式:MMDD.cc39b27) - * 使用 Asia/Shanghai 时区 - * @param {boolean} withCommit 是否带 commit - * @param {boolean} useTauriCommit 是否使用 Tauri 相关的 commit(仅当 withCommit 为 true 时有效) - * @returns {string} - */ +/** Generates `MMDD`, optionally followed by a commit, in the Asia/Shanghai timezone. */ function generateShortTimestamp(withCommit = false, useTauriCommit = false) { const now = new Date() @@ -102,41 +81,22 @@ function generateShortTimestamp(withCommit = false, useTauriCommit = false) { return `${month}${day}` } -/** - * 验证版本号格式 - * @param {string} version - * @returns {boolean} - */ function isValidVersion(version) { return /^v?\d+\.\d+\.\d+(-(alpha|beta|rc)(\.\d+)?)?(\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?$/i.test( version, ) } -/** - * 标准化版本号 - * @param {string} version - * @returns {string} - */ function normalizeVersion(version) { return version.startsWith('v') ? version : `v${version}` } -/** - * 提取基础版本号(去掉所有 -tag 和 +build 部分) - * @param {string} version - * @returns {string} - */ function getBaseVersion(version) { let base = version.replace(/-(alpha|beta|rc)(\.\d+)?/i, '') base = base.replace(/\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*/g, '') return base } -/** - * 更新 package.json 版本号 - * @param {string} newVersion - */ async function updatePackageVersion(newVersion) { const _dirname = process.cwd() const packageJsonPath = path.join(_dirname, 'package.json') @@ -165,10 +125,6 @@ async function updatePackageVersion(newVersion) { } } -/** - * 更新 Cargo.toml 版本号 - * @param {string} newVersion - */ async function updateCargoVersion(newVersion) { const _dirname = process.cwd() const cargoTomlPath = path.join(_dirname, 'src-tauri', 'Cargo.toml') @@ -197,10 +153,6 @@ async function updateCargoVersion(newVersion) { } } -/** - * 更新 tauri.conf.json 版本号 - * @param {string} newVersion - */ async function updateTauriConfigVersion(newVersion) { const _dirname = process.cwd() const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json') @@ -216,7 +168,6 @@ async function updateTauriConfigVersion(newVersion) { tauriConfig.version, ) - // 使用完整版本信息,包含build metadata tauriConfig.version = versionWithoutV await fs.writeFile( @@ -233,9 +184,6 @@ async function updateTauriConfigVersion(newVersion) { } } -/** - * 获取当前版本号 - */ async function getCurrentVersion() { const _dirname = process.cwd() const packageJsonPath = path.join(_dirname, 'package.json') @@ -249,9 +197,6 @@ async function getCurrentVersion() { } } -/** - * 主函数 - */ async function main(versionArg) { if (!versionArg) { console.error('Error: Version argument is required') @@ -274,16 +219,11 @@ async function main(versionArg) { const baseVersion = getBaseVersion(currentVersion) if (versionArg.toLowerCase() === 'autobuild') { - // 格式: 2.3.0+autobuild.1004.cc39b27 - // 使用 Tauri 相关的最新 commit hash newVersion = `${baseVersion}+autobuild.${generateShortTimestamp(true, true)}` } else if (versionArg.toLowerCase() === 'autobuild-latest') { - // 格式: 2.3.0+autobuild.1004.a1b2c3d (使用最新 Tauri 提交) const latestTauriCommit = getLatestTauriCommit() newVersion = `${baseVersion}+autobuild.${generateShortTimestamp()}.${latestTauriCommit}` } else if (versionArg.toLowerCase() === 'deploytest') { - // 格式: 2.3.0+deploytest.1004.cc39b27 - // 使用 Tauri 相关的最新 commit hash newVersion = `${baseVersion}+deploytest.${generateShortTimestamp(true, true)}` } else { newVersion = `${baseVersion}-${versionArg.toLowerCase()}` diff --git a/scripts/set_dns.sh b/scripts/set_dns.sh index 8741eff13..c3e03e209 100644 --- a/scripts/set_dns.sh +++ b/scripts/set_dns.sh @@ -1,6 +1,5 @@ #!/bin/bash -# 验证IPv4地址格式 function is_valid_ipv4() { local ip=$1 local IFS='.' @@ -18,7 +17,6 @@ function is_valid_ipv4() { return 0 } -# 验证IPv6地址格式 function is_valid_ipv6() { local ip=$1 if [[ ! $ip =~ ^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}$ ]] && @@ -28,27 +26,21 @@ function is_valid_ipv6() { return 0 } -# 验证IP地址是否为有效的IPv4或IPv6 function is_valid_ip() { is_valid_ipv4 "$1" || is_valid_ipv6 "$1" } -# 检查参数 [ $# -lt 1 ] && echo "Usage: $0 " && exit 1 ! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1 -# 获取网络接口和硬件端口 nic=$(route -n get default | grep "interface" | awk '{print $2}') -# 从网络服务列表中获取硬件端口 hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" ' /^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)} /\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}} ') -# 获取当前DNS设置 original_dns=$(networksetup -getdnsservers "$hardware_port") -# 检查当前DNS设置是否有效 is_valid_dns=false for ip in $original_dns; do ip=$(echo "$ip" | tr -d '[:space:]') @@ -58,7 +50,6 @@ for ip in $original_dns; do fi done -# 更新DNS设置 if [ "$is_valid_dns" = false ]; then echo "empty" >.original_dns.txt else diff --git a/scripts/telegram.mjs b/scripts/telegram.mjs index cf5243390..8d10e4b5c 100644 --- a/scripts/telegram.mjs +++ b/scripts/telegram.mjs @@ -32,7 +32,6 @@ async function sendTelegramNotification() { log_info(`Target channel: ${chatId}`) log_info(`Download URL: ${downloadUrl}`) - // 读取发布说明和下载地址 let releaseContent try { releaseContent = readFileSync('release.txt', 'utf-8') @@ -42,7 +41,6 @@ async function sendTelegramNotification() { releaseContent = '更多新功能现已支持,详细更新日志请查看发布页面。' } - // Markdown 转换为 HTML function convertMarkdownToTelegramHTML(content) { // Strip stray HTML tags and markdown bold from heading text const cleanHeading = (text) => @@ -88,20 +86,13 @@ async function sendTelegramNotification() { .replace(//g, '\n') } - // Strip HTML tags not supported by Telegram and escape stray angle brackets function sanitizeTelegramHTML(content) { - // Telegram supports: b, strong, i, em, u, ins, s, strike, del, - // a, code, pre, blockquote, tg-spoiler, tg-emoji const allowedTag = /^<\/?(?:b|strong|i|em|u|ins|s|strike|del|a|code|pre|blockquote|tg-spoiler|tg-emoji)(?:\s[^<>]*)?>$/i - // Match a well-formed tag (name first, no nested angle brackets) OR a lone - // '<'/'>'. Matching lone brackets is what keeps a stray '<' in the changelog - // (e.g. "原始配置 < Merge") from greedily swallowing the next real tag and - // leaving its closing tag orphaned ("Unexpected end tag"). + // Match tags or lone brackets separately so stray changelog text cannot swallow a real tag. return content.replace(/<\/?[a-z][^<>]*>|[<>]/gi, (token) => { if (token === '<') return '<' if (token === '>') return '>' - // Keep supported tags; escape everything else so it displays as text. return allowedTag.test(token) ? token : token.replace(//g, '>') @@ -118,7 +109,6 @@ async function sendTelegramNotification() { const releaseTag = isAutobuild ? 'autobuild' : `v${version}` const content = `🎉 Clash Verge Rev v${version} ${releaseTitle}\n\n${formattedContent}` - // 发送到 Telegram try { await axios.post( `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, @@ -144,7 +134,6 @@ async function sendTelegramNotification() { } } -// 执行函数 sendTelegramNotification().catch((error) => { log_error('脚本执行失败:', error) process.exit(1) diff --git a/scripts/updater-fixed-webview2.mjs b/scripts/updater-fixed-webview2.mjs index e890988e3..07dc3fd23 100644 --- a/scripts/updater-fixed-webview2.mjs +++ b/scripts/updater-fixed-webview2.mjs @@ -6,8 +6,6 @@ const UPDATE_TAG_NAME = 'updater' const UPDATE_JSON_FILE = 'update-fixed-webview2.json' const UPDATE_JSON_PROXY = 'update-fixed-webview2-proxy.json' -/// generate update.json -/// upload to update tag's release asset async function resolveUpdater() { if (process.env.GITHUB_TOKEN === undefined) { throw new Error('GITHUB_TOKEN is required') @@ -22,7 +20,6 @@ async function resolveUpdater() { page: 1, }) - // get the latest publish tag const tag = tags.find((t) => t.name.startsWith('v')) console.log(tag) @@ -48,33 +45,27 @@ async function resolveUpdater() { const promises = latestRelease.assets.map(async (asset) => { const { name, browser_download_url } = asset - // win64 url if (name.endsWith('x64_fixed_webview2-setup.exe')) { updateData.platforms['windows-x86_64'].url = browser_download_url } - // win64 signature if (name.endsWith('x64_fixed_webview2-setup.exe.sig')) { const sig = await getSignature(browser_download_url) updateData.platforms['windows-x86_64'].signature = sig } - // win32 url if (name.endsWith('x86_fixed_webview2-setup.exe')) { updateData.platforms['windows-x86'].url = browser_download_url updateData.platforms['windows-i686'].url = browser_download_url } - // win32 signature if (name.endsWith('x86_fixed_webview2-setup.exe.sig')) { const sig = await getSignature(browser_download_url) updateData.platforms['windows-x86'].signature = sig updateData.platforms['windows-i686'].signature = sig } - // win arm url if (name.endsWith('arm64_fixed_webview2-setup.exe')) { updateData.platforms['windows-aarch64'].url = browser_download_url } - // win arm signature if (name.endsWith('arm64_fixed_webview2-setup.exe.sig')) { const sig = await getSignature(browser_download_url) updateData.platforms['windows-aarch64'].signature = sig @@ -84,8 +75,6 @@ async function resolveUpdater() { await Promise.allSettled(promises) console.log(updateData) - // maybe should test the signature as well - // delete the null field Object.entries(updateData.platforms).forEach(([key, value]) => { if (!value.url) { console.log(`[Error]: failed to parse release for "${key}"`) @@ -93,8 +82,6 @@ async function resolveUpdater() { } }) - // 生成一个代理github的更新文件 - // 使用 https://hub.fastgit.xyz/ 做github资源的加速 const updateDataNew = JSON.parse(JSON.stringify(updateData)) Object.entries(updateDataNew.platforms).forEach(([key, value]) => { @@ -105,13 +92,11 @@ async function resolveUpdater() { } }) - // update the update.json const { data: updateRelease } = await github.rest.repos.getReleaseByTag({ ...options, tag: UPDATE_TAG_NAME, }) - // delete the old assets for (const asset of updateRelease.assets) { if (asset.name === UPDATE_JSON_FILE) { await github.rest.repos.deleteReleaseAsset({ @@ -127,7 +112,6 @@ async function resolveUpdater() { } } - // upload new assets await github.rest.repos.uploadReleaseAsset({ ...options, release_id: updateRelease.id, @@ -143,7 +127,6 @@ async function resolveUpdater() { }) } -// get the signature file content async function getSignature(url) { const response = await fetch(url, { method: 'GET', diff --git a/scripts/updater.mjs b/scripts/updater.mjs index c59d2b647..70c05d0a2 100644 --- a/scripts/updater.mjs +++ b/scripts/updater.mjs @@ -2,17 +2,13 @@ import { context, getOctokit } from '@actions/github' import { resolveUpdateLog, resolveUpdateLogDefault } from './updatelog.mjs' -// Add stable update JSON filenames const UPDATE_TAG_NAME = 'updater' const UPDATE_JSON_FILE = 'update.json' const UPDATE_JSON_PROXY = 'update-proxy.json' -// Add alpha update JSON filenames const ALPHA_TAG_NAME = 'updater-alpha' const ALPHA_UPDATE_JSON_FILE = 'update.json' const ALPHA_UPDATE_JSON_PROXY = 'update-proxy.json' -/// generate update.json -/// upload to update tag's release asset async function resolveUpdater() { if (process.env.GITHUB_TOKEN === undefined) { throw new Error('GITHUB_TOKEN is required') @@ -21,7 +17,6 @@ async function resolveUpdater() { const options = { owner: context.repo.owner, repo: context.repo.repo } const github = getOctokit(process.env.GITHUB_TOKEN) - // Fetch all tags using pagination let allTags = [] let page = 1 const perPage = 100 @@ -35,7 +30,6 @@ async function resolveUpdater() { allTags = allTags.concat(pageTags) - // Break if we received fewer tags than requested (last page) if (pageTags.length < perPage) { break } @@ -46,12 +40,9 @@ async function resolveUpdater() { const tags = allTags console.log(`Retrieved ${tags.length} tags in total`) - // More flexible tag detection with regex patterns - const stableTagRegex = /^v\d+\.\d+\.\d+$/ // Matches vX.Y.Z format - // const preReleaseRegex = /^v\d+\.\d+\.\d+-(alpha|beta|rc|pre)/i; // Matches vX.Y.Z-alpha/beta/rc format - const preReleaseRegex = /^(alpha|beta|rc|pre)$/i // Matches exact alpha/beta/rc/pre tags + const stableTagRegex = /^v\d+\.\d+\.\d+$/ + const preReleaseRegex = /^(alpha|beta|rc|pre)$/i - // Get the latest stable tag and pre-release tag const stableTag = tags.find((t) => stableTagRegex.test(t.name)) const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name)) @@ -63,18 +54,15 @@ async function resolveUpdater() { ) console.log() - // Process stable release if (stableTag) { await processRelease(github, options, stableTag, false) } - // Process pre-release if found if (preReleaseTag) { await processRelease(github, options, preReleaseTag, true) } } -// Process a release (stable or alpha) and generate update files async function processRelease(github, options, tag, isAlpha) { if (!tag) return @@ -129,67 +117,55 @@ async function processRelease(github, options, tag, isAlpha) { const promises = release.assets.map(async (asset) => { const { name, browser_download_url } = asset - // Process all the platform URL and signature data - // win64 url if (name.endsWith('x64-setup.exe')) { updateData.platforms['windows-x86_64'].url = browser_download_url updateData.platforms['windows-x86_64-nsis'].url = browser_download_url } - // win64 signature if (name.endsWith('x64-setup.exe.sig')) { const sig = await getSignature(browser_download_url) updateData.platforms['windows-x86_64'].signature = sig updateData.platforms['windows-x86_64-nsis'].signature = sig } - // win32 url if (name.endsWith('x86-setup.exe')) { updateData.platforms['windows-x86'].url = browser_download_url updateData.platforms['windows-x86-nsis'].url = browser_download_url updateData.platforms['windows-i686-nsis'].url = browser_download_url } - // win32 signature if (name.endsWith('x86-setup.exe.sig')) { const sig = await getSignature(browser_download_url) updateData.platforms['windows-x86'].signature = sig updateData.platforms['windows-x86-nsis'].signature = sig updateData.platforms['windows-i686-nsis'].signature = sig } - // win arm url if (name.endsWith('arm64-setup.exe')) { updateData.platforms['windows-aarch64'].url = browser_download_url updateData.platforms['windows-aarch64-nsis'].url = browser_download_url } - // win arm signature if (name.endsWith('arm64-setup.exe.sig')) { const sig = await getSignature(browser_download_url) updateData.platforms['windows-aarch64'].signature = sig updateData.platforms['windows-aarch64-nsis'].signature = sig } - // darwin url (intel) if (name.endsWith('.app.tar.gz') && !name.includes('aarch')) { updateData.platforms['darwin-x86_64'].url = browser_download_url updateData.platforms['darwin-x86_64-app'].url = browser_download_url } - // darwin signature (intel) if (name.endsWith('.app.tar.gz.sig') && !name.includes('aarch')) { const sig = await getSignature(browser_download_url) updateData.platforms['darwin-x86_64'].signature = sig updateData.platforms['darwin-x86_64-app'].signature = sig } - // darwin url (aarch) if (name.endsWith('aarch64.app.tar.gz')) { updateData.platforms['darwin-aarch64'].url = browser_download_url updateData.platforms['darwin-aarch64-app'].url = browser_download_url } - // darwin signature (aarch) if (name.endsWith('aarch64.app.tar.gz.sig')) { const sig = await getSignature(browser_download_url) updateData.platforms['darwin-aarch64'].signature = sig updateData.platforms['darwin-aarch64-app'].signature = sig } - // Linux x86 if (name.endsWith('i386.deb')) { updateData.platforms['linux-x86'].url = browser_download_url updateData.platforms['linux-x86-deb'].url = browser_download_url @@ -213,7 +189,6 @@ async function processRelease(github, options, tag, isAlpha) { updateData.platforms['linux-i686-rpm'].signature = sig } - // Linux x86_64 if (name.endsWith('amd64.deb')) { updateData.platforms['linux-x86_64'].url = browser_download_url updateData.platforms['linux-x86_64-deb'].url = browser_download_url @@ -231,7 +206,6 @@ async function processRelease(github, options, tag, isAlpha) { updateData.platforms['linux-x86_64-rpm'].signature = sig } - // Linux aarch64 if (name.endsWith('arm64.deb')) { updateData.platforms['linux-aarch64'].url = browser_download_url updateData.platforms['linux-aarch64-deb'].url = browser_download_url @@ -249,7 +223,6 @@ async function processRelease(github, options, tag, isAlpha) { updateData.platforms['linux-aarch64-rpm'].signature = sig } - // Linux armv7 if (name.endsWith('armhf.deb')) { updateData.platforms['linux-armv7'].url = browser_download_url updateData.platforms['linux-armv7-deb'].url = browser_download_url @@ -271,8 +244,6 @@ async function processRelease(github, options, tag, isAlpha) { await Promise.allSettled(promises) console.log(updateData) - // maybe should test the signature as well - // delete the null field Object.entries(updateData.platforms).forEach(([key, value]) => { if (!value.url) { console.log(`[Error]: failed to parse release for "${key}"`) @@ -280,7 +251,6 @@ async function processRelease(github, options, tag, isAlpha) { } }) - // Generate a proxy update file for accelerated GitHub resources const updateDataNew = JSON.parse(JSON.stringify(updateData)) Object.entries(updateDataNew.platforms).forEach(([key, value]) => { @@ -292,7 +262,6 @@ async function processRelease(github, options, tag, isAlpha) { } }) - // Get the appropriate updater release based on isAlpha flag const releaseTag = isAlpha ? ALPHA_TAG_NAME : UPDATE_TAG_NAME console.log( `Processing ${isAlpha ? 'alpha' : 'stable'} release:`, @@ -303,7 +272,6 @@ async function processRelease(github, options, tag, isAlpha) { let updateRelease try { - // Try to get the existing release const response = await github.rest.repos.getReleaseByTag({ ...options, tag: releaseTag, @@ -313,7 +281,6 @@ async function processRelease(github, options, tag, isAlpha) { `Found existing ${releaseTag} release with ID: ${updateRelease.id}`, ) } catch (error) { - // If release doesn't exist, create it if (error.status === 404) { console.log( `Release with tag ${releaseTag} not found, creating new release...`, @@ -332,16 +299,13 @@ async function processRelease(github, options, tag, isAlpha) { `Created new ${releaseTag} release with ID: ${updateRelease.id}`, ) } else { - // If it's another error, throw it throw error } } - // File names based on release type const jsonFile = isAlpha ? ALPHA_UPDATE_JSON_FILE : UPDATE_JSON_FILE const proxyFile = isAlpha ? ALPHA_UPDATE_JSON_PROXY : UPDATE_JSON_PROXY - // Delete existing assets with these names for (const asset of updateRelease.assets) { if (asset.name === jsonFile) { await github.rest.repos.deleteReleaseAsset({ @@ -357,7 +321,6 @@ async function processRelease(github, options, tag, isAlpha) { } } - // Upload new assets await github.rest.repos.uploadReleaseAsset({ ...options, release_id: updateRelease.id, @@ -390,7 +353,6 @@ async function processRelease(github, options, tag, isAlpha) { } } -// get the signature file content async function getSignature(url) { const response = await fetch(url, { method: 'GET', diff --git a/src-tauri/src/cmd/app.rs b/src-tauri/src/cmd/app.rs index 75ad8d624..ed0fe8d62 100644 --- a/src-tauri/src/cmd/app.rs +++ b/src-tauri/src/cmd/app.rs @@ -4,14 +4,12 @@ use crate::{cmd::StringifyErr as _, feat, utils::dirs}; use smartstring::alias::String; use tauri::{AppHandle, Manager as _}; -/// 打开应用程序所在目录 #[tauri::command] pub async fn open_app_dir() -> CmdResult<()> { let app_dir = dirs::app_home_dir().stringify_err()?; open::that(app_dir).stringify_err() } -/// 打开核心所在目录 #[tauri::command] pub async fn open_core_dir() -> CmdResult<()> { let core_dir = tauri::utils::platform::current_exe().stringify_err()?; @@ -19,20 +17,17 @@ pub async fn open_core_dir() -> CmdResult<()> { open::that(core_dir).stringify_err() } -/// 打开日志目录 #[tauri::command] pub async fn open_logs_dir() -> CmdResult<()> { let log_dir = dirs::app_logs_dir().stringify_err()?; open::that(log_dir).stringify_err() } -/// 打开网页链接 #[tauri::command] pub fn open_web_url(url: String) -> CmdResult<()> { open::that(url.as_str()).stringify_err() } -/// 打开/关闭开发者工具 #[tauri::command] pub fn open_devtools(app_handle: AppHandle) { if let Some(window) = app_handle.get_webview_window("main") { @@ -44,45 +39,38 @@ pub fn open_devtools(app_handle: AppHandle) { } } -/// 退出应用 #[tauri::command] pub async fn exit_app() { feat::quit().await; } -/// 重启应用 #[tauri::command] pub async fn restart_app() -> CmdResult<()> { feat::restart_app().await; Ok(()) } -/// 获取便携版标识 #[tauri::command] pub fn get_portable_flag() -> bool { *dirs::PORTABLE_FLAG.get().unwrap_or(&false) } -/// 获取应用目录 #[tauri::command] pub fn get_app_dir() -> CmdResult { let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into(); Ok(app_home_dir) } -/// 获取当前自启动状态 #[tauri::command] pub fn get_auto_launch_status() -> CmdResult { autostart::get_launch_status().stringify_err() } -/// 下载图标缓存 #[tauri::command] pub async fn download_icon_cache(url: String, name: String) -> CmdResult { feat::download_icon_cache(url, name).await } -/// 复制图标文件 #[tauri::command] pub async fn copy_icon_file(path: String, icon_info: feat::IconInfo) -> CmdResult { feat::copy_icon_file(path, icon_info).await diff --git a/src-tauri/src/cmd/backup.rs b/src-tauri/src/cmd/backup.rs index ccd651271..52aa2f0d9 100644 --- a/src-tauri/src/cmd/backup.rs +++ b/src-tauri/src/cmd/backup.rs @@ -3,25 +3,21 @@ use crate::{cmd::StringifyErr as _, feat}; use feat::LocalBackupFile; use smartstring::alias::String; -/// Create a local backup #[tauri::command] pub async fn create_local_backup() -> CmdResult<()> { feat::create_local_backup().await.stringify_err() } -/// List local backups #[tauri::command] pub async fn list_local_backup() -> CmdResult> { feat::list_local_backup().await.stringify_err() } -/// Delete local backup #[tauri::command] pub async fn delete_local_backup(filename: String) -> CmdResult<()> { feat::delete_local_backup(filename).await.stringify_err() } -/// Restore local backup #[tauri::command] pub async fn restore_local_backup(filename: String) -> CmdResult<()> { feat::restore_local_backup(filename) @@ -29,13 +25,11 @@ pub async fn restore_local_backup(filename: String) -> CmdResult<()> { .map_err(|error| super::proxy_aware_error(&error)) } -/// Import local backup into the app's backup directory #[tauri::command] pub async fn import_local_backup(source: String) -> CmdResult { feat::import_local_backup(source).await.stringify_err() } -/// Export local backup to a user selected destination #[tauri::command] pub async fn export_local_backup(filename: String, destination: String) -> CmdResult<()> { feat::export_local_backup(filename, destination).await.stringify_err() diff --git a/src-tauri/src/cmd/clash.rs b/src-tauri/src/cmd/clash.rs index c93c38a5c..532ef7ebb 100644 --- a/src-tauri/src/cmd/clash.rs +++ b/src-tauri/src/cmd/clash.rs @@ -16,20 +16,17 @@ use serde_yaml_ng::Mapping; use smartstring::alias::String; use tokio::fs; -/// 复制Clash环境变量 #[tauri::command] pub async fn copy_clash_env() -> CmdResult { feat::copy_clash_env().await; Ok(()) } -/// 获取Clash信息 #[tauri::command] pub async fn get_clash_info() -> CmdResult { Ok(Config::clash().await.data_arc().get_client_info()) } -/// 修改Clash配置 #[tauri::command] pub async fn patch_clash_config(payload: Mapping) -> CmdResult { feat::patch_clash(&payload) @@ -37,10 +34,6 @@ pub async fn patch_clash_config(payload: Mapping) -> CmdResult { .map_err(|error| proxy_aware_coded_error(&error, "CLASH_CONFIG_UPDATE_FAILED")) } -/// 修改Clash模式 -/// -/// 将 `change_clash_mode` 的失败上抛给前端,使前端 `catch` 能真正感知后端 PATCH 失败 -/// 并提示用户(此前命令始终返回 `Ok(())`,吞掉了后端错误)。 #[tauri::command] pub async fn patch_clash_mode(payload: String) -> CmdResult { feat::change_clash_mode(payload) @@ -48,16 +41,12 @@ pub async fn patch_clash_mode(payload: String) -> CmdResult { .with_error_code("CLASH_MODE_UPDATE_FAILED") } -/// 获取当前 Clash 模式(容错读取) -/// -/// 直接读取已保存的 clash 配置中的 `mode`,绕开 mihomo `/configs` 的严格 -/// `BaseConfig` 反序列化,作为主页 mode 显示的兜底来源。 +/// Reads the saved mode without depending on strict mihomo `/configs` deserialization. #[tauri::command] pub async fn get_clash_mode() -> CmdResult> { Ok(Config::clash().await.data_arc().get_mode().map(Into::into)) } -/// 切换Clash核心 #[tauri::command] pub async fn change_clash_core(clash_core: String) -> CmdResult> { logging!(info, Type::Config, "changing core to {clash_core}"); @@ -66,7 +55,6 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult { logging_error!(Type::Core, profiles_save_file_safe().await); - // 切换内核后重启内核 match CoreManager::global().restart_core().await { Ok(_) => { logging!(info, Type::Core, "core changed and restarted to {clash_core}"); @@ -92,7 +80,6 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult CmdResult { let result = CoreManager::global() @@ -105,7 +92,6 @@ pub async fn start_core() -> CmdResult { result } -/// 关闭核心 #[tauri::command] pub async fn stop_core() -> CmdResult { logging_error!(Type::Core, profiles_save_file_safe().await); @@ -119,7 +105,6 @@ pub async fn stop_core() -> CmdResult { result } -/// 重启核心 #[tauri::command] pub async fn restart_core() -> CmdResult { logging_error!(Type::Core, profiles_save_file_safe().await); @@ -133,7 +118,6 @@ pub async fn restart_core() -> CmdResult { result } -/// 测试URL延迟 #[tauri::command] pub async fn test_delay(url: String) -> CmdResult { let result = match feat::test_delay(url).await { @@ -146,16 +130,13 @@ pub async fn test_delay(url: String) -> CmdResult { Ok(result) } -/// 保存DNS配置到单独文件 #[tauri::command] pub async fn save_dns_config(dns_config: Mapping) -> CmdResult { use crate::utils::dirs; use tokio::fs; - // 获取DNS配置文件路径 let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG); - // 保存DNS配置到文件 let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config).stringify_err()?; fs::write(&dns_path, yaml_str).await.stringify_err()?; logging!(info, Type::Config, "DNS config saved to {dns_path:?}"); @@ -163,11 +144,9 @@ pub async fn save_dns_config(dns_config: Mapping) -> CmdResult { Ok(()) } -/// 应用或撤销DNS配置 #[tauri::command] pub async fn apply_dns_config(apply: bool) -> CmdResult { if apply { - // 读取DNS配置文件 let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG); if !dns_path.exists() { @@ -179,23 +158,19 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult { logging!(error, Type::Config, "Failed to read DNS config: {e}"); })?; - // 解析DNS配置 let patch_config = serde_yaml_ng::from_str::(&dns_yaml).stringify_err_log(|e| { logging!(error, Type::Config, "Failed to parse DNS config: {e}"); })?; logging!(info, Type::Config, "Applying DNS config from file"); - // 创建包含DNS配置的patch let mut patch = serde_yaml_ng::Mapping::new(); patch.insert("dns".into(), patch_config.into()); - // 应用DNS配置到运行时配置 Config::runtime().await.edit_draft(|d| { d.patch_config(&patch); }); - // 应用新配置 CoreManager::global().update_config_checked().await.map_err(|error| { logging!(error, Type::Config, "failed to apply the config with DNS: {error:#}"); proxy_aware_error(&error) @@ -203,7 +178,6 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult { logging!(info, Type::Config, "DNS config successfully applied"); } else { - // 当关闭DNS设置时,重新生成配置(不加载DNS配置文件) logging!(info, Type::Config, "DNS settings disabled, regenerating config"); CoreManager::global().update_config_checked().await.map_err(|error| { @@ -218,7 +192,6 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult { Ok(()) } -/// 检查DNS配置文件是否存在 #[tauri::command] pub fn check_dns_config_exists() -> CmdResult { use crate::utils::dirs; @@ -228,7 +201,6 @@ pub fn check_dns_config_exists() -> CmdResult { Ok(dns_path.exists()) } -/// 获取DNS配置文件内容 #[tauri::command] pub async fn get_dns_config_content() -> CmdResult { use crate::utils::dirs; @@ -244,7 +216,6 @@ pub async fn get_dns_config_content() -> CmdResult { Ok(content) } -/// 验证DNS配置文件 #[tauri::command] pub async fn validate_dns_config() -> CmdResult { let app_dir = dirs::app_home_dir().stringify_err()?; diff --git a/src-tauri/src/cmd/network.rs b/src-tauri/src/cmd/network.rs index 0892a7281..faef3a080 100644 --- a/src-tauri/src/cmd/network.rs +++ b/src-tauri/src/cmd/network.rs @@ -8,7 +8,6 @@ use serde_yaml_ng::Mapping; use sysproxy::{Autoproxy, Sysproxy}; use tauri_plugin_clash_verge_sysinfo; -/// get the system proxy #[tauri::command] pub async fn get_sys_proxy() -> CmdResult { logging!(debug, Type::Network, "异步获取系统代理配置"); @@ -38,7 +37,6 @@ pub async fn get_sys_proxy() -> CmdResult { Ok(map) } -/// 获取自动代理配置 #[tauri::command] pub async fn get_auto_proxy() -> CmdResult { Sysopt::global().wait_idle().await; @@ -64,28 +62,22 @@ pub fn get_embedded_server_port() -> CmdResult { crate::utils::server::embedded_server_port().stringify_err() } -/// 获取系统主机名 #[tauri::command] pub fn get_system_hostname() -> String { - // 获取系统主机名,处理可能的非UTF-8字符 match gethostname().into_string() { Ok(name) => name, Err(os_string) => { - // 对于包含非UTF-8的主机名,使用调试格式化 let fallback = format!("{os_string:?}"); - // 去掉可能存在的引号 fallback.trim_matches('"').to_string() } } } -/// 获取网络接口列表 #[tauri::command] pub fn get_network_interfaces() -> Vec { tauri_plugin_clash_verge_sysinfo::list_network_interfaces() } -/// 获取网络接口详细信息 #[tauri::command] pub fn get_network_interfaces_info() -> CmdResult> { use network_interface::{NetworkInterface, NetworkInterfaceConfig as _}; diff --git a/src-tauri/src/cmd/profile.rs b/src-tauri/src/cmd/profile.rs index db5cfe094..ef4de1d54 100644 --- a/src-tauri/src/cmd/profile.rs +++ b/src-tauri/src/cmd/profile.rs @@ -39,7 +39,6 @@ pub async fn get_profiles() -> CmdResult> { Ok(data) } -/// 增强配置文件 #[tauri::command] pub async fn enhance_profiles() -> CmdResult { match feat::enhance_profiles().await { @@ -64,12 +63,10 @@ pub async fn enhance_profiles() -> CmdResult { } } -/// 导入配置文件 #[tauri::command] pub async fn import_profile(url: std::string::String, option: Option) -> CmdResult { logging!(info, Type::Cmd, "[导入订阅] 开始导入: {}", help::mask_url(&url)); - // 直接依赖 PrfItem::from_url 自身的超时/重试逻辑,不再使用 tokio::time::timeout 包裹 let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await { Ok(it) => { logging!(info, Type::Cmd, "[导入订阅] 下载完成,开始保存配置"); @@ -102,7 +99,6 @@ pub async fn import_profile(url: std::string::String, option: Option) Ok(()) } -/// 调整profile的顺序 #[tauri::command] pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult { match profiles_reorder_safe(&active_id, &over_id).await { @@ -117,8 +113,6 @@ pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult { } } -/// 创建新的profile -/// 创建一个新的配置文件 #[tauri::command] pub async fn create_profile(item: PrfItem, file_data: Option) -> CmdResult { match profiles_append_item_with_filedata_safe(&item, file_data).await { @@ -127,7 +121,6 @@ pub async fn create_profile(item: PrfItem, file_data: Option) -> CmdResu .await .with_error_code("PROFILE_CREATE_FAILED")?; logging_error!(Type::Timer, Timer::global().refresh().await); - // 发送配置变更通知 if let Some(uid) = &item.uid { logging!(info, Type::Cmd, "[创建订阅] 发送配置变更通知: {}", uid); handle::Handle::notify_profile_changed(uid); @@ -138,7 +131,6 @@ pub async fn create_profile(item: PrfItem, file_data: Option) -> CmdResu } } -/// 更新配置文件 #[tauri::command] pub async fn update_profile(index: String, option: Option) -> CmdResult { match feat::update_profile(&index, option.as_ref(), true, true, true).await { @@ -150,7 +142,6 @@ pub async fn update_profile(index: String, option: Option) -> CmdResu } } -/// 删除配置文件 #[tauri::command] pub async fn delete_profile(index: String) -> CmdResult { let profile_write_guard = PROFILE_WRITE_LOCK.lock().await; @@ -209,7 +200,6 @@ pub async fn delete_profile(index: String) -> CmdResult { Ok(()) } -/// 执行配置更新并处理结果 async fn restore_previous_profile(prev_profile: &String) -> CmdResult<()> { logging!(info, Type::Cmd, "尝试恢复到之前的配置: {}", prev_profile); let restore_profiles = IProfiles { @@ -322,7 +312,6 @@ async fn perform_config_update( } } -/// 修改profiles的配置 #[tauri::command] pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult { if CURRENT_SWITCHING_PROFILE @@ -338,7 +327,6 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult CmdResult CmdResult { logging!(info, Type::Cmd, "切换配置到: {}", profile_index); @@ -360,10 +347,8 @@ pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> Cm patch_profiles_config(profiles).await } -/// 修改某个profile item的 #[tauri::command] pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult { - // 保存修改前检查是否有更新 update_interval let profiles = Config::profiles().await; let should_refresh_timer = if let Ok(old_profile) = profiles.latest_arc().get_item(&index) && let Some(new_option) = profile.option.as_ref() @@ -377,8 +362,7 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult { false }; - // A selection written from the UI or the chain proxy is newer than anything a restore still - // in flight captured; without this it would be pushed back to the older node moments later. + // Prevent an in-flight restore from overwriting a newer UI or chain selection. let records_a_selection = profile.selected.is_some(); profiles_patch_item_safe(&index, &profile) @@ -389,14 +373,12 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult { profiles::supersede_selected_activation(); } - // 如果更新间隔或允许自动更新变更,异步刷新定时器 if should_refresh_timer { crate::process::AsyncHandler::spawn(move || async move { logging!(info, Type::Timer, "Timer update settings changed, refreshing timer..."); if let Err(e) = crate::core::Timer::global().refresh().await { logging!(error, Type::Timer, "Failed to refresh timer: {}", e); } else { - // 刷新成功后发送自定义事件,不触发配置重载 crate::core::handle::Handle::notify_timer_updated(&index); } }); @@ -405,7 +387,6 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult { Ok(()) } -/// 查看配置文件 #[tauri::command] pub async fn view_profile(index: String) -> CmdResult { let profiles = Config::profiles().await; @@ -430,7 +411,6 @@ pub async fn view_profile(index: String) -> CmdResult { help::open_file(path).with_error_code("PROFILE_OPEN_FAILED") } -/// 读取配置文件内容 #[tauri::command] pub async fn read_profile_file(index: String) -> CmdResult { let item = { @@ -466,7 +446,6 @@ pub async fn read_profile_file(index: String) -> CmdResult { Ok(data) } -/// 获取下一次更新时间 #[tauri::command] pub async fn get_next_update_time(uid: String) -> CmdResult> { let timer = Timer::global(); diff --git a/src-tauri/src/cmd/proxy.rs b/src-tauri/src/cmd/proxy.rs index 8ca8a0a72..7cf33ffa2 100644 --- a/src-tauri/src/cmd/proxy.rs +++ b/src-tauri/src/cmd/proxy.rs @@ -16,15 +16,7 @@ use std::{ sync::atomic::{AtomicBool, Ordering}, }; -/// Record which node a group is on, in the current profile. -/// -/// Takes the group and the node rather than the whole selection list, and merges them into the -/// profile on this side. The frontend used to send the list it had rendered, which made two -/// selections made before that list refreshed into one overwriting the other: both were built -/// from the same stale array, and the later write dropped the earlier group's choice. Since a -/// core start re-applies whatever the profile holds, the dropped one then came back on restart. -/// -/// The tray already recorded this way. Now there is one way. +/// Merges one selection pair into fresh backend state to avoid stale-list overwrites. #[tauri::command] pub async fn record_selected_node(group_name: String, node: String) -> CmdResult<()> { crate::config::profiles::record_selected_node(&group_name, &node) @@ -32,7 +24,6 @@ pub async fn record_selected_node(group_name: String, node: String) -> CmdResult .stringify_err() } -/// Forget the persisted node selection for a proxy group in the current profile. #[tauri::command] pub async fn forget_selected_node(group_name: String) -> CmdResult<()> { crate::config::profiles::forget_selected_node(&group_name) @@ -76,7 +67,6 @@ pub async fn get_proxy_view() -> CmdResult { })) } -/// 同步托盘和GUI的代理选择状态 #[tauri::command] pub async fn sync_tray_proxy_selection() -> CmdResult<()> { if TRAY_SYNC_RUNNING diff --git a/src-tauri/src/cmd/runtime.rs b/src-tauri/src/cmd/runtime.rs index b9c88471d..644a98b2b 100644 --- a/src-tauri/src/cmd/runtime.rs +++ b/src-tauri/src/cmd/runtime.rs @@ -6,13 +6,11 @@ use serde_yaml_ng::Mapping; use smartstring::alias::String; use std::collections::{HashMap, HashSet}; -/// 获取运行时配置 #[tauri::command] pub async fn get_runtime_config() -> CmdResult> { Ok(Config::runtime().await.latest_arc().config.clone()) } -/// 获取运行时YAML配置 #[tauri::command] pub async fn get_runtime_yaml() -> CmdResult { let runtime = Config::runtime().await; @@ -29,13 +27,11 @@ pub async fn get_runtime_yaml() -> CmdResult { .stringify_err() } -/// 获取运行时存在的键 #[tauri::command] pub async fn get_runtime_exists() -> CmdResult> { Ok(Config::runtime().await.latest_arc().exists_keys.clone()) } -/// 获取运行时日志 #[tauri::command] pub async fn get_runtime_logs() -> CmdResult>> { Ok(Config::runtime().await.latest_arc().chain_logs.clone()) @@ -72,7 +68,6 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm .find(|proxy| proxy.get("name").map(|x| x.as_str()) == proxy_name) && !proxies_chain.is_empty() { - // 添加第一个节点 proxies_chain.push(entry_proxy.to_owned()); } @@ -91,7 +86,6 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm } } -/// 更新运行时链式代理配置 #[tauri::command] pub async fn update_proxy_chain_config_in_runtime(proxy_chain_config: Option) -> CmdResult<()> { match CoreManager::global() diff --git a/src-tauri/src/cmd/system.rs b/src-tauri/src/cmd/system.rs index fc309ee5c..dc5ac6cf8 100644 --- a/src-tauri/src/cmd/system.rs +++ b/src-tauri/src/cmd/system.rs @@ -3,17 +3,12 @@ use crate::core::{ runstate::{RUN_STATE, RunStateView}, }; -/// The whole Run State in one call. -/// -/// Replaces the separate running-mode and service-install-state commands: they were three -/// caches on three refresh intervals describing one fact, which the frontend then had to keep -/// coherent by hand. +/// Returns one coherent core/service snapshot instead of independently refreshed state. #[tauri::command] pub async fn get_runtime_state() -> Result { Ok(RUN_STATE.settled().await.to_view()) } -/// Return unresolved failures without consuming them. #[tauri::command] pub async fn get_pending_failures() -> Vec { notification::pending_failures() diff --git a/src-tauri/src/cmd/uwp.rs b/src-tauri/src/cmd/uwp.rs index d87254880..5337a5a7b 100644 --- a/src-tauri/src/cmd/uwp.rs +++ b/src-tauri/src/cmd/uwp.rs @@ -1,6 +1,5 @@ use crate::cmd::CmdResult; -/// Platform-specific implementation for UWP functionality #[cfg(windows)] mod platform { use crate::cmd::CmdResult; @@ -12,7 +11,6 @@ mod platform { } } -/// Stub implementation for non-Windows platforms #[cfg(not(windows))] mod platform { use super::CmdResult; @@ -23,7 +21,6 @@ mod platform { } } -/// Command exposed to Tauri #[tauri::command] pub async fn invoke_uwp_tool() -> CmdResult { platform::invoke_uwp_tool() diff --git a/src-tauri/src/cmd/webdav.rs b/src-tauri/src/cmd/webdav.rs index 27f0ee23d..f4a1e6f25 100644 --- a/src-tauri/src/cmd/webdav.rs +++ b/src-tauri/src/cmd/webdav.rs @@ -7,7 +7,6 @@ use crate::{ use reqwest_dav::list_cmd::ListFile; use smartstring::alias::String; -/// 保存 WebDAV 配置 #[tauri::command] pub async fn save_webdav_config(url: String, username: String, password: String) -> CmdResult<()> { let patch = IVerge { @@ -25,25 +24,21 @@ pub async fn save_webdav_config(url: String, username: String, password: String) Ok(()) } -/// 创建 WebDAV 备份并上传 #[tauri::command] pub async fn create_webdav_backup() -> CmdResult<()> { feat::create_backup_and_upload_webdav().await.stringify_err() } -/// 列出 WebDAV 上的备份文件 #[tauri::command] pub async fn list_webdav_backup() -> CmdResult> { feat::list_wevdav_backup().await.stringify_err() } -/// 删除 WebDAV 上的备份文件 #[tauri::command] pub async fn delete_webdav_backup(filename: String) -> CmdResult<()> { feat::delete_webdav_backup(filename).await.stringify_err() } -/// 从 WebDAV 恢复备份文件 #[tauri::command] pub async fn restore_webdav_backup(filename: String) -> CmdResult<()> { feat::restore_webdav_backup(filename) diff --git a/src-tauri/src/config/config.rs b/src-tauri/src/config/config.rs index 07ec717e3..c07ed48c3 100644 --- a/src-tauri/src/config/config.rs +++ b/src-tauri/src/config/config.rs @@ -68,12 +68,11 @@ impl Config { Self::global().await.runtime_config.clone() } - /// Serialize transactions that share configuration draft layers. + /// Serializes transactions sharing configuration draft layers. pub(crate) async fn lock_config_write() -> MutexGuard<'static, ()> { CONFIG_WRITE_LOCK.lock().await } - /// 初始化订阅 pub async fn init_config() -> Result<()> { Self::init_config_before_window().await?; Self::init_runtime_config().await @@ -140,14 +139,12 @@ impl Config { { let profiles = Self::profiles().await.data_arc(); - // Logging error internally let _ = profiles.cleanup_orphaned_files().await; } Ok(()) } - // Ensure "Merge" and "Script" profile items exist, adding them if missing. async fn ensure_default_profile_items() -> Result<()> { let profiles = Self::profiles().await; Self::ensure_default_profile_items_for(&profiles).await @@ -175,7 +172,6 @@ impl Config { } async fn generate_and_validate() -> Result> { - // 生成运行时配置 if let Err(err) = Self::generate().await { let error_msg: String = err.to_string().into(); logging!(error, Type::Config, "生成运行时配置失败: {}", error_msg); @@ -186,18 +182,14 @@ impl Config { } logging!(info, Type::Config, "生成运行时配置成功"); - // 生成运行时配置文件并验证 let config_result = Self::generate_file(ConfigType::Run).await; if config_result.is_ok() { - // 验证配置文件 logging!(info, Type::Config, "开始验证配置"); match CoreConfigValidator::global().validate_config_outcome().await { Ok(outcome) if outcome.is_valid() => { logging!(info, Type::Config, "配置验证成功"); - // 前端没有必要知道验证成功的消息,也没有事件驱动 - // Some(("config_validate::success", String::new())) Ok(None) } Ok(outcome) => { @@ -238,7 +230,6 @@ impl Config { let runtime = Self::runtime().await; let runtime_lastest = runtime.latest_arc(); - // Fall back to committed config if runtime config is missing let runtime_data = runtime.data_arc(); let config = runtime_lastest .config @@ -259,8 +250,7 @@ impl Config { let (mut config, exists_keys, logs) = enhance::enhance(profiles).await?; sanitize_tunnels_proxy(&mut config); - // Applied here rather than in the Merge Config: this is the only place the Core's config - // is built, and `config.yaml` must stay unaware so the next launch asks for the original. + // Apply only to generated core config so the saved choice survives the next launch. if let Some(port) = MixedPort::session_fallback() { config.insert(MIXED_PORT_KEY.into(), port.into()); } @@ -300,8 +290,7 @@ impl Config { } } - // 升级草稿为正式数据,并写入文件。避免用户行为丢失。 - // 仅在应用退出、重启、关机监听事件启用 + /// Commits drafts during exit/restart/shutdown so user changes are not lost. pub async fn apply_all_and_save_file() { logging!(info, Type::Config, "save all draft data"); let save_clash_task = AsyncHandler::spawn(|| async { @@ -329,7 +318,6 @@ impl Config { } fn sanitize_tunnels_proxy(config: &mut Mapping) { - // 检查是否存在 tunnels if !config .get("tunnels") .and_then(|v| v.as_sequence()) @@ -338,7 +326,6 @@ fn sanitize_tunnels_proxy(config: &mut Mapping) { return; } - // 在需要时,收集可用目标(proxies + proxy-groups + 内建) let mut valid: HashSet = HashSet::with_capacity(64); collect_names(config, "proxies", &mut valid); collect_names(config, "proxy-groups", &mut valid); @@ -350,7 +337,6 @@ fn sanitize_tunnels_proxy(config: &mut Mapping) { return; }; - // 修改 tunnels:删除无效 proxy for item in tunnels { let Some(tunnel) = item.as_mapping_mut() else { continue }; @@ -368,7 +354,6 @@ fn sanitize_tunnels_proxy(config: &mut Mapping) { } } -// tunnels 存在且至少有一条 tunnel 的 proxy 需要校验时才返回 true fn tunnels_need_validation(tunnels: &[Value]) -> bool { tunnels.iter().any(|item| { item.as_mapping() @@ -411,11 +396,8 @@ mod tests { fn test_prfitem_from_merge_size() { let merge_item = PrfItem::from_merge(Some("Merge".into())).expect("Failed to create merge item in test"); let prfitem_size = mem::size_of_val(&merge_item); - // Boxed version let boxed_merge_item = Box::new(merge_item); let box_prfitem_size = mem::size_of_val(&boxed_merge_item); - // The size of Box is always pointer-sized (usually 8 bytes on 64-bit) - // assert_eq!(box_prfitem_size, mem::size_of::>()); assert!(box_prfitem_size < prfitem_size); } diff --git a/src-tauri/src/config/encrypt.rs b/src-tauri/src/config/encrypt.rs index 63fbb46a0..cd2717207 100644 --- a/src-tauri/src/config/encrypt.rs +++ b/src-tauri/src/config/encrypt.rs @@ -15,41 +15,33 @@ tokio::task_local! { static ENCRYPTION_ACTIVE: Cell; } -/// Encrypt data pub fn encrypt_data(data: &str) -> Result> { let encryption_key = get_encryption_key()?; let cipher = Aes256Gcm::new_from_slice(&encryption_key)?; - // Generate random nonce let mut nonce = [0u8; NONCE_LENGTH]; getrandom::fill(&mut nonce)?; - // Encrypt data let ciphertext = cipher .encrypt((&nonce).into(), data.as_bytes()) .map_err(|e| format!("Encryption failed: {e}"))?; - // Concatenate nonce and ciphertext and encode them in base64 let mut combined = nonce.to_vec(); combined.extend(ciphertext); Ok(STANDARD.encode(combined)) } -/// Decrypt data pub fn decrypt_data(encrypted: &str) -> Result> { let encryption_key = get_encryption_key()?; let cipher = Aes256Gcm::new_from_slice(&encryption_key)?; - // Decode from base64 let data = STANDARD.decode(encrypted)?; if data.len() < NONCE_LENGTH { return Err("Invalid encrypted data".into()); } - // Separate nonce and ciphertext let (nonce, ciphertext) = data.split_at(NONCE_LENGTH); let nonce: &[u8; NONCE_LENGTH] = nonce.try_into()?; - // Decrypt data let plaintext = cipher .decrypt(nonce.into(), ciphertext) .map_err(|e| format!("Decryption failed: {e}"))?; @@ -57,7 +49,6 @@ pub fn decrypt_data(encrypted: &str) -> Result(value: &T, serializer: S) -> Result where T: Serialize, @@ -72,7 +63,6 @@ where } } -/// Deserialize decrypted function pub fn deserialize_encrypted<'a, D, T>(deserializer: D) -> Result where T: for<'de> Deserialize<'de> + Default, diff --git a/src-tauri/src/config/mixed_port.rs b/src-tauri/src/config/mixed_port.rs index 4659c8c7b..b172fe7c6 100644 --- a/src-tauri/src/config/mixed_port.rs +++ b/src-tauri/src/config/mixed_port.rs @@ -1,8 +1,5 @@ -//! Resolving the Mixed Port. -//! -//! Two questions, deliberately kept apart because the answers can differ: -//! [`MixedPort::desired`] — what we configured, answerable before the Core exists; -//! [`MixedPort::effective`] — what the Core reports it is serving on. +//! Resolves the configured [`MixedPort::desired`] separately from the Core's live +//! [`MixedPort::effective`] value. use std::sync::atomic::{AtomicU16, Ordering}; @@ -14,18 +11,12 @@ use crate::core::handle::Handle; /// The port mihomo listens on when nothing else is configured. pub const DEFAULT_MIXED_PORT: u16 = 7897; -/// The port a startup fallback moved this session to, or 0 for none. -/// -/// Never persisted: the user's port stays in `verge.yaml` and `config.yaml` so the next launch -/// asks for it again. Persisting it instead made a once-blocked port climb on every launch. +/// A startup fallback for this session only; persistence would make the port climb across launches. static SESSION_FALLBACK: AtomicU16 = AtomicU16::new(0); -/// Resolving the Mixed Port. pub struct MixedPort; impl MixedPort { - /// Read by [`Self::desired`] and by runtime config generation — the two places that decide - /// what the Core is asked to listen on. pub(crate) fn session_fallback() -> Option { match SESSION_FALLBACK.load(Ordering::Acquire) { 0 => None, @@ -41,12 +32,7 @@ impl MixedPort { SESSION_FALLBACK.store(0, Ordering::Release); } - /// The Mixed Port the app has configured. Safe before the Core exists. - /// - /// Reads the draft layer, so this can name a port the Core has not moved to yet — which - /// matters because the PAC endpoint hands it outside the app. Safe only because - /// `feat::listener::save_proxy_ports` closes PAC across the staging (`core_starting` / - /// `core_start_settled`); any other path staging a listener port must do the same. + /// Returns the configured port. Paths staging it must close PAC until the Core catches up. pub async fn desired() -> u16 { let selected = Config::verge().await.latest_arc().verge_mixed_port; // `get_mixed_port` already falls back to the default when the Merge Config is silent. @@ -54,9 +40,7 @@ impl MixedPort { resolve_desired(Self::session_fallback(), selected, merged) } - /// The Mixed Port the Core is actually serving on. - /// - /// Costs a round-trip, so use it on user-triggered paths, not per-request ones. + /// Returns the Core's live port; reserve this round-trip for user-triggered paths. pub async fn effective() -> u16 { let desired = Self::desired().await; resolve_effective( @@ -67,8 +51,7 @@ impl MixedPort { } } -/// Fallback first — it is the only value not written down, so the files still name the port the -/// user asked for while this session serves another. +/// The session-only fallback outranks persisted choices. const fn resolve_desired(session_fallback: Option, selected: Option, merged: u16) -> u16 { match (session_fallback, selected) { (Some(port), _) | (None, Some(port)) => port, diff --git a/src-tauri/src/config/prfitem.rs b/src-tauri/src/config/prfitem.rs index 162afaa3d..a7257450d 100644 --- a/src-tauri/src/config/prfitem.rs +++ b/src-tauri/src/config/prfitem.rs @@ -20,45 +20,34 @@ use tauri::Url; pub struct PrfItem { pub uid: Option, - /// profile item type /// enum value: remote | local | script | merge #[serde(rename = "type")] pub itype: Option, - /// profile name pub name: Option, - /// profile file pub file: Option, - /// profile description #[serde(skip_serializing_if = "Option::is_none")] pub desc: Option, - /// source url #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, - /// selected information #[serde(skip_serializing_if = "Option::is_none")] pub selected: Option>, - /// subscription user info #[serde(skip_serializing_if = "Option::is_none")] pub extra: Option, - /// updated time pub updated: Option, - /// some options of the item #[serde(skip_serializing_if = "Option::is_none")] pub option: Option, - /// profile web page url #[serde(skip_serializing_if = "Option::is_none")] pub home: Option, - /// the file data #[serde(skip)] pub file_data: Option, } @@ -79,32 +68,23 @@ pub struct PrfExtra { #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct PrfOption { - /// for `remote` profile's http request - /// see issue #13 #[serde(skip_serializing_if = "Option::is_none")] pub user_agent: Option, - /// for `remote` profile - /// use system proxy #[serde(skip_serializing_if = "Option::is_none")] pub with_proxy: Option, - /// for `remote` profile - /// use self proxy #[serde(skip_serializing_if = "Option::is_none")] pub self_proxy: Option, #[serde(skip_serializing_if = "Option::is_none")] pub update_interval: Option, - /// for `remote` profile /// HTTP request timeout in seconds /// default is 60 seconds #[serde(skip_serializing_if = "Option::is_none")] pub timeout_seconds: Option, - /// for `remote` profile - /// disable certificate validation /// default is `false` #[serde(skip_serializing_if = "Option::is_none")] pub danger_accept_invalid_certs: Option, @@ -151,8 +131,7 @@ impl PrfOption { } impl PrfItem { - /// From partial item - /// must contain `itype` + /// Builds an item from a partial value that must include `itype`. pub async fn from(item: &Self, file_data: Option) -> Result { if item.itype.is_none() { bail!("type should not be null"); @@ -183,8 +162,6 @@ impl PrfItem { } } - /// ## Local type - /// create a new item from name/desc pub async fn from_local( name: String, desc: String, @@ -250,8 +227,6 @@ impl PrfItem { }) } - /// ## Remote type - /// create a new item from url pub async fn from_url( url: &str, name: Option<&String>, @@ -271,7 +246,6 @@ impl PrfItem { let mut proxies = option.and_then(|o| o.proxies.clone()); let mut groups = option.and_then(|o| o.groups.clone()); - // 选择代理类型 let proxy_type = if self_proxy { ProxyType::Localhost } else if with_proxy { @@ -282,7 +256,6 @@ impl PrfItem { let url = fix_dirty_url(url)?; - // 使用网络管理器发送请求 let resp = match NetworkManager::new() .get_with_interrupt( url.as_str(), @@ -307,7 +280,6 @@ impl PrfItem { let header = resp.headers(); - // parse the Subscription UserInfo let extra; 'extra: { for (k, v) in header.iter() { @@ -330,7 +302,6 @@ impl PrfItem { extra = None; } - // parse the Content-Disposition let filename = match header.get("Content-Disposition") { Some(value) => { let filename = format!("{value:?}"); @@ -380,10 +351,8 @@ impl PrfItem { .unwrap_or_else(|| filename.map(|s| s.into()).unwrap_or_else(|| "Remote File".into())); let data = resp.text_with_charset()?; - // process the charset "UTF-8 with BOM" let data = data.trim_start_matches('\u{feff}'); - // check the data whether the valid yaml format let yaml = serde_yaml_ng::from_str::(data).context("the remote profile data is invalid yaml")?; if !yaml.contains_key("proxies") && !yaml.contains_key("proxy-providers") { @@ -441,8 +410,6 @@ impl PrfItem { }) } - /// ## Merge type (enhance) - /// create the enhanced item by using `merge` rule pub fn from_merge(uid: Option) -> Result { let (id, template) = if let Some(uid) = uid { (uid, tmpl::ITEM_MERGE.into()) @@ -461,8 +428,6 @@ impl PrfItem { }) } - /// ## Script type (enhance) - /// create the enhanced item by using javascript quick.js pub fn from_script(uid: Option) -> Result { let id = if let Some(uid) = uid { uid @@ -480,7 +445,6 @@ impl PrfItem { }) } - /// ## Rules type (enhance) pub fn from_rules() -> Result { let uid = help::get_uid("r").into(); let file = format!("{uid}.yaml").into(); // yaml ext @@ -495,7 +459,6 @@ impl PrfItem { }) } - /// ## Proxies type (enhance) pub fn from_proxies() -> Result { let uid = help::get_uid("p").into(); let file = format!("{uid}.yaml").into(); // yaml ext @@ -510,7 +473,6 @@ impl PrfItem { }) } - /// ## Groups type (enhance) pub fn from_groups() -> Result { let uid = help::get_uid("g").into(); let file = format!("{uid}.yaml").into(); // yaml ext @@ -525,7 +487,6 @@ impl PrfItem { }) } - /// get the file data pub async fn read_file(&self) -> Result { let file = self .file @@ -538,7 +499,6 @@ impl PrfItem { Ok(content.into()) } - /// save the file data pub async fn save_file(&self, data: String) -> Result<()> { let file = self .file @@ -552,27 +512,22 @@ impl PrfItem { } impl PrfItem { - /// 获取current指向的订阅的merge pub fn current_merge(&self) -> Option<&String> { self.option.as_ref().and_then(|o| o.merge.as_ref()) } - /// 获取current指向的订阅的script pub fn current_script(&self) -> Option<&String> { self.option.as_ref().and_then(|o| o.script.as_ref()) } - /// 获取current指向的订阅的rules pub fn current_rules(&self) -> Option<&String> { self.option.as_ref().and_then(|o| o.rules.as_ref()) } - /// 获取current指向的订阅的proxies pub fn current_proxies(&self) -> Option<&String> { self.option.as_ref().and_then(|o| o.proxies.as_ref()) } - /// 获取current指向的订阅的groups pub fn current_groups(&self) -> Option<&String> { self.option.as_ref().and_then(|o| o.groups.as_ref()) } diff --git a/src-tauri/src/config/profiles.rs b/src-tauri/src/config/profiles.rs index 2ec4f8acf..86c7fd54d 100644 --- a/src-tauri/src/config/profiles.rs +++ b/src-tauri/src/config/profiles.rs @@ -30,19 +30,11 @@ use tokio::{fs, task::JoinHandle}; pub(crate) static PROFILE_WRITE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -/// Regex to check profile file names, eg. -/// R12345678.yaml (remote) -/// L12345678.yaml (local) -/// m12345678.yaml (merge) -/// s12345678.js (script) -/// r12345678.yaml (rules) -/// p12345678.yaml (proxies) -/// g12345678.yaml (groups) +/// Matches generated profile files such as `R12345678.yaml` or `s12345678.js`. #[allow(clippy::unwrap_used)] static REGEX_PROFILE_FILE: LazyLock = LazyLock::new(|| regex::Regex::new(r"^(?:[RLmrpg][a-zA-Z0-9]+\.yaml|s[a-zA-Z0-9]+\.js)$").unwrap()); -// activate selected nodes task handle static ACTIVATE_SELECTED_TASK: LazyLock>>> = LazyLock::new(|| Mutex::new(None)); static ACTIVATE_SELECTED_GENERATION: AtomicU64 = AtomicU64::new(0); @@ -50,29 +42,18 @@ static ACTIVATE_SELECTED_GENERATION: AtomicU64 = AtomicU64::new(0); // lock acquisition, connection-pool waiting, and local-socket connection establishment. const MIHOMO_OPERATION_TIMEOUT: Duration = Duration::from_secs(10); const SELECTED_NODES_RECHECK_DELAY: Duration = Duration::from_secs(1); -/// How long a restore keeps trying to put back selections the core has not loaded yet. -/// -/// A provider-backed group is present but empty until its provider finishes loading, which on a -/// cold start is exactly when restoring runs. Bounded rather than indefinite because a record -/// naming a node the profile genuinely no longer has looks identical from here, and would -/// otherwise be retried for the life of the process. +/// Bounds retries while provider-backed groups finish loading. const SELECTED_NODES_SETTLE_DEADLINE: Duration = Duration::from_secs(30); /// How often a restore looks again while waiting for those groups. const SELECTED_NODES_SETTLE_INTERVAL: Duration = Duration::from_secs(1); -/// How long a start waits for the selections that *can* be put back before carrying on. -/// -/// Covers a healthy core answering one query and a handful of selects. Past that the start -/// proceeds: the remaining groups need the core to finish loading, which is not something a -/// start can usefully wait for. +/// Bounds startup waiting; remaining selections continue restoring in the background. const SELECTED_NODES_FIRST_PASS_BUDGET: Duration = Duration::from_secs(3); /// Define the `profiles.yaml` schema #[derive(Default, Debug, Clone, Deserialize, Serialize)] pub struct IProfiles { - /// same as PrfConfig.current pub current: Option, - /// profile list pub items: Option>, } @@ -116,7 +97,6 @@ macro_rules! patch { } impl IProfiles { - // Helper to find and remove an item by uid from the items vec, returning its file name (if any). fn take_item_file_by_uid(items: &mut Vec, target_uid: Option<&str>) -> Option { let index = items.iter().position(|item| item.uid.as_deref() == target_uid)?; items.remove(index).file @@ -178,12 +158,10 @@ impl IProfiles { self.current.as_ref() } - /// get items ref pub const fn get_items(&self) -> Option<&Vec> { self.items.as_ref() } - /// find the item by the uid pub fn get_item(&self, uid: impl AsRef) -> Result<&PrfItem> { let uid_str = uid.as_ref(); @@ -200,17 +178,12 @@ impl IProfiles { bail!("failed to get the profile item \"uid:{}\"", uid_str); } - /// append new item - /// if the file_data is some - /// then should save the data to file pub async fn append_item(&mut self, item: &mut PrfItem) -> Result<()> { let uid = &item.uid; if uid.is_none() { bail!("the uid should not be null"); } - // save the file data - // move the field value after save if let Some(file_data) = item.file_data.take() { if item.file.is_none() { bail!("the file should not be null"); @@ -242,7 +215,6 @@ impl IProfiles { Ok(()) } - /// reorder items pub async fn reorder(&mut self, active_id: &String, over_id: &String) -> Result<()> { let mut items = self.items.take().unwrap_or_default(); let mut old_index = None; @@ -267,7 +239,6 @@ impl IProfiles { self.save_file().await } - /// update the item value pub async fn patch_item(&mut self, uid: &String, item: &PrfItem) -> Result<()> { if let Some(file) = &item.file { Self::validate_profile_file(file)?; @@ -312,14 +283,12 @@ impl IProfiles { Ok(()) } - /// be used to update the remote item - /// only patch `updated` `extra` `file_data` + /// Updates fields returned by a remote profile refresh. pub async fn update_item(&mut self, uid: &String, item: &mut PrfItem) -> Result<()> { if self.items.is_none() { self.items = Some(vec![]); } - // find the item let _ = self.get_item(uid)?; if let Some(items) = self.items.as_mut() { @@ -331,14 +300,11 @@ impl IProfiles { each.updated = item.updated; each.home = item.home.to_owned(); each.option = PrfOption::merge(each.option.as_ref(), item.option.as_ref()); - // save the file data - // move the field value after save if let Some(file_data) = item.file_data.take() { let file = each.file.take(); let file = file.unwrap_or_else(|| item.file.take().unwrap_or_else(|| format!("{}.yaml", uid).into())); - // the file must exists each.file = Some(file.clone()); let path = dirs::app_profiles_dir()?.join(file.as_str()); @@ -434,12 +400,10 @@ impl IProfiles { } } - /// 判断profile是否是current指向的 pub fn is_current_profile_index(&self, index: &String) -> bool { self.current.as_ref() == Some(index) } - /// 获取所有的profiles(uid,名称, 是否为 current) pub fn profiles_preview(&self) -> Option>> { self.items.as_ref().map(|items| { items @@ -457,7 +421,6 @@ impl IProfiles { }) } - /// 通过 uid 获取名称 pub fn get_name_by_uid(&self, uid: &String) -> Option<&String> { if let Some(items) = &self.items { for item in items { @@ -480,10 +443,8 @@ impl IProfiles { return Ok(()); } - // 如果 items 为空(例如 profiles.yaml 解析失败导致 - // IProfiles::new() 返回 default),此时无法判断哪些文件是活跃的, - // 跳过清理以避免误删用户正在使用的订阅配置文件。 - // See: https://github.com/clash-verge-rev/clash-verge-rev/issues/7577 + // An empty list may mean loading failed, so it is not safe evidence that every file is orphaned. + // https://github.com/clash-verge-rev/clash-verge-rev/issues/7577 if self.items.as_ref().is_none_or(|v| v.is_empty()) { logging!( warn, @@ -493,13 +454,10 @@ impl IProfiles { return Ok(()); } - // 获取所有 active profile 的文件名集合 let active_files = self.get_all_active_files(); - // 添加全局扩展配置文件到保护列表 let protected_files = self.get_protected_global_files(); - // 扫描 profiles 目录下的所有文件 let mut total_files = 0; let mut deleted_files = 0; let mut failed_deletions = 0; @@ -517,13 +475,11 @@ impl IProfiles { if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) && Self::is_profile_file(file_name) { - // 检查是否为全局扩展文件 if protected_files.contains(file_name) { logging!(debug, Type::Config, "保护全局扩展配置文件: {file_name}"); continue; } - // 检查是否为活跃文件 if !active_files.contains(file_name) { match path.to_path_buf().remove_if_exists().await { Ok(_) => { @@ -557,7 +513,6 @@ impl IProfiles { Ok(()) } - /// 不删除全局扩展配置 fn get_protected_global_files(&self) -> HashSet { let mut protected_files = HashSet::new(); @@ -567,23 +522,19 @@ impl IProfiles { protected_files } - /// 获取所有 active profile 关联的文件名 fn get_all_active_files(&self) -> HashSet<&str> { let mut active_files: HashSet<&str> = HashSet::new(); if let Some(items) = &self.items { for item in items { - // 收集所有类型 profile 的文件 if let Some(file) = &item.file { active_files.insert(file); } - // 对于主 profile 类型(remote/local),还需要收集其关联的扩展文件 if let Some(itype) = &item.itype && (itype == "remote" || itype == "local") && let Some(option) = &item.option { - // 收集关联的扩展文件 if let Some(merge_uid) = &option.merge && let Ok(merge_item) = self.get_item(merge_uid) && let Some(file) = &merge_item.file @@ -625,13 +576,12 @@ impl IProfiles { active_files } - /// 检查文件名是否符合 profile 文件的命名规则 fn is_profile_file(filename: &str) -> bool { REGEX_PROFILE_FILE.is_match(filename) } } -// 特殊的Send-safe helper函数,完全避免跨await持有guard +// These helpers avoid holding a parking_lot guard across `await`. use crate::config::Config; pub async fn profiles_append_item_with_filedata_safe(item: &PrfItem, file_data: Option) -> Result<()> { @@ -814,16 +764,7 @@ fn reconcile_selected_nodes( plan } -/// Abandon any activation still in flight. -/// -/// An activation reads the profile, then polls the core until its groups are readable, then puts -/// its selections. A choice the user makes inside that window is newer than what the activation -/// captured, so letting it finish would push the core back to the older node — and the profile, -/// already holding the newer one, would then disagree with the core. Bumping the generation is -/// how an activation is told it has been overtaken. -/// -/// Called after a newer selection is written, or before a candidate Core transition whose caller -/// will reactivate whichever Profiles snapshot ultimately commits. +/// Cancels restoration so it cannot overwrite a newer selection or profile snapshot. pub fn supersede_selected_activation() { ACTIVATE_SELECTED_GENERATION.fetch_add(1, Ordering::AcqRel); } @@ -924,12 +865,7 @@ async fn update_tray_after_activation(generation: u64) { } } -/// Record which node a group is on, in the current profile. -/// -/// The counterpart of the frontend's `useRecordSelection`, for the selections the backend makes -/// on the user's behalf — the tray is the one that does. What the profile holds is what -/// [`activate_selected_nodes`] re-applies when a core starts, so a selection it never learned -/// about is one the next start silently undoes. +/// Records a backend-made selection so the next core start restores it. pub async fn record_selected_node(group_name: &str, node: &str) -> Result<()> { let group_name = String::from(group_name); let node = String::from(node); @@ -970,7 +906,6 @@ pub async fn record_selected_node(group_name: &str, node: &str) -> Result<()> { .await?; if recorded { - // Newer than anything an activation still in flight captured. supersede_selected_activation(); handle::Handle::refresh_profiles(); } @@ -1056,12 +991,7 @@ async fn persist_reconciled_selected( Ok(()) } -/// The recorded selections the core is not currently on. -/// -/// Deliberately "is the core on it" rather than "did we send a select": a group whose provider -/// has not loaded is present but empty, a `select` can fail while the core is still settling, and -/// a group that was already correct never produces an activation at all. Only the running state -/// tells those apart. +/// Returns recorded selections whose groups have not reached the requested node. fn unsettled_selections(selected: &[PrfSelected], proxies: &Proxies) -> Vec { selected .iter() @@ -1077,16 +1007,7 @@ fn unsettled_selections(selected: &[PrfSelected], proxies: &Proxies) -> Vec, generation: u64) { let deadline = Instant::now() + SELECTED_NODES_SETTLE_DEADLINE; loop { @@ -1133,11 +1054,7 @@ async fn settle_pending_selections(selected: &[PrfSelected], completed: &mut Has } } -/// Fires once the selections that could be applied have been. -/// -/// Dropping it without firing releases the waiter too: every way the worker can end early is a -/// way of saying "nothing more is coming", and a start that waits forever for one of them would -/// be a worse failure than the one this exists to prevent. +/// Releases the first-pass waiter even when restoration exits early. struct FirstPassSignal(Option>); impl FirstPassSignal { @@ -1212,8 +1129,7 @@ async fn activate_selected_nodes_worker( } if repair == SelectionRepair::KeepRecords { - // Everything the core could be moved to has been. Whoever is waiting to point traffic - // here may go; what is left needs the core to finish loading and cannot be waited on. + // Remaining selections depend on provider loading and continue in the background. first_pass_done.notify(); settle_pending_selections(&selected, &mut completed_activations, generation).await; return Ok(()); @@ -1235,14 +1151,9 @@ async fn activate_selected_nodes_worker( /// Whether an activation may also prune the records it cannot match. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum SelectionRepair { - /// Drop records whose group or node the core does not have. Only safe once the configuration - /// they belong to is known to be fully loaded, which is true right after a profile switch. + /// Prune records after a fully loaded profile switch. Prune, - /// Apply what can be applied and leave the records alone. - /// - /// What a core start needs. A provider-backed group can be empty or absent for seconds after - /// the core comes up, and a record pruned on that evidence is gone from `profiles.yaml` for - /// good — the provider finishing later cannot bring it back. + /// Preserve records during startup while provider-backed groups may still be absent. KeepRecords, } @@ -1253,18 +1164,7 @@ pub fn activate_selected_nodes() -> Result<()> { Ok(()) } -/// Put the profile's selections back before anything is pointed at the core. -/// -/// Awaited, unlike a profile switch's activation, because the caller enables the system proxy as -/// soon as this returns: a proxy pointed at a core still sitting on the first entry of every -/// group is the outcome restoring exists to prevent. Bounded so that a core which will not answer -/// delays a start rather than blocking it — the selections keep being retried either way, and -/// being proxied through the wrong node beats not being proxied at all. -/// -/// TUN is deliberately not covered, because it cannot be: it is configured in the core's own -/// file, so it is carrying traffic from the moment the process starts, before anything here could -/// run. What puts a TUN user on the right node from the first packet is the core's own -/// `cache.db`, which is why the service keeps an owner's runtime generation across restarts. +/// Restores available selections before the system proxy points at the core, then retries the rest. pub async fn restore_selected_nodes() { let first_pass = activate_selected_nodes_with(SelectionRepair::KeepRecords); if tokio::time::timeout(SELECTED_NODES_FIRST_PASS_BUDGET, first_pass) @@ -1287,8 +1187,7 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot let (first_pass_sender, first_pass_done) = tokio::sync::oneshot::channel(); let handle = tokio::spawn(async move { - // Held for the whole task so that every exit — superseded, no profile, an error — drops - // it and releases anyone waiting. Only the worker fires it deliberately. + // Dropping this releases the startup waiter on every early exit. let first_pass = FirstPassSignal(Some(first_pass_sender)); if let Some(previous_task) = previous_task { let _ = previous_task.await; @@ -1298,11 +1197,7 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot } let result = async { - // Committed, not the draft. A profile switch stages its target before validating - // it, and a switch that then fails discards that draft — but an activation which had - // already read it would go on to apply the rejected profile's selections to the one - // still running. The switch that succeeds commits before it activates, so this is the - // same value there. + // A draft may be a profile switch that has not passed validation yet. let profiles = Config::profiles().await.data_arc(); let current = profiles.get_current().context("no current profile running")?.clone(); let selected = profiles diff --git a/src-tauri/src/config/runtime.rs b/src-tauri/src/config/runtime.rs index d063e1350..977a164d1 100644 --- a/src-tauri/src/config/runtime.rs +++ b/src-tauri/src/config/runtime.rs @@ -9,8 +9,7 @@ const PATCH_CONFIG_INNER: [&str; 5] = ["allow-lan", "ipv6", "log-level", "unifie #[derive(Default, Clone)] pub struct IRuntime { pub config: Option, - // 记录在订阅中(包括merge和script生成的)出现过的keys - // 这些keys不一定都生效 + // Keys seen in the profile pipeline, including merge and script output. pub exists_keys: HashSet, // TODO 或许可以用 FixMap 来存储以提升效率 pub chain_logs: HashMap>, @@ -22,7 +21,6 @@ impl IRuntime { Self::default() } - // 这里只更改 allow-lan | ipv6 | log-level | tun | tunnels #[inline] pub fn patch_config(&mut self, patch: &Mapping) { let config = if let Some(config) = self.config.as_mut() { @@ -56,54 +54,7 @@ impl IRuntime { } } - /// 更新链式代理配置 - /// - /// 该函数更新 `proxies` 和 `proxy-groups` 配置,并处理链式代理的修改或(传入 None )删除。 - /// - /// 配置示例: - /// - /// ```json - /// { - /// "proxies": [ - /// { - /// "name": "入口节点", - /// "type": "xxx", - /// "server": "xxx", - /// "port": "xxx", - /// "ports": "xxx", - /// "password": "xxx", - /// "skip-cert-verify": "xxx" - /// }, - /// { - /// "name": "hop_node_1_xxxx", - /// "type": "xxx", - /// "server": "xxx", - /// "port": "xxx", - /// "ports": "xxx", - /// "password": "xxx", - /// "skip-cert-verify": "xxx", - /// "dialer-proxy": "入口节点" - /// }, - /// { - /// "name": "出口节点", - /// "type": "xxx", - /// "server": "xxx", - /// "port": "xxx", - /// "ports": "xxx", - /// "password": "xxx", - /// "skip-cert-verify": "xxx", - /// "dialer-proxy": "hop_node_1_xxxx" - /// } - /// ], - /// "proxy-groups": [ - /// { - /// "name": "proxy_chain", - /// "type": "select", - /// "proxies": ["出口节点"] - /// } - /// ] - /// } - /// ``` + /// Rebuilds `dialer-proxy` links from an ordered proxy chain, or removes them for `None`. #[inline] pub fn update_proxy_chain_config(&mut self, proxy_chain_config: Option) { let config = if let Some(config) = self.config.as_mut() { diff --git a/src-tauri/src/config/verge.rs b/src-tauri/src/config/verge.rs index 8a6b1ebc9..85eadfb95 100644 --- a/src-tauri/src/config/verge.rs +++ b/src-tauri/src/config/verge.rs @@ -12,175 +12,127 @@ use smartstring::alias::String; /// ### `verge.yaml` schema #[derive(Default, Debug, Clone, Deserialize, Serialize)] pub struct IVerge { - /// app log level /// silent | error | warn | info | debug | trace pub app_log_level: Option, - /// app log max size in KB + /// Maximum app log size in KiB. pub app_log_max_size: Option, - /// app log max count pub app_log_max_count: Option, - // i18n pub language: Option, /// `light` or `dark` or `system` pub theme_mode: Option, - /// tray click event pub tray_event: Option, - /// copy env type pub env_type: Option, - /// start page pub start_page: Option, - /// startup script path pub startup_script: Option, - /// enable traffic graph default is true pub traffic_graph: Option, /// show memory info (only for Clash Meta) pub enable_memory_usage: Option, - /// enable group icon #[serde(skip_serializing_if = "Option::is_none")] pub enable_group_icon: Option, - /// pause render traffic stats on blur pub pause_render_traffic_stats_on_blur: Option, - /// common tray icon #[serde(skip_serializing_if = "Option::is_none")] pub common_tray_icon: Option, - /// tray icon #[cfg(target_os = "macos")] #[serde(skip_serializing_if = "Option::is_none")] pub tray_icon: Option, - /// menu icon #[serde(skip_serializing_if = "Option::is_none")] pub menu_icon: Option, - /// menu order #[serde(skip_serializing_if = "Option::is_none")] pub menu_order: Option>, - /// toast / notice position on screen #[serde(skip_serializing_if = "Option::is_none")] pub notice_position: Option, - /// collapse navigation bar pub collapse_navbar: Option, - /// sysproxy tray icon pub sysproxy_tray_icon: Option, - /// tun tray icon pub tun_tray_icon: Option, - /// clash tun mode pub enable_tun_mode: Option, - /// can the app auto startup pub enable_auto_launch: Option, - /// not show the window on launch pub enable_silent_start: Option, - /// set system proxy pub enable_system_proxy: Option, - /// enable proxy guard pub enable_proxy_guard: Option, - /// enable bypass format check pub enable_bypass_check: Option, /// enable dns settings - this controls whether dns_config.yaml is applied pub enable_dns_settings: Option, - /// always use default bypass pub use_default_bypass: Option, - /// set system proxy bypass pub system_proxy_bypass: Option, - /// proxy guard duration pub proxy_guard_duration: Option, - /// use pac mode pub proxy_auto_config: Option, - /// pac script content pub pac_file_content: Option, - /// proxy host address pub proxy_host: Option, - /// theme setting pub theme_setting: Option, - /// web ui list pub web_ui_list: Option>, - /// clash core path #[serde(skip_serializing_if = "Option::is_none")] pub clash_core: Option, - /// hotkey map /// format: {func},{key} #[serde(skip_serializing_if = "Option::is_none")] pub hotkeys: Option>, - /// enable global hotkey pub enable_global_hotkey: Option, - /// 首页卡片设置 - /// 控制首页各个卡片的显示和隐藏 pub home_cards: Option, - /// 切换代理时自动关闭连接 pub auto_close_connection: Option, - /// 是否自动检查更新 pub auto_check_update: Option, - /// 默认的延迟测试连接 pub default_latency_test: Option, - /// 默认的延迟测试超时时间 pub default_latency_timeout: Option, - /// 是否自动检测当前节点延迟 pub enable_auto_delay_detection: Option, /// 自动检测当前节点延迟的间隔(分钟) pub auto_delay_detection_interval_minutes: Option, - /// 是否使用内部的脚本支持,默认为真 pub enable_builtin_enhanced: Option, - /// proxy 页面布局 列数 pub proxy_layout_column: Option, - /// 测试站列表 pub test_list: Option>, - /// 日志清理 /// 0: 不清理; 1: 1天;2: 7天; 3: 30天; 4: 90天 pub auto_log_clean: Option, - /// Enable scheduled automatic backups pub enable_auto_backup_schedule: Option, /// Automatic backup interval in hours pub auto_backup_interval_hours: Option, - /// Create backups automatically when critical configs change pub auto_backup_on_change: Option, /// verge 的各种 port 用于覆盖 clash 的各种 port @@ -206,7 +158,6 @@ pub struct IVerge { pub verge_http_enabled: Option, - /// WebDAV 配置 (加密存储) #[serde( serialize_with = "serialize_encrypted", deserialize_with = "deserialize_encrypted", @@ -215,7 +166,6 @@ pub struct IVerge { )] pub webdav_url: Option, - /// WebDAV 用户名 (加密存储) #[serde( serialize_with = "serialize_encrypted", deserialize_with = "deserialize_encrypted", @@ -224,7 +174,6 @@ pub struct IVerge { )] pub webdav_username: Option, - /// WebDAV 密码 (加密存储) #[serde( serialize_with = "serialize_encrypted", deserialize_with = "deserialize_encrypted", @@ -236,26 +185,20 @@ pub struct IVerge { #[cfg(target_os = "macos")] pub enable_tray_speed: Option, - // pub enable_tray_icon: Option, - /// show proxy groups directly on tray root menu #[serde(skip_serializing_if = "Option::is_none")] pub tray_proxy_groups_display_mode: Option, - /// show outbound modes directly on tray root menu pub tray_inline_outbound_modes: Option, - /// 自动进入轻量模式 pub enable_auto_light_weight_mode: Option, /// 自动进入轻量模式的延迟(分钟) pub auto_light_weight_minutes: Option, - /// 启用代理页面自动滚动 pub enable_hover_jump_navigator: Option, /// 代理页面自动滚动延迟(毫秒) pub hover_jump_navigator_delay: Option, - /// 启用外部控制器 pub enable_external_controller: Option, } @@ -284,10 +227,8 @@ pub struct IVergeTheme { } impl IVerge { - /// 有效的clash核心名称 pub const VALID_CLASH_CORES: &'static [&'static str] = &["verge-mihomo", "verge-mihomo-alpha"]; - /// 验证并修正配置文件中的clash_core值 pub async fn validate_and_fix_config() -> Result<()> { let config_path = dirs::verge_path()?; let mut config = match help::read_yaml::(&config_path).await { @@ -319,7 +260,6 @@ impl IVerge { needs_fix = true; } - // 修正后保存配置 if needs_fix { logging!(info, Type::Config, "正在保存修正后的配置文件..."); help::save_yaml(&config_path, &config, Some("# Clash Verge Config")).await?; @@ -333,7 +273,6 @@ impl IVerge { Ok(()) } - /// 配置修正后重新加载配置 async fn reload_config_after_fix(updated_config: Self) -> Result<()> { logging!( info, @@ -359,7 +298,6 @@ impl IVerge { match dirs::verge_path() { Ok(path) => match help::read_yaml::(&path).await { Ok(mut config) => { - // compatibility if let Some(start_page) = config.start_page.clone() && start_page == "/home" { @@ -440,7 +378,6 @@ impl IVerge { webdav_password: None, #[cfg(target_os = "macos")] enable_tray_speed: Some(false), - // enable_tray_icon: Some(true), tray_proxy_groups_display_mode: Some("default".into()), tray_inline_outbound_modes: Some(false), enable_global_hotkey: Some(true), @@ -453,13 +390,10 @@ impl IVerge { } } - /// Save IVerge App Config pub async fn save_file(&self) -> Result<()> { help::save_yaml(&dirs::verge_path()?, &self, Some("# Clash Verge Config")).await } - /// patch verge config - /// only save to file #[allow(clippy::cognitive_complexity)] pub fn patch_config(&mut self, patch: &Self) { macro_rules! patch { @@ -546,7 +480,6 @@ impl IVerge { patch!(webdav_password); #[cfg(target_os = "macos")] patch!(enable_tray_speed); - // patch!(enable_tray_icon); patch!(tray_proxy_groups_display_mode); patch!(tray_inline_outbound_modes); patch!(enable_auto_light_weight_mode); @@ -556,7 +489,6 @@ impl IVerge { patch!(enable_external_controller); } - /// 获取日志等级 pub fn get_log_level(&self) -> LevelFilter { if let Some(level) = self.app_log_level.as_ref() { match level.to_lowercase().as_str() { diff --git a/src-tauri/src/core/manager/config.rs b/src-tauri/src/core/manager/config.rs index 72d64ff17..349b95039 100644 --- a/src-tauri/src/core/manager/config.rs +++ b/src-tauri/src/core/manager/config.rs @@ -22,36 +22,23 @@ use std::{ }; use tauri_plugin_mihomo::Error as MihomoError; -/// What came back from asking the Service to stage a runtime. -/// -/// Separated from the decision below so the decision stays a pure function of it. The variants are -/// the four ways an attempt can end, and what distinguishes them is how much they let us conclude: -/// a refusal is the Service's verdict on this bundle, while silence says nothing at all. +/// A staging result separated from the policy that acts on it. #[derive(Debug, Clone, PartialEq, Eq)] enum StageAttempt { - /// The Service that owns the running Core predates staging. Unsupported, - /// The Service answered, and its answer was a refusal about the bundle itself — the kind a - /// fresh start would run into again, because it would materialise the same bundle. + /// Restarting would present the same rejected bundle. RefusedTheBundle(String), - /// The Service answered with a refusal about something other than the bundle: the session, the - /// protocol, the request. Starting is how several of those get resolved, so it is not a reason - /// to leave the Core where it is. + /// A new service session may resolve this refusal. RefusedForAnotherReason(String), - /// The request did not come back. The Service is authoritative about its own runtime, so - /// without an answer nothing may be assumed about what it did or did not write. + /// The service may have staged the bundle without delivering its reply. Unanswered(String), Answered(StageRuntimeOutcome), } -/// How the Core should be made to pick up a configuration. #[derive(Debug, PartialEq, Eq)] enum ConfigApplication { - /// Point the running Core at this path. ReloadFrom(String), - /// Stop the Core and start it again from a freshly materialised runtime. ReplaceCore, - /// Leave the Core alone and report why the configuration cannot be applied. Fail(String), } @@ -63,21 +50,8 @@ impl Drop for ConfigUpdateGuard<'_> { } } -/// Decide how to apply a configuration, given how staging went. -/// -/// Two branches avoid replacing the Core, for opposite reasons. One is success. The other is a -/// refusal *about the bundle*: the Service inspected it and would not take it, and a fresh start -/// materialises the same bundle — so it would fail the same way, having stopped a Core that was -/// working. -/// -/// The distinction matters more than it looks. A refusal about the session is not a refusal about -/// the bundle: starting proposes a new session rather than presenting the old one, and the restart -/// path is also what notices that ownership was lost and clears the system proxy. Treating every -/// non-zero code alike would skip that. -/// -/// Everything else — an older Service, a request that never came back, or a Service that asked to -/// be restarted — replaces the Core, which is what this did before staging existed and what the -/// Service guarantees is still safe after every way of declining. +/// Avoids replacing a working core only when the service staged successfully or rejected the +/// bundle itself; session-level failures may be repaired by replacement. fn plan_config_application(attempt: &StageAttempt) -> ConfigApplication { match attempt { StageAttempt::Answered(StageRuntimeOutcome::Staged { config_path }) => { @@ -112,7 +86,6 @@ fn plan_config_application(attempt: &StageAttempt) -> ConfigApplication { } } -/// One round trip to the Service, classified but not judged. async fn ask_to_stage(path: &std::path::Path) -> StageAttempt { match crate::core::service::stage_runtime_by_service(path).await { Ok(StageRequest::Answered(outcome)) => StageAttempt::Answered(outcome), @@ -128,21 +101,8 @@ async fn ask_to_stage(path: &std::path::Path) -> StageAttempt { } } -/// Ask once, and ask again only when the first attempt came back silent. -/// -/// Silence is the one answer that has to be resolved rather than acted on. The Service commits the -/// generation before it replies, so a reply that never arrives can leave `config.yaml` already -/// replaced while this side concludes nothing happened — and the Service restarts a Core that dies -/// later from exactly that file. Treating silence as "nothing happened" is therefore a guess, and -/// the expensive half of it is silent: the Core keeps running the previous configuration until -/// something restarts it, and then it does not. -/// -/// Asking again is cheap precisely where it matters. Staging plans against the manifest it wrote, -/// so a bundle that already landed copies nothing and answers in milliseconds; and because the -/// Service holds its lifecycle lock for the whole operation, a second ask that overtakes a first -/// one still in flight waits for it rather than interleaving with it. `confirm_within` is what -/// keeps that wait from being the caller's problem — past it, the answer has stopped being worth -/// more than the fallback, which is where a second silence goes anyway. +/// Confirms one silent request because the service may commit before its reply is lost. +/// Re-staging an already committed bundle is idempotent and bounded by `confirm_within`. async fn stage_with_confirmation(confirm_within: Duration, ask: Ask) -> StageAttempt where Ask: Fn() -> Fut, @@ -326,10 +286,7 @@ impl CoreManager { self.validate_and_apply(transaction).await } - /// Validate the staged Runtime Config and hand it to the Core, committing only if both work. - /// - /// Takes the transaction rather than opening one, so it covers the staging its callers did. - /// Every way out of here other than the last line rolls that staging back. + /// Validates and applies the caller's transaction, committing only on success. async fn validate_and_apply(&self, transaction: DraftTransaction<'_>) -> Result { let outcome = CoreConfigValidator::global().validate_config_outcome().await?; if !outcome.is_valid() { @@ -342,15 +299,7 @@ impl CoreManager { Ok(ValidationOutcome::Valid) } - /// Hand the generated configuration to the Core. - /// - /// Says nothing about drafts: whether the staged Runtime Config is kept follows from - /// whether this succeeded, and the caller's transaction decides that. - /// - /// Both modes have the same shape — put the configuration where the Core can read it, ask the - /// Core to reload, restart it if that did not work. Only the first step differs: in Sidecar - /// mode the Core already reads the app's own directory, while in Service mode the Service has - /// to materialise the configuration into the directory it started the Core in. + /// Applies a generated configuration through the active core owner. async fn apply_config(&self, path: PathBuf) -> Result<()> { if matches!(*self.get_running_mode(), RunningMode::Service) { let _lifecycle = self.lifecycle_lock.lock().await; @@ -364,18 +313,12 @@ impl CoreManager { self.reload_or_restart(path).await } - /// Apply a configuration in Service mode, staging it in place when that is possible. - /// - /// Every way of not staging leads to the same place it always led: stop the Core and start it - /// again from a freshly materialised runtime. That is what makes declining safe — the Service - /// leaves the runtime it could not stage exactly as the running Core left it. - /// + /// Applies through the service, replacing the core when in-place staging is unavailable. /// Caller must hold `lifecycle_lock`. async fn apply_config_by_service(&self, path: &std::path::Path) -> Result<()> { match plan_config_application(&self.attempt_staging(path).await) { ConfigApplication::Fail(message) => { - // The Service looked at this bundle and would not take it. Replacing the Core would - // hand it the same bundle, so the outage would buy nothing. + // Replacement would hand the service the same rejected bundle. logging!( warn, Type::Core, @@ -402,7 +345,6 @@ impl CoreManager { Ok(()) } - /// Ask the Service to stage the runtime, reporting the attempt rather than judging it. async fn attempt_staging(&self, path: &std::path::Path) -> StageAttempt { if !crate::core::service::active_service_supports_runtime_staging() { return StageAttempt::Unsupported; @@ -466,12 +408,7 @@ mod tests { ); } - /// Wrap a rejection as the answer the Service would have sent. - /// - /// The `match` is the point, not the wrapping: it is exhaustive, so a new way for the Service - /// to decline cannot be added upstream without this failing to compile — which is what sends - /// whoever adds it to the test below to say what it should lead to. `CoreRestarted` reached - /// production without one, and inherited its verdict silently. + /// Exhaustively maps every service rejection into the policy under test. fn declined(reason: StageRejection) -> StageAttempt { match &reason { StageRejection::CoreNotRunning @@ -490,9 +427,7 @@ mod tests { StageRejection::RuntimeUnwritable { detail: "held open".to_owned(), }, - // The service's own watchdog can restart the core mid-staging, which leaves the - // generation holding a mixture no manifest describes. Rebuilding it from nothing is - // the only answer that does not guess at what is in there. + // A watchdog restart mid-staging can leave a generation that no manifest describes. StageRejection::CoreRestarted, ] { assert_eq!( @@ -514,9 +449,7 @@ mod tests { #[test] fn a_refusal_about_the_bundle_leaves_the_core_alone() { - // Replacing the core would materialise the same bundle the Service just rejected, so the - // outage buys nothing: the configuration update fails either way, and this way the user's - // proxy is still working when it does. + // Keep the working core when replacement would present the same rejected bundle. assert_eq!( plan_config_application(&StageAttempt::RefusedTheBundle("runtime asset is unavailable".into())), ConfigApplication::Fail("runtime asset is unavailable".into()) @@ -525,9 +458,7 @@ mod tests { #[test] fn a_refusal_about_anything_else_still_replaces_the_core() { - // A stale session is the case that matters: starting proposes a new one, so it is the cure - // rather than a repeat of the failure — and the restart path is also what notices ownership - // was lost and clears the system proxy. + // A new session can repair stale ownership and clear the inherited system proxy. assert_eq!( plan_config_application(&StageAttempt::RefusedForAnotherReason("owner session is stale".into())), ConfigApplication::ReplaceCore @@ -559,8 +490,7 @@ mod tests { #[test] fn a_request_that_never_came_back_makes_the_core_be_replaced() { - // Not knowing what the service did is the one case where reloading would be a guess: the - // staged path might not exist, or might hold the previous configuration. + // Silence gives no trustworthy path to reload. assert_eq!( plan_config_application(&StageAttempt::Unanswered("connection reset".into())), ConfigApplication::ReplaceCore @@ -569,8 +499,7 @@ mod tests { #[tokio::test] async fn an_answer_is_taken_at_its_word_and_not_asked_for_twice() { - // Staging is only idempotent in what it writes, not in what it costs: a second ask after a - // perfectly good answer would re-send the whole bundle for nothing. + // Do not pay for a confirmation after a conclusive answer. for answer in [ staged(), StageAttempt::Answered(StageRuntimeOutcome::RestartRequired { @@ -593,9 +522,7 @@ mod tests { #[tokio::test] async fn silence_is_resolved_by_asking_again_rather_than_assumed() { - // The case this exists for: the service committed the generation and the reply was lost. - // Asking again reaches a service that has already done the work, so the second answer is - // the truth the first one failed to deliver — and it keeps the core off the replace path. + // Confirm a committed generation whose first reply was lost. let asks = Cell::new(0); let outcome = stage_with_confirmation(CONFIRM_WITHIN, || { asks.set(asks.get() + 1); @@ -639,8 +566,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn a_second_ask_that_keeps_working_is_not_waited_out() { - // A service still copying assets is why the deadline exists: the answer would be worth - // having, but not at the cost of leaving the user's configuration unapplied indefinitely. + // Do not wait indefinitely for a service still copying assets. let asks = Cell::new(0); let started = tokio::time::Instant::now(); let outcome = stage_with_confirmation(CONFIRM_WITHIN, || async { diff --git a/src-tauri/src/core/manager/lifecycle.rs b/src-tauri/src/core/manager/lifecycle.rs index 097f9698c..40ea23d38 100644 --- a/src-tauri/src/core/manager/lifecycle.rs +++ b/src-tauri/src/core/manager/lifecycle.rs @@ -24,15 +24,12 @@ enum StartupDecision { Wait, } -/// Proxy action captured before a controlled stop. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ProxyStopIntent { Clear, - /// Let the service overwrite the inherited proxy state. HandOverToService, } -/// Select whether a controlled stop clears or hands over proxy state. const fn proxy_stop_intent(is_macos: bool, running_mode: RunningMode, decision: StartupDecision) -> ProxyStopIntent { match (is_macos, running_mode, decision) { (true, RunningMode::Sidecar, StartupDecision::Service) => ProxyStopIntent::HandOverToService, @@ -280,14 +277,10 @@ where start_sidecar().await } -/// sidecar→service 交接结果 #[cfg(target_os = "windows")] enum HandoffOutcome { - /// 服务尚未就绪 NotReady, - /// 已完成或无需交接 Done, - /// 交接失败并已回退 Failed, } @@ -408,7 +401,6 @@ impl CoreManager { run_sidecar_termination_transition(proxy_control::clear, || self.stop_core_by_sidecar_unprepared()).await } - /// Replaces a Service-owned core while the caller holds `lifecycle_lock`. pub(super) async fn replace_service_core_with_config(&self, config_file: &Path) -> Result<()> { run_service_config_replacement_transition( cfg!(target_os = "macos"), @@ -457,14 +449,11 @@ impl CoreManager { Ok(()) } - /// 调用者须已持有 `lifecycle_lock`。 async fn start_core_inner(&self) -> Result<()> { - // 退出中不再启动新内核。 if Handle::global().is_exiting() { return Ok(()); } - // 已有内核运行时保持幂等,重启请走 restart_core。 if !matches!(*self.get_running_mode(), RunningMode::NotRunning) { logging!( info, @@ -479,7 +468,6 @@ impl CoreManager { self.rollback_failed_start().await; return Ok(()); } - // 等待服务期间可能进入退出;未真正启动时回滚状态。 if Handle::global().is_exiting() { self.core_stopped(); return Ok(()); @@ -491,15 +479,12 @@ impl CoreManager { StartupDecision::Wait => Ok(()), }; - // No startup failure may leave a locally reported running mode. PAC is - // still fail-closed, and any service owner monitor will exit after this - // transition instead of treating an unconfirmed core as running. + // A failed start must not leave a locally reported running mode. if result.is_err() { self.rollback_failed_start().await; return result; } - // 回退 sidecar 后,后台等待服务就绪再交接 #[cfg(target_os = "windows")] if matches!(*self.get_running_mode(), RunningMode::Sidecar) { self.spawn_service_handoff_watcher().await; @@ -513,12 +498,10 @@ impl CoreManager { self.controlled_stop_core_inner().await } - /// 调用者须已持有 `lifecycle_lock`。 pub(crate) async fn controlled_stop_core_inner(&self) -> Result<()> { self.controlled_stop_core_with_intent(ProxyStopIntent::Clear).await } - /// 调用者须已持有 `lifecycle_lock`。 async fn controlled_stop_core_with_intent(&self, proxy_intent: ProxyStopIntent) -> Result<()> { let running_mode = *self.get_running_mode(); run_controlled_stop_transition( @@ -532,7 +515,6 @@ impl CoreManager { .await } - /// Capture handoff intent before stopping; startup state may change afterward. async fn proxy_stop_intent(&self) -> ProxyStopIntent { let running_mode = *self.get_running_mode(); if !cfg!(target_os = "macos") || !matches!(running_mode, RunningMode::Sidecar) { @@ -541,7 +523,6 @@ impl CoreManager { proxy_stop_intent(cfg!(target_os = "macos"), running_mode, self.prepare_startup().await) } - /// 调用者须已持有 `lifecycle_lock`,且已完成受控代理清理。 async fn stop_core_unprepared_inner(&self) -> Result<()> { CLASH_LOGGER.clear_logs().await; match *self.get_running_mode() { @@ -565,7 +546,6 @@ impl CoreManager { } pub(crate) async fn restart_core_during_config_update(&self) -> Result<()> { - // 持锁覆盖 stop+start,避免生命周期操作插入。 let _life = self.lifecycle_lock.lock().await; logging!(info, Type::Core, "Restarting core"); let proxy_intent = self.proxy_stop_intent().await; @@ -583,7 +563,6 @@ impl CoreManager { .await } - /// Switch the core binary and reload configuration. pub async fn change_core(&self, clash_core: &String) -> Result<()> { if !IVerge::VALID_CLASH_CORES.contains(&clash_core.as_str()) { anyhow::bail!("invalid clash core: {clash_core}"); @@ -644,7 +623,6 @@ impl CoreManager { let _ = tokio::time::timeout(timing::SERVICE_WAIT_MAX, wait).await; } - /// 在窗口内等待服务就绪,再从 sidecar 交接到 service #[cfg(target_os = "windows")] async fn spawn_service_handoff_watcher(&self) { use crate::constants::timing; @@ -652,14 +630,12 @@ impl CoreManager { use std::sync::atomic::Ordering; use std::time::Instant; - // 仅 TUN 模式需要服务交接 let needs_service = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false) && !Config::tun_suppressed_for_session(); if !needs_service { return; } - // 单实例,避免并发交接 if self.handoff_watcher_running.swap(true, Ordering::AcqRel) { return; } @@ -684,14 +660,11 @@ impl CoreManager { } tokio::time::sleep(timing::SERVICE_HANDOFF_INTERVAL).await; - // 模式已变更时退出 if !matches!(*manager.get_running_mode(), RunningMode::Sidecar) { break; } match manager.try_handoff_sidecar_to_service().await { - // 已交接或无需交接 HandoffOutcome::Done => break, - // 已回退 sidecar,停止重试 HandoffOutcome::Failed => { logging!(warn, Type::Core, "handoff attempt failed; staying in sidecar mode"); break; @@ -705,21 +678,18 @@ impl CoreManager { #[cfg(target_os = "windows")] async fn refresh_service_readiness_for_handoff() -> bool { - // 主动刷新服务状态,避免缓存状态阻止交接 if SERVICE_MANAGER.confirm_ready().await.is_err() { return false; } crate::core::runstate::RUN_STATE.state().service_usable() } - /// 服务就绪后停止 sidecar,再以 service 重启内核 #[cfg(target_os = "windows")] async fn try_handoff_sidecar_to_service(&self) -> HandoffOutcome { if !Self::refresh_service_readiness_for_handoff().await { return HandoffOutcome::NotReady; } - // 先抢 config 锁;失败则让位给正在进行的更新。 if !self.try_start_config_update() { return HandoffOutcome::NotReady; } @@ -727,10 +697,9 @@ impl CoreManager { self.finish_config_update(); } - // 再取 lifecycle 锁;锁序固定为 config→lifecycle。 + // Lock order is config then lifecycle. let _life = self.lifecycle_lock.lock().await; - // 持锁后复检运行模式和 TUN 状态 if !matches!(*self.get_running_mode(), RunningMode::Sidecar) || !Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false) || Config::tun_suppressed_for_session() diff --git a/src-tauri/src/core/manager/state.rs b/src-tauri/src/core/manager/state.rs index b272df1c9..fe5cc91c7 100644 --- a/src-tauri/src/core/manager/state.rs +++ b/src-tauri/src/core/manager/state.rs @@ -20,22 +20,8 @@ use tauri_plugin_shell::ShellExt as _; const SIDECAR_READINESS_ATTEMPTS: usize = 30; impl CoreManager { - /// A core process is up: put back the node selections the user made. - /// - /// mihomo restores these itself, from the `cache.db` it keeps in the directory it was started - /// against — but only when `profile.store-selected` is on and that file survived, and neither - /// is something the app can assume. The setting comes from a merge template a user is free to - /// replace, and a service older than the durable runtime generation hands every start a - /// directory nothing has ever run in. The app holds the same selections in the profile, so it - /// is the one that can say for certain. - /// - /// Awaited, and deliberately here rather than beside the proxy: the caller enables the system - /// proxy as soon as the start returns, and pointing it at a core still on the first entry of - /// every group is what this exists to prevent. The wait is bounded — what cannot be put back - /// yet keeps being retried in the background — so a core that will not answer delays a start - /// instead of blocking it. - /// - /// Repeat calls supersede each other, so every start path may call this without coordinating. + /// Restores profile selections before callers enable the system proxy. + /// The bounded first pass continues in the background, and later calls supersede earlier ones. async fn restore_selected_nodes(&self) { if PROFILE_SELECTIONS_PENDING_COMMIT .try_with(|pending| *pending) @@ -469,8 +455,7 @@ mod tests { time::{Duration, Instant}, }; - // 起一个长命子进程用于验证 Job Object 的生命周期绑定。 - // 直接使用 System32 下的 ping.exe,避免 cmd 中间层。 + // Use ping directly as a long-lived process without a cmd.exe intermediary. fn spawn_long_lived() -> Result { let child = Command::new("ping") .args(["-n", "999", "127.0.0.1"]) @@ -480,7 +465,6 @@ mod tests { Ok(child) } - // 在超时内轮询子进程是否退出,返回是否已退出。 fn wait_until_exited(child: &mut Child, timeout: Duration) -> Result { let deadline = Instant::now() + timeout; loop { @@ -494,21 +478,17 @@ mod tests { } } - // 成功路径:进程被分配进 Job Object 后仍存活;drop Job 句柄触发 - // KILL_ON_JOB_CLOSE,进程应在超时内被 OS 终止。 #[test] fn job_kills_child_on_handle_drop() -> Result<()> { let mut child = spawn_long_lived()?; let job = create_and_assign_sidecar_job(child.id())?; - // 分配后进程应仍在运行。 assert!( child.try_wait()?.is_none(), "child should still be running after being assigned to the job" ); - // 关闭 Job 句柄,OS 应连带终止其成员进程。 drop(job); assert!( @@ -519,10 +499,9 @@ mod tests { Ok(()) } - // 失败路径:对一个不存在的 PID 调用时 OpenProcess 应失败,函数返回 Err。 #[test] fn returns_err_for_invalid_pid() { - // PID 必须为 4 的倍数且极不可能存在;0xFFFF_FFFC 对应不到真实进程。 + // Windows PIDs are multiples of four; this one is effectively impossible. let result = create_and_assign_sidecar_job(0xFFFF_FFFC); assert!(result.is_err(), "expected Err for a non-existent PID"); } diff --git a/src-tauri/src/core/proxy_control.rs b/src-tauri/src/core/proxy_control.rs index f5566492a..9ded3fa89 100644 --- a/src-tauri/src/core/proxy_control.rs +++ b/src-tauri/src/core/proxy_control.rs @@ -41,7 +41,6 @@ pub enum SysproxyFailure { } impl SysproxyFailure { - /// Stable frontend error code. #[inline] pub const fn code(&self) -> &'static str { match self { @@ -52,7 +51,6 @@ impl SysproxyFailure { } } - /// Find a classification using `anyhow`'s context-aware downcast. #[inline] pub fn from_chain(error: &anyhow::Error) -> Option<&Self> { error.downcast_ref::() @@ -83,7 +81,6 @@ impl std::error::Error for SysproxyFailure {} pub struct SystemProxyStateUnknown; impl SystemProxyStateUnknown { - /// Whether this marker appears in the error chain. #[inline] pub fn is_in(error: &anyhow::Error) -> bool { error.downcast_ref::().is_some() @@ -98,7 +95,6 @@ impl std::fmt::Display for SystemProxyStateUnknown { impl std::error::Error for SystemProxyStateUnknown {} -/// Whether the error carries a frontend-mappable classification. #[inline] fn carries_a_mappable_code(error: &anyhow::Error) -> bool { SysproxyFailure::from_chain(error).is_some() @@ -120,7 +116,6 @@ pub fn rollback_failure(caused_by: anyhow::Error, rollback: anyhow::Error) -> an combined } -/// Classify local macOS proxy failures without replacing their source chain. fn classify_local_failure(error: anyhow::Error) -> anyhow::Error { if !was_refused_locally(&error) { return error; @@ -128,7 +123,6 @@ fn classify_local_failure(error: anyhow::Error) -> anyhow::Error { error.context(SysproxyFailure::PrivilegeRequired) } -/// Whether macOS refused a local proxy write. fn was_refused_locally(error: &anyhow::Error) -> bool { cfg!(target_os = "macos") && error.chain().any(|cause| { @@ -141,7 +135,6 @@ fn was_refused_locally(error: &anyhow::Error) -> bool { /// Distinguish a misplaced sidecar from a missing privileged service. async fn classify_local_apply_failure(error: anyhow::Error) -> anyhow::Error { - // Preserve any existing, more specific classification. if SysproxyFailure::from_chain(&error).is_some() || !was_refused_locally(&error) { return error; } @@ -153,7 +146,6 @@ async fn classify_local_apply_failure(error: anyhow::Error) -> anyhow::Error { error.context(refusal_classification(probed)) } -/// Bound the diagnostic service probe. const SERVICE_PROBE_TIMEOUT: Duration = Duration::from_secs(2); /// Classify a refusal from a fresh service probe only. @@ -206,7 +198,6 @@ impl ServiceProxyOperations { self.run_service_operation(operation).await } - /// Invalidate the guard generation and wait for cancellation. async fn cancel_and_drain(&self, cancel: F) -> (u64, T) where F: FnOnce() -> Fut, @@ -217,7 +208,6 @@ impl ServiceProxyOperations { (generation, cancelled) } - /// Record under the operation lock only for the current guard generation. async fn record_if_current(&self, captured_generation: u64, record: Record) -> bool where Record: FnOnce() -> bool, @@ -339,12 +329,10 @@ async fn current_service_proxy_config(verge: &IVerge) -> Result bool { is_reportable_given(error, notification::has_pending_failure) } @@ -549,10 +537,8 @@ pub async fn refresh_guard() -> Result<()> { Ok(()) } -/// Consecutive failures allowed before the guard stops. const GUARD_FAILURES_BEFORE_STOPPING: u32 = 3; -/// Report that the current guard stopped. async fn report_guard_stopped(captured_generation: u64, error: &anyhow::Error) { let stopped = anyhow::Error::new(SysproxyFailure::GuardStopped { detail: format!("{error:#}"), @@ -569,7 +555,6 @@ async fn report_guard_stopped(captured_generation: u64, error: &anyhow::Error) { } } -/// Stop the local guard and report whether it drained. pub async fn stop_guard() -> bool { SERVICE_PROXY_OPERATIONS .cancel_and_drain(|| Sysopt::global().stop_proxy_guard()) @@ -593,7 +578,6 @@ mod tests { use std::task::Poll; use tokio::sync::Barrier; - /// Proxy state maintained by a running guard. fn guarded_proxy() -> MacosProxyConfig { MacosProxyConfig::Global { host: "127.0.0.1".to_owned(), diff --git a/src-tauri/src/core/runstate/env.rs b/src-tauri/src/core/runstate/env.rs index 46f87b46c..debc642de 100644 --- a/src-tauri/src/core/runstate/env.rs +++ b/src-tauri/src/core/runstate/env.rs @@ -1,9 +1,4 @@ -//! The seam between Run State and the machine it observes. -//! -//! Two adapters satisfy it: [`RealEnv`] talks to the Service over IPC and to the platform's -//! service registry, [`FakeEnv`] replays scripted answers. Because the store is generic over -//! this trait rather than reaching for globals, the state machine can be exercised without -//! a running app, an installed Service, or elevation. +//! Adapters between Run State and the real or scripted machine it observes. use anyhow::{Context as _, Result}; @@ -12,41 +7,21 @@ use super::probe::ServiceVersionReply; /// Everything Run State needs from outside itself. pub trait RunStateEnv: Send + Sync + 'static { - /// Ask the Service which protocol it speaks. Fails when it cannot be reached at all. fn probe_service_version(&self) -> impl Future> + Send; - /// Whether the platform's service registry has a trusted record of an installation. - /// - /// Fails when the registry itself could not be inspected, which is different from - /// "no installation" and is treated as unavailable rather than not-installed. - /// - /// Async so blocking systemd and Windows SCM checks can run off the runtime workers. + /// Returns an error when installation evidence cannot be inspected, not when it is absent. fn trusted_install_evidence(&self) -> impl Future> + Send; - /// Whether this app process is running elevated. fn is_elevated(&self) -> bool; - /// Open or close the local PAC endpoint. - /// - /// Derived from the Running Mode, never set independently: a PAC script that points at a - /// proxy port nothing is listening on is worse than no PAC at all. + /// Keeps the PAC endpoint derived from Running Mode. fn set_pac_available(&self, available: bool); - /// Publish the Run State to observers outside the store. - /// - /// Two of them today: the diagnostic snapshot, which reports the Running Mode, and the - /// frontend, which gets the whole thing pushed rather than polling for it. fn publish(&self, state: &RunState); - /// Carry out a privileged operation against the Service. - /// - /// Blocking and platform-specific — SCM, systemd, launchd, elevation prompts — which is - /// exactly why it sits behind the seam: the state machine around it can then be exercised - /// without installing anything. fn run_privileged(&self, action: PendingAction) -> Result<()>; } -/// The production adapter: real IPC, real platform registry, real elevation check. #[derive(Debug, Default, Clone, Copy)] pub struct RealEnv; @@ -67,9 +42,7 @@ impl RunStateEnv for RealEnv { } fn is_elevated(&self) -> bool { - // Deliberately non-panicking, unlike `Handle::app_handle()`: Run State is read from - // startup paths that run before the app handle exists, and "not yet known" must - // degrade to "not elevated" rather than abort the process. + // Startup may read Run State before the app handle exists; fail closed instead of panicking. crate::APP_HANDLE .get() .is_some_and(tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin) @@ -80,17 +53,11 @@ impl RunStateEnv for RealEnv { } fn publish(&self, state: &RunState) { - // Reconciling capability is a reaction to the new state, not part of publishing it, so - // it is spawned: a transition must not wait on a config write it triggered. It is also - // spawned *before* the app-handle gate below, because a Core that stops and starts - // before the app handle exists still needs reconciling; the task re-reads the Run - // State itself rather than closing over this snapshot. + // Reconciliation must run before the app handle exists and must not block this transition. crate::process::AsyncHandler::spawn(|| async { crate::feat::reconcile_tun_availability().await; }); - // Non-panicking for the same reason as `is_elevated`: the Core can stop and start - // before the app handle exists, and a missed notification is better than an abort. let Some(app_handle) = crate::APP_HANDLE.get() else { return; }; @@ -115,10 +82,7 @@ mod fake { use super::{PendingAction, RunState, RunStateEnv, ServiceVersionReply}; use crate::core::manager::RunningMode; - /// A scripted stand-in for the machine. - /// - /// Defaults to the least capable environment — no Service, no elevation — so a test only - /// has to say what it needs. Outbound effects are recorded rather than performed. + /// A fail-closed scripted machine that records outbound effects. #[derive(Debug)] pub struct FakeEnv { version_replies: Mutex>>, @@ -152,7 +116,6 @@ mod fake { Self::default() } - /// An installed, reachable, protocol-compatible Service. #[must_use] pub fn service_ready(self) -> Self { self.with_evidence(true).always_replying(Ok(ServiceVersionReply { @@ -162,7 +125,6 @@ mod fake { })) } - /// An installed Service that answers with an incompatible protocol. #[must_use] pub fn service_version_mismatch(self) -> Self { self.with_evidence(true).always_replying(Ok(ServiceVersionReply { @@ -172,14 +134,12 @@ mod fake { })) } - /// Registered with the platform but not answering. #[must_use] pub fn service_unreachable(self) -> Self { self.with_evidence(true) .always_replying(Err("ipc transport refused".to_owned())) } - /// The platform registry itself could not be inspected. #[must_use] pub fn evidence_unavailable(mut self) -> Self { self.evidence = Err("registry probe failed".to_owned()); @@ -210,38 +170,32 @@ mod fake { self.replying(vec![reply]) } - /// How many times the Service was probed — asserts that retries actually retried. #[must_use] pub fn probe_count(&self) -> usize { *self.probe_count.lock() } - /// The last PAC availability the store asked for, or `None` if it never asked. #[must_use] pub fn pac_available(&self) -> Option { *self.pac_available.lock() } - /// Every Running Mode published outward, in order. #[must_use] pub fn published_modes(&self) -> Vec { self.published.lock().iter().map(|state| state.mode).collect() } - /// Every Run State published outward, in order. #[must_use] pub fn published(&self) -> Vec { self.published.lock().clone() } - /// Make every privileged operation fail with `reason`. #[must_use] pub fn privileged_operations_fail(self, reason: &str) -> Self { *self.privileged_outcome.lock() = Err(reason.to_owned()); self } - /// Every privileged operation actually attempted, in order. #[must_use] pub fn privileged_actions(&self) -> Vec { self.privileged_actions.lock().clone() diff --git a/src-tauri/src/core/runstate/health.rs b/src-tauri/src/core/runstate/health.rs index 7efdd3c28..46fe493c6 100644 --- a/src-tauri/src/core/runstate/health.rs +++ b/src-tauri/src/core/runstate/health.rs @@ -3,19 +3,13 @@ use crate::core::manager::RunningMode; -/// Last observed Service state. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub enum ServiceHealth { - /// Not probed yet. #[default] Unknown, - /// Installed, reachable, and speaking a compatible protocol. Ready, - /// No trusted installation evidence on this machine. NotInstalled, - /// Installed but speaking an incompatible protocol; needs reinstall. VersionMismatch, - /// Installed but unusable, with the reason we recorded. Unavailable(String), } @@ -38,7 +32,6 @@ impl ServiceHealth { } } -/// Requested privileged Service operation. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "camelCase")] pub enum PendingAction { @@ -48,7 +41,6 @@ pub enum PendingAction { ForceReinstall, } -/// Snapshot used for Core availability decisions. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RunState { pub health: ServiceHealth, @@ -61,26 +53,22 @@ pub struct RunState { } impl RunState { - /// Whether the last probe observed a ready Service; ignores in-flight operations. #[must_use] pub const fn service_ready(&self) -> bool { matches!(self.health, ServiceHealth::Ready) } - /// Whether the Service is ready and not mid-operation. #[must_use] pub const fn service_usable(&self) -> bool { self.service_ready() && !self.op_in_flight } - /// Whether elevation or a usable Service can support TUN. #[must_use] pub const fn tun_capable(&self) -> bool { self.is_admin || self.service_usable() } - /// Whether the user must resolve a pending request or a present-but-unusable Service. - /// In-flight, absent, and accepted-Sidecar states need no decision. + /// In-flight, absent, and accepted-Sidecar states require no user decision. #[must_use] pub const fn service_needs_attention(&self) -> bool { if self.op_in_flight { @@ -98,7 +86,6 @@ impl RunState { ) } - /// Whether enabled TUN is certainly unusable and may be disabled. /// Unknown, in-flight, and attention-required states are inconclusive. #[must_use] pub const fn tun_should_be_disabled(&self, tun_enabled: bool) -> bool { @@ -108,14 +95,12 @@ impl RunState { !matches!(self.health, ServiceHealth::Unknown) && !self.op_in_flight && !self.service_needs_attention() } - /// Whether startup must suppress TUN for a confirmed absent Service. - /// Present-but-broken Services remain in the repair flow. + /// Present-but-broken services remain in the repair flow rather than suppressing TUN. #[must_use] pub const fn startup_tun_should_be_disabled(&self, tun_enabled: bool) -> bool { matches!(self.health, ServiceHealth::NotInstalled) && self.tun_should_be_disabled(tun_enabled) } - /// Frontend view with derived availability decisions. #[must_use] pub fn to_view(&self) -> RunStateView { RunStateView { @@ -133,7 +118,6 @@ impl RunState { } } -/// Frontend Run State. #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct RunStateView { @@ -159,20 +143,17 @@ pub(super) struct StoredService { } impl StoredService { - /// Record an observation, clearing any request it answers. pub fn observe(&mut self, health: ServiceHealth) { self.health = health; self.pending = None; self.sidecar_allowed = false; } - /// Record a requested privileged operation, preserving the last observation. pub const fn request(&mut self, action: PendingAction) { self.pending = Some(action); self.sidecar_allowed = false; } - /// Record that the user accepted Sidecar for this session. pub const fn allow_sidecar(&mut self) { self.pending = None; self.sidecar_allowed = true; diff --git a/src-tauri/src/core/runstate/mod.rs b/src-tauri/src/core/runstate/mod.rs index 6d7d2d032..69959873a 100644 --- a/src-tauri/src/core/runstate/mod.rs +++ b/src-tauri/src/core/runstate/mod.rs @@ -1,15 +1,7 @@ -//! Run State — the single answer to "how is the Core running, and what backs it". +//! The single answer to how the Core runs and what backs it. //! -//! This module owns Service Health, Running Mode, Pending Action and the privileged-operation -//! lock. It deliberately does **not** own starting, stopping or restarting the Core — those -//! stay in `CoreManager`, which reports transitions in and reads snapshots back. Merging the -//! two would leave no point at which the state is consistent, since a snapshot taken -//! mid-restart would describe a transition rather than a state. -//! -//! Reads come in three flavours that differ only in *freshness* — [`RunStateStore::state`] -//! (cached), [`RunStateStore::settled`] (waits for any in-flight operation) and -//! [`RunStateStore::probe`] (forces live IPC). What the answer *means* is decided by methods -//! on [`RunState`], so no caller writes its own availability formula. +//! Core lifecycle stays in `CoreManager`; this module owns its state, service health, pending +//! actions, and the privileged-operation lock. Reads differ only in freshness. mod env; mod health; @@ -45,11 +37,7 @@ use probe::{CurrentServiceProbe, classify_service_health, probe_outcome}; /// The process-wide Run State, observing the real machine. pub static RUN_STATE: Lazy> = Lazy::new(|| RunStateStore::new(RealEnv)); -/// Why a wait for Service readiness ended without a ready Service. -/// -/// The two are not interchangeable: an unreachable Service told us nothing, so the caller -/// decides whether silence means unavailable; a rejected one already updated health and must -/// not have that verdict overwritten with a vaguer one. +/// Distinguishes silence from a reply that updated Service health with a rejection. #[derive(Debug)] pub enum ReadyWaitError { /// No readable reply within the attempts budget. @@ -66,11 +54,7 @@ impl std::fmt::Display for ReadyWaitError { } } -/// The service-owned state together with the counters that version it. -/// -/// They share one lock so a reader can never see a change without its version bump. -/// `generation` tracks visible changes; `observation` also tracks assertions that invalidate -/// older health probes without changing visible state. +/// Service state and revisions share one lock so observations cannot overtake their version bump. #[derive(Debug, Default)] struct VersionedService { service: StoredService, @@ -79,13 +63,11 @@ struct VersionedService { } impl VersionedService { - /// Record a visible change and invalidate older health probes. const fn bump(&mut self) { self.generation = self.generation.wrapping_add(1); self.observation = self.observation.wrapping_add(1); } - /// Reserve a revision that invalidates older health probes. const fn next_observation(&mut self) -> u64 { self.observation = self.observation.wrapping_add(1); self.observation @@ -113,33 +95,19 @@ impl RunStateStore { } } - // ─────────────────────────── reads: freshness only ─────────────────────────── - /// The last known Run State, without waiting and without probing. - /// - /// May be stale while a privileged operation is in flight — the snapshot says so via - /// [`RunState::op_in_flight`], and [`RunState::service_usable`] accounts for it. + /// It reports when a privileged operation may make the snapshot stale. pub fn state(&self) -> RunState { let service = self.service.lock().service.clone(); self.snapshot(service) } - /// The Run State once any in-flight privileged operation has finished. - /// - /// The snapshot is the decision, not a separate check before it. Reading the slot twice — - /// once to decide whether to wait, once to build the snapshot — leaves a window in which an - /// operation claims it, and the snapshot then *describes* that operation instead of waiting - /// for it. `prepare_startup` reads this: it would see a requested install as a reason not to - /// start, where waiting would have told it whether the install worked. + /// Returns one snapshot taken after any operation visible in that snapshot has settled. pub async fn settled(&self) -> RunState { loop { let notified = self.operation_done.notified(); tokio::pin!(notified); - // Register as a waiter *before* reading the state. `notify_waiters` wakes only - // those already registered and leaves no permit behind, so without this an - // operation finishing between the read and the await is a wakeup lost for good - // — and this future would then wait for some unrelated later operation, or - // forever. + // Register before reading; `notify_waiters` leaves no permit for late waiters. notified.as_mut().enable(); let state = self.state(); @@ -150,11 +118,7 @@ impl RunStateStore { } } - /// Probe the Service now, record what came back, and return the fresh Run State. - /// - /// A *transport* failure is not an observation: the Service may simply be restarting, so - /// the last confirmed health survives and the caller decides whether to retry. Only a - /// reply we could read and reject updates health. + /// Records readable replies; transport failures preserve the last confirmed health. pub async fn probe(&self) -> Result { let reply = self .env @@ -164,11 +128,7 @@ impl RunStateStore { self.record_reply(&reply) } - /// Probe until the Service answers, giving it `attempts` tries `interval` apart. - /// - /// Only an unreachable Service is worth retrying. A reply we could read is the Service's - /// answer — retrying an incompatible version twenty times would just delay telling the - /// user to reinstall — so the wait ends on the first readable reply either way. + /// Retries transport failures but stops at the first readable reply, including a rejection. pub async fn await_ready(&self, attempts: usize, interval: Duration) -> Result { let mut last_error = None; for attempt in 0..attempts { @@ -186,7 +146,6 @@ impl RunStateStore { }))) } - /// Record a readable reply as an observation, and report whether it was acceptable. fn record_reply(&self, reply: &ServiceVersionReply) -> Result { match classify_service_version_reply(reply) { ServiceVersionCheck::Ready => { @@ -200,10 +159,7 @@ impl RunStateStore { } } - /// Work out the Service's health from scratch: platform evidence first, then a live probe. - /// - /// Platform evidence that cannot be inspected is *unavailable*, not *absent* — assuming - /// "not installed" there would offer the user an install that is already there. + /// Detects health from platform evidence and then live IPC; unknown evidence is not absence. pub async fn detect_service_health(&self) -> ServiceHealth { let has_marker = match self.env.trusted_install_evidence().await { Ok(exists) => exists, @@ -234,26 +190,18 @@ impl RunStateStore { } } - // ─────────────────────────── writes ─────────────────────────── - - /// Probe and record Service health unless a newer observation arrived first. - /// - /// Reserving before I/O prevents slower probes from overwriting later observations, including - /// assertions that leave visible state unchanged. Returns the health that remains recorded. + /// Records detected health unless a newer observation arrived during I/O. pub async fn observe_current_health(&self) -> ServiceHealth { let reservation = self.reserve_health_observation(); let health = self.detect_service_health().await; self.commit_reserved_observation(health, reservation) } - /// Reserve the revision for a health probe. fn reserve_health_observation(&self) -> u64 { self.service.lock().next_observation() } - /// Commit `health` only if `reservation` is still latest; otherwise return the newer health. - /// - /// The comparison and write share one lock. A successful commit reuses its reserved revision. + /// Commits only the latest reserved observation. fn commit_reserved_observation(&self, health: ServiceHealth, reservation: u64) -> ServiceHealth { let mut state = self.service.lock(); if state.observation != reservation { @@ -276,7 +224,6 @@ impl RunStateStore { health } - /// Record an observation about the Service, clearing any request it answers. pub fn observe(&self, health: ServiceHealth) { let mut state = self.service.lock(); if state.service.health == health && state.service.pending.is_none() && !state.service.sidecar_allowed { @@ -290,11 +237,7 @@ impl RunStateStore { self.announce(); } - /// Record an authorised privileged operation and return whether it displaced the session's - /// Sidecar allowance. - /// - /// Reading and clearing the allowance in one transition lets the caller safely restore it if - /// the operation fails. + /// Records an action and returns the Sidecar allowance it displaced for possible rollback. pub fn request_action(&self, action: PendingAction) -> bool { let mut state = self.service.lock(); let displaced = state.service.sidecar_allowed; @@ -310,9 +253,7 @@ impl RunStateStore { displaced } - /// Accept Sidecar for the rest of this app session without any eligibility check. - /// - /// For builds and paths where Sidecar is the decided outcome rather than a fallback. + /// Accepts Sidecar when it is the predetermined outcome rather than a fallback. pub fn accept_sidecar(&self) { let mut state = self.service.lock(); if state.service.sidecar_allowed && state.service.pending.is_none() { @@ -326,10 +267,7 @@ impl RunStateStore { self.announce(); } - /// Restore a displaced Sidecar allowance unless one already exists or the Service is ready. - /// - /// The check and update are atomic so a restored allowance cannot shadow a newly ready - /// Service. Returns whether the allowance was restored. + /// Atomically restores an allowance unless Sidecar is already allowed or the Service is ready. pub fn restore_sidecar_allowance(&self) -> bool { let mut state = self.service.lock(); if state.service.sidecar_allowed { @@ -348,9 +286,7 @@ impl RunStateStore { true } - /// Revoke an allowance after Sidecar startup fails without changing Service health. - /// - /// Returns whether the allowance was revoked. + /// Revokes a failed Sidecar allowance without changing Service health. pub fn withdraw_sidecar_allowance(&self) -> bool { let mut state = self.service.lock(); if !state.service.sidecar_allowed { @@ -363,10 +299,7 @@ impl RunStateStore { true } - /// Accept Sidecar for the rest of this app session. - /// - /// Rejected while an operation is in flight, or from a state where the Service is - /// already usable or is mid-privileged-operation other than an install. + /// Accepts Sidecar only while the Service is unusable and no conflicting operation is active. pub fn allow_sidecar_for_session(&self) -> Result<()> { let mut state = self.service.lock(); if self.operation_running.load(Ordering::Acquire) { @@ -390,10 +323,7 @@ impl RunStateStore { Ok(()) } - /// Require the Service to be installed before the Core starts, when it is simply absent. - /// - /// A no-op from any other state, mirroring the rule that an already-requested or - /// already-usable Service must not be downgraded to "needs installing". + /// Requests installation only for a confirmed absent Service. pub fn require_install_for_session(&self) -> Result<()> { let mut state = self.service.lock(); if self.operation_running.load(Ordering::Acquire) { @@ -414,11 +344,7 @@ impl RunStateStore { Ok(()) } - /// Carry out a privileged operation and record what it did to the Service. - /// - /// A successful uninstall records an absent Service. Other successes rely on the caller's - /// readiness check. Failures re-probe the Service so the request is retired and health - /// reflects the machine's current state. + /// Runs an operation, recording uninstall success or re-probing after failure. pub async fn perform(&self, action: PendingAction) -> Result<()> { let outcome = self.env.run_privileged(action); match &outcome { @@ -436,56 +362,32 @@ impl RunStateStore { outcome } - // ─────────────────────────── running mode ─────────────────────────── - /// The Running Mode, shared so hot callers such as the tray do not allocate. pub fn mode_arc(&self) -> Arc { Arc::clone(&self.mode.load()) } - /// The Core is now running, and serving, in `mode`. pub fn core_started(&self, mode: RunningMode) { self.enter_mode(mode); } - /// The Core is no longer running. pub fn core_stopped(&self) { self.enter_mode(RunningMode::NotRunning); } - /// A start attempt is under way: the Core is not serving yet, whatever the mode says. - /// - /// Closes the PAC endpoint without disturbing the Running Mode, so a handover cannot - /// hand out a PAC script for a proxy port that is between owners. - /// - /// Every caller must pair this with [`Self::core_start_settled`]. A start attempt that - /// never happens — a candidate rejected before anything was stopped — otherwise leaves PAC - /// closed for a Core that is still serving, and nothing else would ever reopen it: PAC is - /// re-derived only when the Running Mode changes, and that is exactly what did not happen. + /// Closes PAC during a start or handover without changing the last running mode. + /// Every call must be paired with [`Self::core_start_settled`]. pub fn core_starting(&self) { self.env.set_pac_available(false); } - /// The start attempt is over, however it ended: PAC goes back to following the Running Mode. - /// - /// Idempotent and safe on every path, because it re-derives rather than restores. After a - /// start that succeeded the mode is already running and this confirms PAC open; after one - /// that was abandoned the mode never moved and this reopens PAC for the Core that kept - /// serving; after one that failed and stopped the Core the mode says NotRunning and PAC - /// stays shut. + /// Idempotently re-derives PAC availability after a start attempt settles. pub fn core_start_settled(&self) { let running = !matches!(**self.mode.load(), RunningMode::NotRunning); self.env.set_pac_available(running); } - /// Move to `mode` and re-derive everything that follows from it. - /// - /// PAC availability and the outward mirror are derived here and nowhere else, so they - /// cannot drift from the Running Mode the way ten hand-paired call sites could. - /// - /// PAC closes *before* the mode changes and opens *after*, so whatever a concurrent - /// reader catches mid-transition is the closed state: being refused a PAC script beats - /// being handed one that points at a proxy port between owners. + /// Moves to `mode`, closing PAC before the transition and reopening it only afterward. fn enter_mode(&self, mode: RunningMode) { let running = !matches!(mode, RunningMode::NotRunning); if !running { @@ -498,8 +400,6 @@ impl RunStateStore { self.announce(); } - // ─────────────────────────── privileged operations ─────────────────────────── - /// Claim the privileged-operation slot, releasing it when the guard drops. /// /// Only one privileged operation may be in flight; [`Self::settled`] waits on this. @@ -514,31 +414,22 @@ impl RunStateStore { Ok(OperationGuard { store: self }) } - /// Whether a privileged operation is in flight right now. - /// - /// Production code reads this through [`RunState::op_in_flight`]; this accessor exists for - /// tests that assert on the slot without taking a snapshot. #[cfg(test)] pub fn operation_in_flight(&self) -> bool { self.operation_running.load(Ordering::Acquire) } - /// The environment this store observes, so tests can assert on the effects it recorded. #[cfg(test)] pub const fn env(&self) -> &E { &self.env } - /// Bumped on every state change; lets tests assert that a no-op really was a no-op. #[cfg(test)] pub fn generation_count(&self) -> u64 { self.service.lock().generation } - /// Tell the outside world the Run State moved. - /// - /// Called after every mutation and never while the state lock is held, so a publisher that - /// reads back the state cannot deadlock against the writer that triggered it. + /// Publishes after dropping the state lock so observers may safely read it back. fn announce(&self) { self.env.publish(&self.state()); } diff --git a/src-tauri/src/core/runstate/owner.rs b/src-tauri/src/core/runstate/owner.rs index 8d20856e4..11109f8e4 100644 --- a/src-tauri/src/core/runstate/owner.rs +++ b/src-tauri/src/core/runstate/owner.rs @@ -1,20 +1,8 @@ -//! Deciding when the Service has stopped being ours. -//! -//! While the Core runs under the Service, the app polls the Service for owner status. Three -//! things can go wrong — we cannot read a reply, the Service says someone else owns it, or it -//! answers but describes a Core that is gone — and each needs a different response. This is -//! the decision, separated from the polling and from the recovery it triggers. -//! -//! Recovery *actions* deliberately live with the Core lifecycle rather than here: this module -//! decides that the Service can no longer be trusted, and `CoreManager` is what tears the Core -//! down in response. +//! Classifies service ownership samples; `CoreManager` performs any recovery it requests. use clash_verge_service_ipc::ServiceLifecycleState; -/// How many consecutive bad samples we tolerate before acting. -/// -/// Service restarts and slow status calls are normal; acting on the first one would tear down -/// a working proxy for a blip. +/// Tolerates transient service restarts and slow status calls. const SUSTAINED_SAMPLES: u8 = 3; /// Why the app stopped trusting the Service that was running its Core. @@ -49,11 +37,7 @@ pub enum OwnerSample { pub enum OwnerStep { /// Nothing is wrong, or not wrong for long enough yet. Continue, - /// Unreadable for long enough that it might be real. Check whether the Core's own endpoint - /// still answers, then report back via [`OwnerWatch::resolve_transport`]. - /// - /// A Service we cannot reach while the Core is still serving is a broken status channel, - /// not a lost Core — tearing down the proxy there would be the cure causing the disease. + /// Check the Core endpoint before treating a sustained status-channel failure as lost ownership. VerifyTransport, /// Stop trusting the Service and recover. Recover(OwnerRecoveryReason), @@ -75,8 +59,7 @@ impl OwnerWatch { } } - /// Whether this sample warrants a log line: only the moment a run becomes sustained, so a - /// permanently broken Service does not fill the log at the poll interval. + /// True only when a failure first becomes sustained, to avoid repeated logs. #[must_use] pub const fn just_became_sustained(&self) -> bool { self.unreadable_samples == SUSTAINED_SAMPLES @@ -120,10 +103,7 @@ impl OwnerWatch { } } - /// Report what the transport check found after [`OwnerStep::VerifyTransport`]. - /// - /// If the Core's own endpoint still answers, only the status channel is broken: the run is - /// reset so the next stretch of silence has to earn its own verdict. + /// Resets the failure run when only the status channel is unavailable. pub const fn resolve_transport(&mut self, owner_endpoint_available: bool) -> OwnerStep { if owner_endpoint_available { self.unreadable_samples = 0; diff --git a/src-tauri/src/core/runtime_bundle.rs b/src-tauri/src/core/runtime_bundle.rs index b2a1c13c9..d74276f7d 100644 --- a/src-tauri/src/core/runtime_bundle.rs +++ b/src-tauri/src/core/runtime_bundle.rs @@ -61,13 +61,8 @@ pub(crate) async fn collect_runtime_bundle(config_file: &Path, core_path: &Path) }) } -/// Rewrite one provider section's paths to service-side destinations, and record what each -/// destination is going to hold. -/// -/// The two outputs are not the same thing. `assets` are files the app owns and the service copies; -/// `remote_providers` are files the *core* downloads, which the service never writes and only -/// needs to recognise — a download cache is reusable exactly while the url that produced it is -/// unchanged, and the service has no other way to tell. +/// Rewrites provider paths and records copied assets separately from core-downloaded providers. +/// Remote URLs let the service decide whether a core download cache remains reusable. fn collect_provider_assets( config: &mut Value, section: &str, @@ -91,28 +86,13 @@ fn collect_provider_assets( let Some(raw_path) = provider.get("path").and_then(Value::as_str) else { continue; }; - // The Core decides what a provider is by its `type`, so anything else here would disagree - // with it. Keying off the presence of a `url` used to do that: a `file` provider carrying - // a stray `url` was treated as remote, its local file left uncopied, and the Core then - // could not find what the rewritten path pointed at. - // - // Disagreeing the other way is just as bad, though, and it is why neither branch may fail - // the whole bundle over one odd provider. Measured against mihomo v1.19.26: it accepts a - // `http` provider with no url, an `inline` provider carrying both a url and a path that - // does not exist, and a `file` provider whose path is missing. Refusing any of those here - // would turn a configuration the Core is willing to load into one that cannot be applied - // at all — including at start, since this is where a Service-mode start gets its bundle. - // A provider we cannot classify keeps its path unrewritten and is simply not declared. + // Match mihomo's `type` classification and leave malformed-but-accepted providers untouched. let is_remote = provider.get("type").and_then(Value::as_str) == Some("http"); let url = provider.get("url").and_then(Value::as_str).map(str::to_owned); let destination = match (is_remote, url) { (true, Some(url)) => { let destination = provider_destination(config_root, raw_path)?; - // Remote providers take their destination out of circulation as much as local ones - // do: the Service refuses a bundle where one path is claimed both by a file it must - // copy and by a file the Core downloads, because the two would overwrite each - // other. Two providers naming the same file *and* the same source are not that — - // they are one file with two names for it, which the Service folds. + // Reserve remote destinations too; copied and downloaded files must not collide. match remote_providers .iter() .find(|declared| declared.destination == destination) @@ -281,9 +261,7 @@ mod tests { ); assert!(bundle.assets.iter().any(|asset| asset.destination == "Country.mmdb")); assert!(!bundle.assets.iter().any(|asset| asset.destination.contains("remote"))); - // A remote provider is not copied, but it must still be declared: the Service decides - // whether the Core's download cache is reusable by comparing the url that produced it, - // and an undeclared provider is one it can only ever discard. + // Remote declarations preserve service-side cache reuse. assert_eq!( bundle .remote_providers @@ -305,9 +283,7 @@ mod tests { #[tokio::test] async fn two_names_for_one_remote_file_are_folded_rather_than_refused() -> anyhow::Result<()> { - // The Service folds an identical repeat, so refusing it here would block a configuration - // mihomo itself accepts — and this is also where a Service-mode *start* gets its bundle, - // so the refusal would stop the core coming up at all. + // Identical repeats are one service-side file and are safe to fold. let root = std::env::temp_dir().join(format!("clash-verge-bundle-dup-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root)?; @@ -351,8 +327,7 @@ mod tests { #[tokio::test] async fn an_odd_provider_is_left_alone_instead_of_failing_the_whole_bundle() -> anyhow::Result<()> { - // Measured against mihomo v1.19.26: it accepts a `http` provider with no url, and a - // non-http provider whose path does not exist. Neither may stop the rest being materialised. + // Odd providers accepted by mihomo must not block the rest of the bundle. let root = std::env::temp_dir().join(format!("clash-verge-bundle-odd-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root)?; diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index 172701e10..74d58b947 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -39,12 +39,8 @@ use std::{ static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0); static ACTIVE_SERVICE_SESSION: Lazy>> = Lazy::new(|| Mutex::new(None)); -/// The Service session that owns the running Core, and what that Service can do. -/// -/// The capability is learned once, when the Core is started, and discarded with the session -/// rather than cached globally — it describes *the Service instance that owns this Core*, which -/// is the only scope where it is safe to act on. A Service upgraded underneath a running Core -/// does not silently gain abilities the Core it owns was not started under. +/// Capabilities of the service session that owns the running Core. +/// They are discarded with that session rather than cached across service upgrades. #[derive(Clone)] struct ActiveServiceSession { proof: OwnerSessionProof, @@ -65,10 +61,7 @@ pub(crate) fn active_service_session() -> Result { .context("service owner session is not active") } -/// Whether the Service that started the running Core can stage a runtime in place. -/// -/// False for every reason that is not "yes": no session, an older Service, or a protocol query -/// that did not come back. All of them mean the same thing to the caller — take the slow path. +/// Returns false unless the active service session explicitly supports in-place staging. pub(crate) fn active_service_supports_runtime_staging() -> bool { ACTIVE_SERVICE_SESSION .lock() @@ -80,10 +73,7 @@ pub(crate) fn clear_active_service_session() { ACTIVE_SERVICE_SESSION.lock().take(); } -/// Ask the Service whether it speaks the staging half of the protocol. -/// -/// A failure here is not a failure to start: it only costs the fast path, so it is reported as -/// "no" and logged rather than propagated. +/// Probes staging support without failing startup when the fast path is unavailable. async fn probe_runtime_staging_support() -> bool { match clash_verge_service_ipc::get_version().await { Ok(response) if response.code == 0 => response @@ -130,11 +120,7 @@ pub enum ServiceStatus { } impl ServiceStatus { - /// Flatten a Run State snapshot into the legacy single-slot status. - /// - /// Precedence mirrors what the single slot used to hold: a requested action shadows an - /// accepted Sidecar, which shadows the last observation. Kept until the frontend seam - /// moves to `RunState` wholesale. + /// Flattens Run State using legacy precedence: action, Sidecar allowance, then health. fn from_run_state(state: &RunState) -> Self { if let Some(action) = state.pending { return match action { @@ -240,11 +226,7 @@ pub(crate) fn trusted_service_evidence() -> Result { macos_service_install_marker_exists().context("failed to inspect launchd service registration") } -/// Legacy façade over [`RUN_STATE`]. -/// -/// Holds no state of its own: Service Health, the requested action and the privileged-operation -/// lock all live in `core::runstate`. This type survives only so that existing call sites keep -/// compiling while the seam moves; it is retired once they read `RunState` directly. +/// Stateless legacy façade over [`RUN_STATE`] retained for existing call sites. pub struct ServiceManager; #[cfg(any(all(target_os = "macos", feature = "verge-dev"), test))] @@ -812,9 +794,7 @@ fn force_reinstall_service() -> Result<()> { }) } -/// Dispatch a privileged operation to the platform implementation. -/// -/// Blocking work, so it is handed to a blocking thread: the caller is async. +/// Dispatches a privileged platform operation on a blocking thread. pub(crate) fn run_privileged_service_action(action: PendingAction) -> Result<()> { let (operation, label): (fn() -> Result<()>, &'static str) = match action { PendingAction::Install => (install_service, "install service"), @@ -825,11 +805,7 @@ pub(crate) fn run_privileged_service_action(action: PendingAction) -> Result<()> tokio::task::block_in_place(operation).with_context(|| format!("{label} failed")) } -/// Describe what the Service should hold, for the Core binary this app would start. -/// -/// Shared by starting and staging so the two cannot disagree about the binary or about how the -/// configuration's provider paths are rewritten — a staged bundle naming a different core is -/// exactly what makes the Service refuse to stage. +/// Builds the same runtime bundle description for both service start and staging. async fn collect_service_runtime_bundle(config_file: &Path) -> Result { let verge_config = Config::verge().await; let clash_core = verge_config.latest_arc().get_valid_clash_core(); @@ -840,13 +816,7 @@ async fn collect_service_runtime_bundle(config_file: &Path) -> Result Result { let session = active_service_session()?; let credentials = current_owner_credentials()?; @@ -1141,10 +1107,7 @@ fn start_owner_monitor() { }); } -/// Ask the Service who owns it, flattening every unusable answer into one sample. -/// -/// A transport error, an error code and an empty payload are the same thing to the watch: -/// we did not learn anything. Only the log line distinguishes them. +/// Samples ownership, treating every unusable reply as unreadable. async fn read_owner_sample() -> OwnerSample { let response = match current_owner_credentials() { Ok(credentials) => clash_verge_service_ipc::get_status(&credentials).await, @@ -1270,11 +1233,8 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) { } } -/// Wait for a freshly installed or repaired Service to answer. -/// -/// Silence for the whole budget *is* an observation here — the Service had its window and -/// never spoke — unlike a single failed probe. A readable but rejected reply already recorded -/// its own verdict, which must not be flattened into "unavailable". +/// Waits for a repaired service, preserving readable rejection details but classifying sustained +/// silence as unavailable. async fn wait_for_service_ipc() -> Result<()> { const CONTEXT: &str = "service IPC did not become available"; let config = ServiceManager::config(); @@ -1354,9 +1314,7 @@ impl ServiceManager { } async fn apply_service_status(&self, status: ServiceStatus) -> Result<()> { - // Derived from the caller's own argument, not read back out of the store: an - // observation racing in between would clear the pending action and silently turn a - // user-authorised install into a no-op. + // Use the caller's action; a racing observation may clear the stored pending action. let Some(action) = requested_action(&status) else { self.set_status(status.clone()); return report_non_actionable_status(status); @@ -1377,9 +1335,7 @@ impl ServiceManager { } } -/// Run the full action workflow and restore the session's previous Sidecar allowance on failure. -/// -/// This includes the readiness wait: an action has not landed until the Service responds. +/// Runs through readiness and restores a displaced Sidecar allowance on failure. async fn run_action_restoring_sidecar( store: &RunStateStore, was_allowed: bool, @@ -1428,11 +1384,7 @@ fn report_non_actionable_status(status: ServiceStatus) -> Result<()> { Ok(()) } -/// Run a privileged operation while holding the Run State operation slot. -/// -/// The slot is released — and `settled` waiters woken — before `post_operation` runs, so a -/// post-operation refresh such as the tray menu observes the final state rather than a -/// state still flagged as in-flight. +/// Releases the Run State operation slot before post-operation observers refresh. async fn run_operation_and_then( store: &RunStateStore, operation: impl Future>, @@ -1451,12 +1403,7 @@ where post_operation().await } -/// Apply a legacy single-slot status to a Run State, splitting it back into observation and -/// request. The inverse of [`ServiceStatus::from_run_state`]. -/// The privileged operation a status is asking for, if any. -/// -/// A pure function of the status so that a caller can decide what to run without reading the -/// store back and racing an observation. +/// Maps a legacy status to its requested action without racing a store reread. const fn requested_action(status: &ServiceStatus) -> Option { match status { ServiceStatus::InstallRequired => Some(PendingAction::Install), diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index f644c94a2..2cbc5e176 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -156,7 +156,6 @@ impl Tray { logging!(info, Type::Tray, "System tray created successfully"); } Err(e) => { - // Don't return error, let application continue running without tray logging!( warn, Type::Tray, @@ -167,7 +166,6 @@ impl Tray { Ok(()) } - /// 更新托盘点击行为 pub async fn update_click_behavior(&self) -> Result<()> { if handle::Handle::global().is_exiting() { logging!(debug, Type::Tray, "应用正在退出,跳过托盘点击行为更新"); @@ -187,7 +185,6 @@ impl Tray { Ok(()) } - /// 更新托盘菜单 pub async fn update_menu(&self) -> Result<()> { if handle::Handle::global().is_exiting() { logging!(debug, Type::Tray, "应用正在退出,跳过托盘菜单更新"); @@ -245,7 +242,6 @@ impl Tray { Ok(()) } - /// 更新托盘图标 pub async fn update_icon(&self, verge: &IVerge) -> Result<()> { if handle::Handle::global().is_exiting() { logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新"); @@ -278,7 +274,6 @@ impl Tray { Ok(()) } - /// 更新托盘提示 pub async fn update_tooltip(&self) -> Result<()> { if handle::Handle::global().is_exiting() { logging!(debug, Type::Tray, "应用正在退出,跳过托盘提示更新"); @@ -309,7 +304,6 @@ impl Tray { } } - // Get localized strings before using them let sys_proxy_text = clash_verge_i18n::t!("tray.tooltip.systemProxy"); let tun_text = clash_verge_i18n::t!("tray.tooltip.tun"); let profile_text = clash_verge_i18n::t!("tray.tooltip.profile"); @@ -413,7 +407,6 @@ impl Tray { allow } - /// 根据配置统一更新托盘速率采集任务状态(macOS) #[cfg(target_os = "macos")] pub fn update_speed_task(&self, enable_tray_speed: bool) { self.speed_controller.update_task(enable_tray_speed); @@ -476,7 +469,6 @@ fn create_subcreate_proxy_menu_item( // TODO: 应用启动时,内核还未启动完全,无法获取代理节点信息 if let Some(proxy_nodes_data) = proxy_nodes_data { for (group_name, group_data) in proxy_nodes_data.proxies.iter() { - // Filter groups based on mode and hidden flag let should_show = match proxy_mode { "global" => group_name == "GLOBAL", _ => group_name != "GLOBAL", @@ -492,14 +484,12 @@ fn create_subcreate_proxy_menu_item( let now_proxy = group_data.now.as_deref().unwrap_or_default(); - // Create proxy items let group_items: Vec> = all_proxies .iter() .filter_map(|proxy_str| { let is_selected = *proxy_str == now_proxy; let item_id = format!("proxy_{}_{}", group_name, proxy_str); - // Get delay for display let delay_text = proxy_nodes_data .proxies .get(proxy_str) @@ -565,7 +555,6 @@ fn create_proxy_menu_item( proxy_submenus: Vec>, proxies_text: &str, ) -> Result { - // 创建代理主菜单 let (proxies_submenu, inline_proxy_items) = if show_proxy_groups_inline { ( None, @@ -660,9 +649,7 @@ async fn create_tray_menu( let profile_menu_items: Vec> = create_profile_menu_item(app_handle, profiles_preview)?; - // Pre-fetch all localized strings let texts = MenuTexts::new(); - // Convert to references only when needed let profile_menu_items_refs: Vec<&dyn IsMenuItem> = profile_menu_items .iter() .map(|item| item as &dyn IsMenuItem) @@ -842,7 +829,6 @@ async fn create_tray_menu( let separator = &PredefinedMenuItem::separator(app_handle)?; - // 动态构建菜单项 let mut menu_items: Vec<&dyn IsMenuItem> = vec![open_window, separator]; if show_outbound_modes_inline { @@ -857,7 +843,6 @@ async fn create_tray_menu( menu_items.extend_from_slice(&[separator, profiles]); - // 如果有代理节点,添加代理节点菜单 match tray_proxy_groups_display_mode { "default" => { menu_items.extend(proxies_menu.iter().map(|item| item as &dyn IsMenuItem<_>)); @@ -898,7 +883,6 @@ fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) { .. } = tray_event { - // 添加防抖检查,防止快速连击 #[allow(clippy::use_self)] if !Tray::global().should_handle_tray_click() { return; @@ -942,12 +926,10 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) { AsyncHandler::spawn(|| async move { match event.id.as_ref() { mode @ (MenuIds::RULE_MODE | MenuIds::GLOBAL_MODE | MenuIds::DIRECT_MODE) => { - // Removing the the "tray_" prefix and "_mode" suffix if let Some(stripped) = mode.strip_prefix("tray_") && let Some(final_mode) = stripped.strip_suffix("_mode") { logging!(info, Type::ProxyMode, "Switch Proxy Mode To: {}", final_mode); - // 错误已在 change_clash_mode 内部记录,此处显式忽略返回值 let _ = feat::change_clash_mode(final_mode.into()).await; } } @@ -1004,7 +986,6 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) { feat::toggle_proxy_profile(profile_index.into()).await; } id if id.starts_with("proxy_") => { - // proxy_{group_name}_{proxy_name} let rest = match id.strip_prefix("proxy_") { Some(r) => r, None => return, @@ -1019,8 +1000,5 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) { logging!(debug, Type::Tray, "Unhandled tray menu event: {:?}", event.id); } } - - // We dont expected to refresh tray state here - // as the inner handle function (SHOULD) already takes care of it }); } diff --git a/src-tauri/src/core/tray/speed_task.rs b/src-tauri/src/core/tray/speed_task.rs index 8c024b50a..4c66677de 100644 --- a/src-tauri/src/core/tray/speed_task.rs +++ b/src-tauri/src/core/tray/speed_task.rs @@ -8,12 +8,9 @@ use std::time::Duration; use tauri::async_runtime::JoinHandle; use tauri_plugin_mihomo::models::WsConnectionId; -/// 托盘速率流异常后的重连间隔。 const TRAY_SPEED_RETRY_DELAY: Duration = Duration::from_secs(1); -/// 托盘速率流在此时间内收不到有效数据时,触发重连并降级到 0/0。 const TRAY_SPEED_STALE_TIMEOUT: Duration = Duration::from_secs(5); -/// macOS 托盘速率任务控制器。 #[derive(Clone)] pub struct TraySpeedController { speed_task: Arc>>>, @@ -42,13 +39,11 @@ impl TraySpeedController { } } - /// 启动托盘速率采集后台任务(基于 `/traffic` WebSocket 流)。 fn start_task(&self) { if handle::Handle::global().is_exiting() { return; } - // 关键步骤:托盘不可用时不启动速率任务,避免无效连接重试。 if !Self::has_main_tray() { logging!(warn, Type::Tray, "托盘不可用,跳过启动托盘速率任务"); return; @@ -111,7 +106,7 @@ impl TraySpeedController { break; } - // Stale 分支在内层 loop 中已重置为 0/0;此处兜底 Closed 分支(流被远端关闭)。 + // `Stale` already reset the display; this covers remote close. Self::apply_tray_speed(0, 0); tokio::time::sleep(TRAY_SPEED_RETRY_DELAY).await; } @@ -122,17 +117,12 @@ impl TraySpeedController { *guard = Some(task); } - /// 停止托盘速率采集后台任务并清除速率显示。 fn stop_task(&self) { - // 取出任务句柄,与 speed_connection_id 一同传入清理任务。 let task = self.speed_task.lock().take(); let speed_connection_id = Arc::clone(&self.speed_connection_id); AsyncHandler::spawn(move || async move { - // 关键步骤:先等待 abort 完成,再断开 WebSocket 连接。 - // 若直接 abort 后立即 disconnect,任务可能已通过 take 取走 connection_id - // 但尚未完成断开,导致 connection_id 丢失、连接泄漏。 - // await task handle 可保证原任务已退出,connection_id 不再被占用。 + // Await abort before disconnect so the task cannot consume and lose the connection ID. if let Some(task) = task { task.abort(); let _ = task.await; diff --git a/src-tauri/src/core/updater.rs b/src-tauri/src/core/updater.rs index 7eaf90db9..d64ad77bc 100644 --- a/src-tauri/src/core/updater.rs +++ b/src-tauri/src/core/updater.rs @@ -34,8 +34,6 @@ impl SilentUpdater { } } -// ─── Disk Cache ─────────────────────────────────────────────────────────────── - #[derive(Serialize, Deserialize)] struct UpdateCacheMeta { version: String, @@ -95,10 +93,7 @@ impl SilentUpdater { } } -// ─── Version Comparison ─────────────────────────────────────────────────────── - -/// Returns true if version `a` <= version `b` using semver-like comparison. -/// Strips leading 'v', splits on '.', handles pre-release suffixes. +/// Compares numeric version components after stripping `v` and prerelease suffixes. fn version_lte(a: &str, b: &str) -> bool { let parse = |v: &str| -> Vec { v.trim_start_matches('v') @@ -127,12 +122,7 @@ fn version_lte(a: &str, b: &str) -> bool { true // equal } -/// Map the app's resolved UI language code to an NSIS installer language ID. -/// -/// The Windows installer only ships SimpChinese, English and Russian -/// (`tauri.windows.conf.json` `nsis.languages`). Languages without a matching -/// NSIS translation fall back to English; Traditional Chinese ("zhtw") is -/// mapped to SimpChinese so Chinese users don't get an English installer. +/// Maps UI language to one of the three NSIS translations, defaulting to English. #[cfg(target_os = "windows")] fn nsis_language_id(app_language: &str) -> &'static str { match app_language { @@ -142,12 +132,8 @@ fn nsis_language_id(app_language: &str) -> &'static str { } } -// ─── Startup Install & Cache Management ───────────────────────────────────── - impl SilentUpdater { - /// Called at app startup. If a cached update exists and is newer than the current version, - /// attempt to install it immediately (before the main app initializes). - /// Returns true if install was triggered (app should relaunch), false otherwise. + /// Installs a newer cached update before normal startup, if the user confirms. pub async fn try_install_on_startup(&self, app_handle: &tauri::AppHandle) -> bool { let current_version = env!("CARGO_PKG_VERSION"); @@ -178,14 +164,12 @@ impl SilentUpdater { current_version ); - // Ask user for confirmation — they can skip and use the app normally. - // The cache is preserved so next launch will ask again. + // Preserve the cache when skipped so the next launch asks again. if !Self::ask_user_to_install(app_handle, cached_version).await { logging!(info, Type::System, "User skipped update install, starting normally"); return false; } - // Read cached bytes let bytes = match Self::read_cache_bytes() { Ok(b) => b, Err(e) => { @@ -199,14 +183,8 @@ impl SilentUpdater { } }; - // Need a fresh Update object from the server to call install(). - // This is a lightweight HTTP request (< 1s), not a re-download. - // - // The updater is built with the app's current language forwarded as - // `/LANG=`. On Windows the NSIS installer parses this in - // `.onInit` (see `packages/windows/installer.nsi`) and sets `$LANGUAGE` - // directly, skipping its language-selection dialog so the update starts - // without prompting. Other platforms don't run an installer. + // Refresh metadata without re-downloading. Windows receives `/LANG` to suppress the + // NSIS language dialog; see `packages/windows/installer.nsi`. let updater_builder = app_handle.updater_builder(); #[cfg(target_os = "windows")] let updater_builder = { @@ -245,8 +223,7 @@ impl SilentUpdater { } }; - // Verify the server's version matches the cached version. - // If server now has a newer version, our cached bytes are stale. + // A changed server version invalidates the cached bytes. if update.version != *cached_version { logging!( info, @@ -262,11 +239,9 @@ impl SilentUpdater { let version = update.version.clone(); logging!(info, Type::System, "Installing cached update v{version} at startup..."); - // Show splash window so user knows the app is updating, not frozen Self::show_update_splash(app_handle, &version); - // install() is sync and may hang (known bug #2558), so run with a timeout. - // On Windows, NSIS takes over the process so install() may never return — that's OK. + // `install()` may hang (#2558); on Windows NSIS can take over without returning. let install_result = tokio::task::spawn_blocking({ let bytes = bytes.clone(); let update = update.clone(); @@ -305,7 +280,6 @@ impl SilentUpdater { } }; - // Close splash window if install failed and app continues normally if !success { Self::close_update_splash(app_handle); } @@ -314,11 +288,7 @@ impl SilentUpdater { } } -// ─── User Confirmation Dialog ──────────────────────────────────────────────── - impl SilentUpdater { - /// Show a native dialog asking the user to install or skip the update. - /// Returns true if user chose to install, false if they chose to skip. async fn ask_user_to_install(app_handle: &tauri::AppHandle, version: &str) -> bool { use tauri_plugin_dialog::{DialogExt as _, MessageDialogButtons, MessageDialogKind}; @@ -343,12 +313,8 @@ impl SilentUpdater { } } -// ─── Update Splash Window ──────────────────────────────────────────────────── - impl SilentUpdater { - /// Show a small centered splash window indicating update is being installed. - /// Injects HTML via eval() after window creation so it doesn't depend on any - /// external file in the bundle. + /// Uses injected HTML so the splash has no bundled-file dependency. fn show_update_splash(app_handle: &tauri::AppHandle, version: &str) { use tauri::{WebviewUrl, WebviewWindowBuilder}; @@ -405,7 +371,7 @@ impl SilentUpdater { "# ); - // Retry eval a few times — the webview may not be ready immediately + // The new webview may not accept evaluation immediately. std::thread::spawn(move || { for i in 0..10 { std::thread::sleep(std::time::Duration::from_millis(100 * (i + 1))); @@ -418,7 +384,6 @@ impl SilentUpdater { logging!(info, Type::System, "Update splash window shown"); } - /// Close the update splash window (e.g. after install failure). fn close_update_splash(app_handle: &tauri::AppHandle) { use tauri::Manager as _; if let Some(window) = app_handle.get_webview_window("update-splash") { @@ -428,8 +393,6 @@ impl SilentUpdater { } } -// ─── Background Check and Download ─────────────────────────────────────────── - impl SilentUpdater { async fn check_and_download(&self, app_handle: &tauri::AppHandle) -> Result<()> { let is_portable = *dirs::PORTABLE_FLAG.get().unwrap_or(&false); @@ -535,8 +498,6 @@ impl SilentUpdater { mod tests { use super::*; - // ─── version_lte tests ────────────────────────────────────────────────── - #[test] fn test_version_equal() { assert!(version_lte("2.4.7", "2.4.7")); @@ -565,10 +526,8 @@ mod tests { #[test] fn test_version_with_prerelease() { - // "2.4.8-alpha" → numeric part is still "2.4.8" assert!(version_lte("2.4.7", "2.4.8-alpha")); assert!(version_lte("2.4.8-alpha", "2.4.8")); - // Both have same numeric part, so equal → true assert!(version_lte("2.4.8-alpha", "2.4.8-beta")); } @@ -579,8 +538,6 @@ mod tests { assert!(version_lte("2.4.0", "2.4")); } - // ─── Cache metadata tests ─────────────────────────────────────────────── - #[test] fn test_cache_meta_serialize_roundtrip() { let meta = UpdateCacheMeta { diff --git a/src-tauri/src/core/validate.rs b/src-tauri/src/core/validate.rs index 1de0d5649..99e9e455d 100644 --- a/src-tauri/src/core/validate.rs +++ b/src-tauri/src/core/validate.rs @@ -133,16 +133,13 @@ impl CoreConfigValidator { } impl CoreConfigValidator { - /// 检查文件是否为脚本文件 async fn is_script_file(path: &str) -> Result { - // 1. 先通过扩展名快速判断 if has_ext(path, "yaml") || has_ext(path, "yml") { - return Ok(false); // YAML文件不是脚本文件 + return Ok(false); } else if has_ext(path, "js") { - return Ok(true); // JS文件是脚本文件 + return Ok(true); } - // 2. 读取文件内容 let content = match fs::read_to_string(path).await { Ok(content) => content, Err(err) => { @@ -151,13 +148,11 @@ impl CoreConfigValidator { } }; - // 3. 检查是否存在明显的YAML特征 let has_yaml_features = content.contains(": ") || content.contains("#") || content.contains("---") || content.lines().any(|line| line.trim().starts_with("- ")); - // 4. 检查是否存在明显的JS特征 let has_js_features = content.contains("function ") || content.contains("const ") || content.contains("let ") @@ -168,16 +163,11 @@ impl CoreConfigValidator { || content.contains("export ") || content.contains("import "); - // 5. 决策逻辑 if has_yaml_features && !has_js_features { - // 只有YAML特征,没有JS特征 return Ok(false); } else if has_js_features && !has_yaml_features { - // 只有JS特征,没有YAML特征 return Ok(true); } else if has_yaml_features && has_js_features { - // 两种特征都有,需要更精细判断 - // 优先检查是否有明确的JS结构特征 if content.contains("function main") || content.contains("module.exports") || content.contains("export default") @@ -185,24 +175,21 @@ impl CoreConfigValidator { return Ok(true); } - // 检查冒号后是否有空格(YAML的典型特征) let yaml_pattern_count = content.lines().filter(|line| line.contains(": ")).count(); if yaml_pattern_count > 2 { - return Ok(false); // 多个键值对格式,更可能是YAML + return Ok(false); } } - // 默认情况:无法确定时,假设为非脚本文件(更安全) + // Unknown files take the safer YAML validation path. logging!(debug, Type::Validate, "无法确定文件类型,默认当作YAML处理: {}", path); Ok(false) } - /// 只进行文件语法检查,不进行完整验证 async fn validate_file_syntax_outcome(config_path: &str) -> Result { logging!(info, Type::Validate, "开始检查文件: {}", config_path); - // 读取文件内容 let content = match fs::read_to_string(config_path).await { Ok(content) => content, Err(err) => { @@ -211,7 +198,6 @@ impl CoreConfigValidator { return Ok(ValidationOutcome::invalid_from_message(error_msg)); } }; - // 对YAML文件尝试解析,只检查语法正确性 logging!(info, Type::Validate, "进行YAML语法检查"); match serde_yaml_ng::from_str::(&content) { Ok(_) => { @@ -226,9 +212,7 @@ impl CoreConfigValidator { } } - /// 验证脚本文件语法 async fn validate_script_file_outcome(path: &str) -> Result { - // 读取脚本内容 let content = match fs::read_to_string(path).await { Ok(content) => content, Err(err) => { @@ -240,7 +224,6 @@ impl CoreConfigValidator { logging!(debug, Type::Validate, "验证脚本文件: {}", path); - // 使用boa引擎进行基本语法检查 use boa_engine::{Context, Source}; let mut context = Context::default(); @@ -258,7 +241,6 @@ impl CoreConfigValidator { Ok(_) => { logging!(debug, Type::Validate, "脚本语法验证通过: {}", path); - // 检查脚本是否包含main函数 if !content.contains("function main") && !content.contains("const main") && !content.contains("let main") @@ -278,12 +260,10 @@ impl CoreConfigValidator { } } - /// 验证指定的配置文件 pub async fn validate_config_file_outcome( config_path: &str, is_merge_file: Option, ) -> Result { - // 检查程序是否正在退出,如果是则跳过验证 if handle::Handle::global().is_exiting() { logging!(info, Type::Core, "应用正在退出,跳过验证"); return Ok(ValidationOutcome::Skipped { @@ -291,13 +271,11 @@ impl CoreConfigValidator { }); } - // 检查文件是否存在 if !std::path::Path::new(config_path).exists() { let error_msg: String = format!("File not found: {config_path}").into(); return Ok(ValidationOutcome::invalid_from_message(error_msg)); } - // 如果是合并文件且不是强制验证,执行语法检查但不进行完整验证 if is_merge_file.unwrap_or(false) { logging!(info, Type::Validate, "检测到Merge文件,仅进行语法检查: {}", config_path); return Self::validate_file_syntax_outcome(config_path).await; @@ -306,7 +284,6 @@ impl CoreConfigValidator { let is_script = match Self::is_script_file(config_path).await { Ok(result) => result, Err(err) => { - // 如果无法确定文件类型,尝试使用Clash内核验证 logging!(warn, Type::Validate, "无法确定文件类型: {}, 错误: {}", config_path, err); return Self::validate_config_internal_outcome(config_path).await; } @@ -322,14 +299,11 @@ impl CoreConfigValidator { return Self::validate_script_file_outcome(config_path).await; } - // 对YAML配置文件使用Clash内核验证 logging!(info, Type::Validate, "使用Clash内核验证配置文件: {}", config_path); Self::validate_config_internal_outcome(config_path).await } - /// 内部验证配置文件的实现 async fn validate_config_internal_outcome(config_path: &str) -> Result { - // 检查程序是否正在退出,如果是则跳过验证 if handle::Handle::global().is_exiting() { logging!(info, Type::Validate, "应用正在退出,跳过验证"); return Ok(ValidationOutcome::Skipped { @@ -347,7 +321,6 @@ impl CoreConfigValidator { let app_dir_str = dirs::path_to_str(&app_dir)?; logging!(info, Type::Validate, "验证目录: {}", app_dir_str); - // 使用子进程运行clash验证配置 let command = app_handle.shell().sidecar(clash_core.as_str()).map_err(|error| { anyhow::anyhow!("failed to build validation command for core {clash_core:?}: {error:#}") })?; @@ -363,7 +336,6 @@ impl CoreConfigValidator { let stderr = &output.stderr; let stdout = &output.stdout; - // 检查进程退出状态和错误输出 let error_keywords = ["FATA", "fatal", "Parse config error", "level=fatal"]; let has_error = !status.success() || contains_any_keyword(stderr, &error_keywords); @@ -399,7 +371,6 @@ impl CoreConfigValidator { } } - /// 验证运行时配置 pub async fn validate_config_outcome(&self) -> Result { if !self.try_start() { logging!(info, Type::Validate, "验证已在进行中,跳过新的验证请求"); diff --git a/src-tauri/src/enhance/chain.rs b/src-tauri/src/enhance/chain.rs index c2132e84e..868e6ce29 100644 --- a/src-tauri/src/enhance/chain.rs +++ b/src-tauri/src/enhance/chain.rs @@ -28,42 +28,6 @@ pub enum ChainSupport { ClashMetaAlpha, } -// impl From<&PrfItem> for Option { -// fn from(item: &PrfItem) -> Self { -// let itype = item.itype.as_ref()?.as_str(); -// let file = item.file.clone()?; -// let uid = item.uid.clone().unwrap_or("".into()); -// let path = dirs::app_profiles_dir().ok()?.join(file); - -// if !path.exists() { -// return None; -// } - -// match itype { -// "script" => Some(ChainItem { -// uid, -// data: ChainType::Script(fs::read_to_string(path).ok()?), -// }), -// "merge" => Some(ChainItem { -// uid, -// data: ChainType::Merge(help::read_mapping(&path).ok()?), -// }), -// "rules" => Some(ChainItem { -// uid, -// data: ChainType::Rules(help::read_seq_map(&path).ok()?), -// }), -// "proxies" => Some(ChainItem { -// uid, -// data: ChainType::Proxies(help::read_seq_map(&path).ok()?), -// }), -// "groups" => Some(ChainItem { -// uid, -// data: ChainType::Groups(help::read_seq_map(&path).ok()?), -// }), -// _ => None, -// } -// } -// } // Helper trait to allow async conversion pub trait AsyncChainItemFrom { async fn from_async(item: &PrfItem) -> Option; diff --git a/src-tauri/src/enhance/mod.rs b/src-tauri/src/enhance/mod.rs index 33e069b7f..6cec40344 100644 --- a/src-tauri/src/enhance/mod.rs +++ b/src-tauri/src/enhance/mod.rs @@ -521,12 +521,10 @@ fn merge_default_config( continue; } } - // 处理 external-controller 键的开关逻辑 if key.as_str() == Some("external-controller") { if enable_external_controller { config.insert(key, value); } else { - // 如果禁用了外部控制器,设置为空字符串 config.insert(key, "".into()); } } else { @@ -656,7 +654,7 @@ fn ensure_fake_ip_range6(dns: &mut Mapping) { .map(|m| m == "fake-ip") .unwrap_or(true); - // 缺失或为空字符串(可能来自手动编辑的 YAML)时都需要补充 + // Hand-edited YAML may leave the key present but empty. let range6_missing = dns .get("fake-ip-range6") .and_then(|v| v.as_str()) @@ -702,10 +700,8 @@ async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> M config } -/// Enhance mode -/// 返回最终订阅、该订阅包含的键、和script执行的结果 +/// Returns the enhanced profile, its original keys, and script logs. pub async fn enhance(profiles: &IProfiles) -> Result<(Mapping, HashSet, HashMap)> { - // gather config values let cfg_vals = get_config_values().await; let ConfigValues { clash_config, @@ -722,7 +718,6 @@ pub async fn enhance(profiles: &IProfiles) -> Result<(Mapping, HashSet, tproxy_enabled, } = cfg_vals; - // collect profile items let profile = collect_profile_items(profiles).await?; let config = profile.config; let merge_item = profile.merge_item; @@ -736,11 +731,9 @@ pub async fn enhance(profiles: &IProfiles) -> Result<(Mapping, HashSet, let result_map = HashMap::new(); - // 顺序项先于手动覆盖。 let config = process_seq_items(config, rules_item, proxies_item, groups_item); let exists_keys = use_keys(&config).collect::>(); - // merge default clash config let config = merge_default_config( config, clash_config, @@ -753,15 +746,12 @@ pub async fn enhance(profiles: &IProfiles) -> Result<(Mapping, HashSet, tproxy_enabled, ); - // app 生成项先于手动覆盖。 let config = apply_builtin_scripts(config, clash_core, enable_builtin).await; let config = use_tun(config, enable_tun); let config = apply_dns_settings(config, enable_dns_settings).await; - // 手动覆盖前锁定 app 权威字段,覆盖后由同一个值恢复。 let authoritative = AuthoritativeFields::capture(&config, enable_dns_settings); - // 全局手动覆盖。 let (config, exists_keys, result_map) = process_global_items( config, exists_keys, @@ -772,11 +762,9 @@ pub async fn enhance(profiles: &IProfiles) -> Result<(Mapping, HashSet, ) .await; - // 当前 profile 手动覆盖。 let (config, exists_keys, result_map) = process_profile_items(config, exists_keys, result_map, merge_item, script_item, &profile_name).await; - // 手动覆盖后恢复 app 权威字段。 let config = authoritative.enforce(config); let config = ensure_lan_bind_address(config); diff --git a/src-tauri/src/enhance/tun.rs b/src-tauri/src/enhance/tun.rs index df4599b51..03700a648 100644 --- a/src-tauri/src/enhance/tun.rs +++ b/src-tauri/src/enhance/tun.rs @@ -18,7 +18,6 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping { }); if enable { - // 读取DNS配置 let dns_key = Value::from("dns"); let dns_val = config.get(&dns_key); let mut dns_val = dns_val.map_or_else(Mapping::new, |val| { @@ -27,13 +26,11 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping { let ipv6_key = Value::from("ipv6"); let ipv6_val = config.get(&ipv6_key).and_then(|v| v.as_bool()).unwrap_or(false); - // 检查现有的 enhanced-mode 设置 let current_mode = dns_val .get(Value::from("enhanced-mode")) .and_then(|v| v.as_str()) .unwrap_or("fake-ip"); - // 只有当 enhanced-mode 是 fake-ip 或未设置时才修改 DNS 配置 if current_mode == "fake-ip" || !dns_val.contains_key(Value::from("enhanced-mode")) { revise!(dns_val, "enable", true); revise!(dns_val, "ipv6", ipv6_val); @@ -46,7 +43,6 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping { revise!(dns_val, "fake-ip-range", "198.18.0.1/16"); } - // 当启用 IPv6 时,补充 IPv6 的 fake-ip 范围 if ipv6_val && !dns_val.contains_key(Value::from("fake-ip-range6")) { revise!(dns_val, "fake-ip-range6", "fdfe:dcba:9876::1/64"); } @@ -60,17 +56,14 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping { } } - // 当TUN启用时,将修改后的DNS配置写回 revise!(config, "dns", dns_val); } else { - // TUN未启用时,仅恢复系统DNS,不修改配置文件中的DNS设置 #[cfg(target_os = "macos")] AsyncHandler::spawn(move || async move { crate::utils::resolve::dns::restore_public_dns().await; }); } - // 更新TUN配置 revise!(tun_val, "enable", enable); revise!(config, "tun", tun_val); diff --git a/src-tauri/src/feat/backup.rs b/src-tauri/src/feat/backup.rs index 679d14890..7dff211c6 100644 --- a/src-tauri/src/feat/backup.rs +++ b/src-tauri/src/feat/backup.rs @@ -24,15 +24,13 @@ pub struct LocalBackupFile { pub content_length: u64, } -/// Load restored verge.yaml from disk, merge back WebDAV creds, save, and sync memory. -/// Also reload other restored configs so restarts won't overwrite them. +/// Reloads restored configs into memory while preserving WebDAV credentials. async fn finalize_restored_verge_config( webdav_url: Option, webdav_username: Option, webdav_password: Option, ) -> Result<()> { - // Do NOT silently fallback to defaults; a broken/missing verge.yaml means restore failed. - // Propagate the error so the UI/user can react accordingly. + // A broken restored `verge.yaml` is a restore failure, not a reason to load defaults. let mut restored = help::read_yaml::(&verge_path()?).await?; restored.webdav_url = webdav_url; restored.webdav_username = webdav_username; @@ -59,8 +57,7 @@ async fn finalize_restored_verge_config( }); verge_draft.apply(); - // Ensure side-effects (flags, tray, sysproxy, hotkeys, auto-backup refresh, etc.) run. - // Use not_save_file = true to avoid extra I/O (we already persisted the restored file). + // Run configuration side effects without rewriting the already-restored file. if let Err(err) = super::patch_verge(&restored, true).await { logging!(error, Type::Backup, "Failed to apply restored verge config: {err:#?}"); // Propagate unknown proxy state; ordinary side-effect failures stay logged. @@ -71,7 +68,6 @@ async fn finalize_restored_verge_config( Ok(()) } -/// Create a backup and upload to WebDAV pub async fn create_backup_and_upload_webdav() -> Result<()> { let (file_name, temp_file_path) = backup::create_backup().await.map_err(|err| { logging!(error, Type::Backup, "Failed to create backup: {err:#?}"); @@ -83,7 +79,6 @@ pub async fn create_backup_and_upload_webdav() -> Result<()> { .await { logging!(error, Type::Backup, "Failed to upload to WebDAV: {err:#?}"); - // 上传失败时重置客户端缓存 backup::WebDavClient::global().reset(); return Err(err); } @@ -95,7 +90,6 @@ pub async fn create_backup_and_upload_webdav() -> Result<()> { Ok(()) } -/// List WebDAV backups pub async fn list_wevdav_backup() -> Result> { backup::WebDavClient::global().list().await.map_err(|err| { logging!(error, Type::Backup, "Failed to list WebDAV backup files: {err:#?}"); @@ -103,7 +97,6 @@ pub async fn list_wevdav_backup() -> Result> { }) } -/// Delete WebDAV backup pub async fn delete_webdav_backup(filename: String) -> Result<()> { backup::WebDavClient::global().delete(filename).await.map_err(|err| { logging!(error, Type::Backup, "Failed to delete WebDAV backup file: {err:#?}"); @@ -111,7 +104,6 @@ pub async fn delete_webdav_backup(filename: String) -> Result<()> { }) } -/// Restore WebDAV backup pub async fn restore_webdav_backup(filename: String) -> Result<()> { let verge = Config::verge().await; let verge_data = verge.latest_arc(); @@ -130,19 +122,16 @@ pub async fn restore_webdav_backup(filename: String) -> Result<()> { err })?; - // extract zip file let value = backup_storage_path.clone(); let file = AsyncHandler::spawn_blocking(move || std::fs::File::open(&value)).await??; let mut zip = zip::ZipArchive::new(file)?; let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await; zip.extract(app_home_dir()?)?; let res = finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await; - // Finally remove the temp file (attempt cleanup even if finalize fails) let _ = backup_storage_path.remove_if_exists().await; res } -/// Create a backup and save to local storage pub async fn create_local_backup() -> Result<()> { create_local_backup_with_namer(|name| name.to_string().into()) .await @@ -164,7 +153,6 @@ where if let Err(err) = move_file(temp_file_path.clone(), target_path.clone()).await { logging!(error, Type::Backup, "Failed to move local backup file: {err:#?}"); - // 清理临时文件 if let Err(clean_err) = temp_file_path.remove_if_exists().await { logging!( warn, @@ -178,7 +166,6 @@ where Ok(final_name) } -/// Import an existing backup file into the local backup directory pub async fn import_local_backup(source: String) -> Result { let source_path = PathBuf::from(source.as_str()); if !source_path.exists() { @@ -206,7 +193,6 @@ pub async fn import_local_backup(source: String) -> Result { let target_path = backup_dir.join(file_name); if target_path == source_path { - // Already located in the backup directory return Ok(file_name.to_string().into()); } @@ -233,7 +219,7 @@ async fn move_file(from: PathBuf, to: PathBuf) -> Result<()> { match fs::rename(&from, &to).await { Ok(_) => Ok(()), Err(rename_err) => { - // Attempt copy + remove as fallback, covering cross-device moves + // Rename can fail across filesystems; fall back to copy then remove. logging!( warn, Type::Backup, @@ -250,7 +236,6 @@ async fn move_file(from: PathBuf, to: PathBuf) -> Result<()> { } } -/// List local backups pub async fn list_local_backup() -> Result> { let backup_dir = local_backup_dir()?; if !backup_dir.exists() { @@ -286,7 +271,6 @@ pub async fn list_local_backup() -> Result> { Ok(backups) } -/// Delete local backup pub async fn delete_local_backup(filename: String) -> Result<()> { let backup_dir = local_backup_dir()?; let target_path = backup_dir.join(filename.as_str()); @@ -298,7 +282,6 @@ pub async fn delete_local_backup(filename: String) -> Result<()> { Ok(()) } -/// Restore local backup pub async fn restore_local_backup(filename: String) -> Result<()> { let backup_dir = local_backup_dir()?; let target_path = backup_dir.join(filename.as_str()); @@ -324,7 +307,6 @@ pub async fn restore_local_backup(filename: String) -> Result<()> { Ok(()) } -/// Export local backup file to user selected destination pub async fn export_local_backup(filename: String, destination: String) -> Result<()> { let backup_dir = local_backup_dir()?; let source_path = backup_dir.join(filename.as_str()); diff --git a/src-tauri/src/feat/clash.rs b/src-tauri/src/feat/clash.rs index 76a3f71c1..b20a04c5c 100644 --- a/src-tauri/src/feat/clash.rs +++ b/src-tauri/src/feat/clash.rs @@ -23,7 +23,6 @@ static TLS_CONFIG: Lazy> = Lazy::new(|| { Arc::new(config) }); -/// Restart the Clash core pub async fn restart_clash_core() { match CoreManager::global().restart_core().await { Ok(_) => { @@ -37,10 +36,8 @@ pub async fn restart_clash_core() { } } -/// Restart the application pub async fn restart_app() { logging!(debug, Type::System, "启动重启应用流程"); - // 设置退出标志 handle::Handle::global().set_is_exiting(); Config::apply_all_and_save_file().await; @@ -84,14 +81,10 @@ fn after_change_clash_mode() { }); } -/// Change Clash mode (rule/global/direct/script) -/// -/// mihomo `/configs` PATCH 失败时返回 `Err`,以便命令层把失败上抛给前端。 -/// (此前该函数吞掉错误并始终视为成功,导致 UI 误判"切换成功"、看似"切不动"。) +/// Propagates mihomo PATCH failures so the frontend can roll back its optimistic mode. pub async fn change_clash_mode(mode: String) -> Result<(), String> { let mut mapping = Mapping::new(); mapping.insert(Value::from("mode"), Value::from(mode.as_str())); - // Convert YAML mapping to JSON Value let json_value = serde_json::json!({ "mode": mode }); @@ -101,12 +94,10 @@ pub async fn change_clash_mode(mode: String) -> Result<(), String> { return Err(err.to_string().into()); } - // 更新订阅 let clash = Config::clash().await; clash.edit_draft(|d| d.patch_config(&mapping)); clash.apply(); - // 分离数据获取和异步调用 let clash_data = clash.data_arc(); if clash_data.save_config().await.is_ok() { handle::Handle::refresh_clash(); @@ -184,7 +175,6 @@ pub async fn test_delay(url: String) -> anyhow::Result { let _ = stream.read(&mut buf).await?; } - // frontend treats 0 as timeout Ok((start.elapsed().as_millis() as u32).max(1)) }) .await diff --git a/src-tauri/src/feat/proxy.rs b/src-tauri/src/feat/proxy.rs index e5fca7702..e1d00172e 100644 --- a/src-tauri/src/feat/proxy.rs +++ b/src-tauri/src/feat/proxy.rs @@ -14,13 +14,11 @@ use clash_verge_logging::{Type, logging}; use std::env; use tauri_plugin_clipboard_manager::ClipboardExt as _; -/// Toggle system proxy, returning `None` when it does not change. pub async fn toggle_system_proxy() -> Option { let verge = Config::verge().await; let current = verge.latest_arc().enable_system_proxy.unwrap_or(false); let auto_close_connection = verge.latest_arc().auto_close_connection.unwrap_or(false); - // 如果当前系统代理即将关闭,且自动关闭连接设置为true,则关闭所有连接 if current && auto_close_connection && let Err(err) = handle::Handle::mihomo().close_all_connections().await @@ -42,7 +40,6 @@ pub async fn toggle_system_proxy() -> Option { .await; match patch_result { - // Patch processing refreshes every caller. Ok(_) => Some(requested), Err(err) => { logging!(error, Type::ProxyMode, "{err:#}"); @@ -52,7 +49,6 @@ pub async fn toggle_system_proxy() -> Option { } } -/// Notify only when a classified failure remains pending. async fn report_toggle_failure(error: &anyhow::Error) { let recorded = proxy_control::is_reportable(error); @@ -69,8 +65,6 @@ const fn toggle_operation(requested: bool) -> FailedOperation { } } -/// Toggle TUN mode on/off -/// Returns the updated toggle state pub async fn toggle_tun_mode(not_save_file: Option) -> bool { let current = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false); let enable = !current; @@ -86,11 +80,7 @@ pub async fn toggle_tun_mode(not_save_file: Option) -> bool { { Ok(_) => { handle::Handle::refresh_verge(); - // Read back rather than returning what was asked for: patching TUN reconciles it - // afterwards, and where TUN cannot work that reconciliation turns it straight off - // again. This path is not gated on availability — it is the global hotkey — so - // reporting the request would tell the caller TUN is on moments before the notice - // saying it was disabled. + // Reconciliation may immediately disable unavailable TUN; report the resulting state. Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false) } Err(err) => { @@ -100,7 +90,6 @@ pub async fn toggle_tun_mode(not_save_file: Option) -> bool { } } -/// Copy proxy environment variables to clipboard pub async fn copy_clash_env() { let env_ip = env::var("CLASH_VERGE_REV_IP").ok(); let verge_cfg = Config::verge().await.latest_arc(); @@ -109,9 +98,7 @@ pub async fn copy_clash_env() { .unwrap_or_else(|| verge_cfg.proxy_host.as_deref().unwrap_or("127.0.0.1")); let app_handle = handle::Handle::app_handle(); - // The user is about to paste this into a shell, so it has to be the port the Core is - // really on — and this path is user-triggered, so a round-trip is affordable. It also - // used to fall back to a hardcoded 7897, ignoring the Merge Config entirely. + // Clipboard output must use the core's live port, including merge-config overrides. let port = MixedPort::effective().await; let http_proxy = format!("http://{ip}:{port}"); let socks5_proxy = format!("socks5://{ip}:{port}"); diff --git a/src-tauri/src/utils/connections_stream.rs b/src-tauri/src/utils/connections_stream.rs index e76193fce..b41bbdb89 100644 --- a/src-tauri/src/utils/connections_stream.rs +++ b/src-tauri/src/utils/connections_stream.rs @@ -6,27 +6,20 @@ use tauri_plugin_mihomo::models::WsConnectionId; use tokio::sync::mpsc; use tokio::time::Instant; -/// Mihomo WebSocket 流的有界队列容量,避免异常场景下内存无限增长。 +/// Bounds memory use if the consumer falls behind the WebSocket. const MIHOMO_WS_STREAM_BUFFER_SIZE: usize = 8; -/// 断开 Mihomo WebSocket 连接时使用的关闭码(RFC 6455 标准正常关闭)。 const MIHOMO_WS_STREAM_CLOSE_CODE: u64 = 1000; -/// `/traffic` 即时速率事件(字节/秒)。 #[derive(Debug, Clone, Copy)] pub struct TrafficSpeedEvent { pub up: u64, pub down: u64, } -/// Mihomo WebSocket 流消费状态。 pub enum StreamConsumeState { - /// 收到一条业务事件。 Event(T), - /// 连接关闭或消息流结束。 Closed, - /// 在超时时间内未收到有效事件,需要重连。 Stale, - /// 上层请求退出消费循环。 ExitRequested, } @@ -34,13 +27,9 @@ enum InternalWsEvent { Data(T), } -/// Mihomo WebSocket 订阅句柄(通用事件流)。 pub struct MihomoWsEventStream { - /// 当前订阅连接 ID,用于主动断开。 pub connection_id: WsConnectionId, - /// 当前订阅消息接收器。 receiver: mpsc::Receiver>, - /// 最近一次收到有效事件的时间戳。 last_valid_event_at: Instant, } @@ -61,19 +50,15 @@ fn parse_traffic_event(data: &[u8]) -> Option fn try_send_internal_event(message_tx: &mpsc::Sender>, event: InternalWsEvent) { if let Err(err) = message_tx.try_send(event) { match err { - // 队列满时丢弃本次事件,下一次事件会继续覆盖更新。 + // A later real-time sample supersedes one dropped from a full queue. tokio::sync::mpsc::error::TrySendError::Full(_) => {} - // 任务已结束时通道可能关闭,忽略即可。 tokio::sync::mpsc::error::TrySendError::Closed(_) => {} } } } -/// 建立 `/traffic` WebSocket 订阅(通用流)。 pub async fn connect_traffic_stream() -> Result> { - // 使用有界 mpsc 通道承接回调事件,限制消息积压上限。 let (message_tx, message_rx) = mpsc::channel::>(MIHOMO_WS_STREAM_BUFFER_SIZE); - // 建立 Mihomo `/traffic` WebSocket 订阅。 let connection_id = handle::Handle::mihomo() .ws_traffic({ let message_tx = message_tx.clone(); @@ -93,11 +78,6 @@ pub async fn connect_traffic_stream() -> Result MihomoWsEventStream { - /// 等待下一次可用事件或结束状态。 - /// - /// # Arguments - /// * `stale_timeout` - 无有效事件超时时间 - /// * `should_exit` - 上层退出判定函数 pub async fn next_event(&mut self, stale_timeout: Duration, should_exit: F) -> StreamConsumeState where F: Fn() -> bool, @@ -132,10 +112,6 @@ impl MihomoWsEventStream { } } -/// 断开指定 Mihomo WebSocket 连接。 -/// -/// # Arguments -/// * `connection_id` - 目标连接 ID pub async fn disconnect_connection(connection_id: WsConnectionId) { if let Err(err) = handle::Handle::mihomo() .disconnect(connection_id, Some(MIHOMO_WS_STREAM_CLOSE_CODE)) diff --git a/src-tauri/src/utils/dirs.rs b/src-tauri/src/utils/dirs.rs index 3c6b02498..b4193137a 100644 --- a/src-tauri/src/utils/dirs.rs +++ b/src-tauri/src/utils/dirs.rs @@ -24,7 +24,6 @@ pub static PROFILE_YAML: &str = "profiles.yaml"; /// Marks that the one-shot raise of too-short auto-update intervals has already run. pub static UPDATE_INTERVAL_MIGRATED: &str = ".update-interval-migrated"; -/// init portable flag pub fn init_portable_flag() -> Result<()> { use tauri::utils::platform::current_exe; @@ -40,7 +39,6 @@ pub fn init_portable_flag() -> Result<()> { Ok(()) } -/// get the verge app home dir pub fn app_home_dir() -> Result { use tauri::utils::platform::current_exe; @@ -54,7 +52,7 @@ pub fn app_home_dir() -> Result { return Ok(PathBuf::from(app_dir).join(".config").join(APP_ID)); } - // 避免在Handle未初始化时崩溃 + // Directory helpers can run before the Tauri handle is initialized. let app_handle = handle::Handle::app_handle(); match app_handle.path().data_dir() { @@ -88,9 +86,7 @@ pub fn preinit_app_data_dir() -> Result { Ok(root.join(APP_ID)) } -/// get the resources dir pub fn app_resources_dir() -> Result { - // 避免在Handle未初始化时崩溃 let app_handle = handle::Handle::app_handle(); match app_handle.path().resource_dir() { @@ -102,12 +98,10 @@ pub fn app_resources_dir() -> Result { } } -/// profiles dir pub fn app_profiles_dir() -> Result { Ok(app_home_dir()?.join("profiles")) } -/// icons dir pub fn app_icons_dir() -> Result { Ok(app_home_dir()?.join("icons")) } @@ -131,29 +125,24 @@ pub fn find_target_icons(target: &str) -> Result> { icon_path.map(|path| path_to_str(&path).map(|s| s.into())).transpose() } -/// logs dir pub fn app_logs_dir() -> Result { Ok(app_home_dir()?.join("logs")) } -/// service logs dir #[cfg(target_os = "macos")] pub fn service_logs_root_dir() -> Result { Ok(app_home_dir()?.join("service-logs")) } -/// service logs dir #[cfg(not(target_os = "macos"))] pub fn service_logs_root_dir() -> Result { app_logs_dir() } -// latest verge log pub fn app_latest_log() -> Result { Ok(app_logs_dir()?.join("latest.log")) } -/// local backups dir pub fn local_backup_dir() -> Result { let dir = app_home_dir()?.join(BACKUP_DIR); fs::create_dir_all(&dir)?; @@ -222,18 +211,14 @@ pub fn get_encryption_key() -> Result> { let key_path = app_dir.join(".encryption_key"); if key_path.exists() { - // Read existing key fs::read(&key_path).map_err(|e| anyhow::anyhow!("Failed to read encryption key: {}", e)) } else { - // Generate and save new key let mut key = vec![0u8; 32]; getrandom::fill(&mut key)?; - // Ensure directory exists if let Some(parent) = key_path.parent() { fs::create_dir_all(parent).map_err(|e| anyhow::anyhow!("Failed to create key directory: {}", e))?; } - // Save key fs::write(&key_path, &key).map_err(|e| anyhow::anyhow!("Failed to save encryption key: {}", e))?; Ok(key) } diff --git a/src-tauri/src/utils/help.rs b/src-tauri/src/utils/help.rs index 315e3c33f..7085fda0b 100644 --- a/src-tauri/src/utils/help.rs +++ b/src-tauri/src/utils/help.rs @@ -9,7 +9,6 @@ use std::{ str::FromStr, }; -/// read data from yaml as struct T pub async fn read_yaml(path: &PathBuf) -> Result { if !tokio::fs::try_exists(path).await.unwrap_or(false) { bail!("file not found \"{}\"", path.display()); @@ -20,7 +19,6 @@ pub async fn read_yaml(path: &PathBuf) -> Result { Ok(with_encryption(|| async { serde_yaml_ng::from_str::(&yaml_str) }).await?) } -/// read mapping from yaml pub async fn read_mapping(path: &PathBuf) -> Result { if !tokio::fs::try_exists(path).await.unwrap_or(false) { bail!("file not found \"{}\"", path.display()); @@ -30,7 +28,6 @@ pub async fn read_mapping(path: &PathBuf) -> Result { .await .with_context(|| format!("failed to read the file \"{}\"", path.display()))?; - // YAML语法检查 match serde_yaml_ng::from_str::(&yaml_str) { Ok(mut val) => { val.apply_merge() @@ -52,13 +49,10 @@ pub async fn read_mapping(path: &PathBuf) -> Result { } } -/// read mapping from yaml fix #165 pub async fn read_seq_map(path: &PathBuf) -> Result { read_yaml(path).await } -/// save the data to the file -/// can set `prefix` string to add some comments pub async fn save_yaml(path: &PathBuf, data: &T, prefix: Option<&str>) -> Result<()> { let data_str = with_encryption(|| async { serde_yaml_ng::to_string(data) }).await?; @@ -90,14 +84,11 @@ const ALPHABET: [char; 62] = [ 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', ]; -/// generate the uid pub fn get_uid(prefix: &str) -> String { let id = nanoid!(11, &ALPHABET); format!("{prefix}{id}") } -/// parse the string -/// xxx=123123; => 123123 pub fn parse_str(target: &str, key: &str) -> Option { target.split(';').map(str::trim).find_map(|s| { let mut parts = s.splitn(2, '='); @@ -108,18 +99,13 @@ pub fn parse_str(target: &str, key: &str) -> Option { }) } -/// Mask sensitive parts of a subscription URL for safe logging. -/// Examples: -/// - `https://example.com/api/v1/clash?token=abc123` → `https://example.com/api/v1/clash?token=***` -/// - `https://example.com/abc123def456ghi789/clash` → `https://example.com/***/clash` +/// Masks query values and token-like path segments for logging. pub fn mask_url(url: &str) -> String { - // Split off query string let (path_part, query_part) = match url.find('?') { Some(pos) => (&url[..pos], Some(&url[pos + 1..])), None => (url, None), }; - // Extract scheme+host prefix (everything up to the first '/' after "://") let host_end = path_part .find("://") .and_then(|scheme_end| { @@ -134,7 +120,6 @@ pub fn mask_url(url: &str) -> String { let mut result = scheme_and_host.to_owned(); - // Mask path segments that look like tokens (longer than 16 chars) if !path.is_empty() { let masked: Vec<&str> = path .split('/') @@ -143,7 +128,6 @@ pub fn mask_url(url: &str) -> String { result.push_str(&masked.join("/")); } - // Keep query param keys, mask values if let Some(query) = query_part { result.push('?'); let masked_query: Vec = query @@ -159,11 +143,7 @@ pub fn mask_url(url: &str) -> String { result } -/// Mask all URLs embedded in an error/log string for safe logging. -/// -/// Scans the string for `http://` or `https://` and replaces each URL -/// (terminated by whitespace or `)`, `]`, `"`, `'`) with its masked form. -/// Text between URLs is copied verbatim. +/// Masks every HTTP(S) URL embedded in a log string while preserving surrounding text. pub fn mask_err(err: &str) -> String { let mut result = String::with_capacity(err.len()); let mut remaining = err; @@ -194,9 +174,8 @@ pub fn mask_err(err: &str) -> String { result } -/// get the last part of the url, if not found, return empty string pub fn get_last_part_and_decode(url: &str) -> Option { - let path = url.split('?').next().unwrap_or(""); // Splits URL and takes the path part + let path = url.split('?').next().unwrap_or(""); let segments: Vec<&str> = path.split('/').collect(); let last_segment = segments.last()?; @@ -207,7 +186,6 @@ pub fn get_last_part_and_decode(url: &str) -> Option { ) } -/// open file pub fn open_file(path: PathBuf) -> Result<()> { open::that_detached(path.as_os_str())?; Ok(()) @@ -245,7 +223,6 @@ pub fn linux_elevator() -> String { match Command::new("which").arg("pkexec").output() { Ok(output) => { if !output.stdout.is_empty() { - // Convert the output to a string slice if let Ok(path) = std::str::from_utf8(&output.stdout) { path.trim().to_string() } else { @@ -260,7 +237,6 @@ pub fn linux_elevator() -> String { } #[cfg(target_os = "windows")] -/// copy the file to the dist path and return the dist path pub fn snapshot_path(original_path: &Path) -> Result { let temp_dir = original_path .parent() diff --git a/src-tauri/src/utils/init.rs b/src-tauri/src/utils/init.rs index ebeb9bcb0..3bf61399a 100644 --- a/src-tauri/src/utils/init.rs +++ b/src-tauri/src/utils/init.rs @@ -1,4 +1,3 @@ -// #[cfg(not(feature = "tracing"))] use crate::{ config::{Config, IClashTemp, IProfiles, IVerge}, constants, @@ -43,7 +42,6 @@ async fn delete_snapshot_logs(log_dir: &Path) -> Result<()> { } // TODO flexi_logger 提供了最大保留天数,或许我们应该用内置删除log文件 -/// 删除log文件 pub async fn delete_log() -> Result<()> { let log_dir = dirs::app_logs_dir()?; let service_log_dir = dirs::service_log_dir()?; @@ -63,7 +61,6 @@ pub async fn delete_log() -> Result<()> { verge.auto_log_clean.unwrap_or(0) }; - // 1: 1天, 2: 7天, 3: 30天, 4: 90天 let day = match auto_log_clean { 1 => 1, 2 => 7, @@ -74,7 +71,6 @@ pub async fn delete_log() -> Result<()> { logging!(info, Type::Setup, "try to delete log files, day: {}", day); - // %Y-%m-%d to NaiveDateTime let parse_time_str = |s: &str| { let sa: Vec<&str> = s.split('-').collect(); if sa.len() != 4 { @@ -320,14 +316,12 @@ async fn migrate_legacy_macos_logs() -> Result<()> { Ok(()) } -/// 初始化DNS配置文件 pub(super) async fn init_dns_config() -> Result<()> { use serde_yaml_ng::Value; - // 创建DNS子配置 let dns_config = serde_yaml_ng::Mapping::from_iter([ ("enable".into(), Value::Bool(true)), - // 与前端 DNS 默认一致,并供 dns.ipv6 锁定使用 + // Must match the frontend default used for authoritative `dns.ipv6`. ("ipv6".into(), Value::Bool(true)), ("listen".into(), Value::String(":53".into())), ("enhanced-mode".into(), Value::String("fake-ip".into())), @@ -410,13 +404,11 @@ pub(super) async fn init_dns_config() -> Result<()> { ), ]); - // 获取默认DNS和host配置 let default_dns_config = serde_yaml_ng::Mapping::from_iter([ ("dns".into(), Value::Mapping(dns_config)), ("hosts".into(), Value::Mapping(serde_yaml_ng::Mapping::new())), ]); - // 检查DNS配置文件是否存在 let app_dir = dirs::app_home_dir()?; let dns_path = app_dir.join(constants::files::DNS_CONFIG); @@ -428,7 +420,6 @@ pub(super) async fn init_dns_config() -> Result<()> { Ok(()) } -/// 确保目录结构存在 async fn ensure_directories() -> Result<()> { let directories = [ ("app_home", dirs::app_home_dir()?), @@ -449,7 +440,6 @@ async fn ensure_directories() -> Result<()> { Ok(()) } -/// 初始化配置文件 async fn initialize_config_files() -> Result<()> { if let Ok(path) = dirs::clash_path() && !path.exists() @@ -481,7 +471,6 @@ async fn initialize_config_files() -> Result<()> { logging!(info, Type::Setup, "Created profiles config at {:?}", path); } - // 验证并修正verge配置 IVerge::validate_and_fix_config() .await .map_err(|e| anyhow::anyhow!("Failed to validate verge config: {}", e))?; @@ -489,14 +478,8 @@ async fn initialize_config_files() -> Result<()> { Ok(()) } -/// Initialize all the config files -/// before tauri setup +/// Initializes configuration required before Tauri setup. pub async fn init_config() -> Result<()> { - // We do not need init_portable_flag here anymore due to lib.rs will to the things - // let _ = dirs::init_portable_flag(); - - // We do not need init_log here anymore due to resolve will to the things - #[cfg(target_os = "macos")] migrate_legacy_macos_logs().await?; @@ -514,8 +497,7 @@ pub async fn init_config() -> Result<()> { Ok(()) } -/// initialize app resources -/// after tauri setup +/// Initializes application resources after Tauri setup. pub async fn init_resources() -> Result<()> { let app_dir = dirs::app_home_dir()?; let res_dir = dirs::app_resources_dir()?; @@ -529,8 +511,6 @@ pub async fn init_resources() -> Result<()> { let file_list = ["Country.mmdb", "geoip.dat", "geosite.dat"]; - // copy the resource file - // if the source file is newer than the destination file, copy it over for file in file_list.iter() { let src_path = res_dir.join(file); let dest_path = app_dir.join(file); @@ -559,7 +539,6 @@ pub async fn init_resources() -> Result<()> { Ok(()) } -/// initialize url scheme #[cfg(target_os = "windows")] pub fn init_scheme() -> Result<()> { use tauri::utils::platform::current_exe; diff --git a/src-tauri/src/utils/resolve/window.rs b/src-tauri/src/utils/resolve/window.rs index f7fe308e5..ed4500557 100644 --- a/src-tauri/src/utils/resolve/window.rs +++ b/src-tauri/src/utils/resolve/window.rs @@ -4,17 +4,15 @@ use tauri::webview::PageLoadEvent; use tauri::{Theme, WebviewWindow}; use crate::{config::Config, core::handle, utils::resolve::window_script::build_window_initial_script}; -use clash_verge_logging::{Type, logging_error}; -// logging 仅在 macOS 的渲染进程恢复逻辑中使用 #[cfg(target_os = "macos")] use clash_verge_logging::logging; +use clash_verge_logging::{Type, logging_error}; const DARK_BACKGROUND_COLOR: Color = Color(46, 48, 61, 255); // #2E303D const LIGHT_BACKGROUND_COLOR: Color = Color(245, 245, 245, 255); // #F5F5F5 const DARK_BACKGROUND_HEX: &str = "#2E303D"; const LIGHT_BACKGROUND_HEX: &str = "#F5F5F5"; -// 定义默认窗口尺寸常量 const DEFAULT_WIDTH: f64 = 940.0; const DEFAULT_HEIGHT: f64 = 700.0; @@ -46,7 +44,6 @@ fn restore_default_size_if_needed(window: &WebviewWindow) { logging_error!(Type::Window, window.center()); } -/// 构建新的 WebView 窗口 pub async fn build_new_window() -> Result { let app_handle = handle::Handle::app_handle(); @@ -112,7 +109,7 @@ pub async fn build_new_window() -> Result { Ok(window) => { logging_error!(Type::Window, window.set_background_color(Some(background_color))); restore_default_size_if_needed(&window); - // 全新窗口的页面即为最新状态,丢弃旧窗口遗留的待重载标记,避免多余 reload + // A new page supersedes any reload marker left by the old window. #[cfg(target_os = "macos")] take_webview_needs_reload(); Ok(window) @@ -121,40 +118,19 @@ pub async fn build_new_window() -> Result { } } -/// 渲染进程死亡、页面待重载标记(macOS) -/// -/// 渲染进程在窗口不可见时被系统终止后置位,由下次激活窗口的路径取走并执行 reload。 +/// Defers recovery of a terminated hidden main webview until its next activation. #[cfg(target_os = "macos")] static WEBVIEW_NEEDS_RELOAD: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); -/// 取出并清除"页面待重载"标记 -/// -/// # Returns -/// * `bool` - 渲染进程是否曾在窗口不可见时被系统终止、页面需要 reload #[cfg(target_os = "macos")] pub fn take_webview_needs_reload() -> bool { WEBVIEW_NEEDS_RELOAD.swap(false, std::sync::atomic::Ordering::SeqCst) } -/// WebView 渲染进程被系统终止后的恢复处理(macOS) /// -/// macOS 在内存压力下可能杀掉 WKWebView 的 WebContent 渲染进程: -/// 1. 页面内容层消失,窗口打开后表现为白屏; -/// 2. 前端 JS 状态随之丢失,无法调用 `ws_disconnect` 清理 Mihomo WebSocket 订阅, -/// 孤儿订阅持续把大于 1KB 的 payload(如 `/connections` 全量快照)塞进 tauri 的 -/// `ChannelDataIpcQueue`,且没有存活的页面来取走,导致主进程内存无限增长。 -/// -/// 恢复策略: -/// * 窗口可见(前台被杀的罕见场景)——立即 reload 恢复页面; -/// * 窗口隐藏/最小化(托盘常驻的常见场景)——只置位待重载标记,等用户下次打开 -/// 窗口时再 reload。系统正是因内存压力才杀掉不可见窗口的渲染进程,此时立即 -/// 重建渲染进程既浪费内存,也可能形成"系统杀→拉起→再杀"的循环。 -/// -/// 注意:应用层注册 `on_web_content_process_terminate` 后会覆盖 tauri-runtime-wry -/// 的默认自动 reload 行为,因此页面死亡状态会一直保持到我们主动 reload。 -/// -/// # Arguments -/// * `webview` - 渲染进程被终止的 WebView +/// macOS may kill hidden WebContent under memory pressure. Clean orphaned Mihomo subscriptions, +/// reload visible webviews immediately, and defer a hidden main-window reload until activation. +/// Registering this callback replaces Tauri's default automatic reload. #[cfg(target_os = "macos")] pub fn on_web_content_process_terminated(webview: &tauri::Webview) { if handle::Handle::global().is_exiting() { @@ -171,18 +147,16 @@ pub fn on_web_content_process_terminated(webview: &tauri::Webview) { let window = webview.window(); let is_user_visible = window.is_visible().unwrap_or(false) && !window.is_minimized().unwrap_or(false); - // 懒重载标记仅供主窗口;其它 webview(update-splash)无消费通道,不可见时直接 reload 兜底 + // Only the main window has a path that consumes the deferred marker. let is_main_window = webview.label() == "main"; let reload_now = is_user_visible || !is_main_window; if !reload_now { - // 主窗口不可见:置标记,延迟到下次 activate_window / reload_main_window_if_needed 再 reload WEBVIEW_NEEDS_RELOAD.store(true, std::sync::atomic::Ordering::SeqCst); logging!(info, Type::Window, "窗口不可见,页面将在下次打开窗口时重载"); } - // 清理全部 Mihomo WS 订阅,阻断 ChannelDataIpcQueue 泄漏(托盘速率任务约 1s 后自重连)。 - // reload 必须与清理同任务、排在其后,否则清理可能误清重载后新页面的订阅(竞态)。 + // Clean before reload so cleanup cannot remove subscriptions created by the new page. let webview = webview.clone(); crate::process::AsyncHandler::spawn(move || async move { if let Err(err) = handle::Handle::mihomo().clear_all_ws_connections().await { @@ -196,9 +170,7 @@ pub fn on_web_content_process_terminated(webview: &tauri::Webview) { }); } -/// 消费待重载标记并 reload 主窗口(macOS)。 -/// 兜底原生取消最小化(Dock 缩略图/调度中心/窗口菜单)只触发 Focused(true)、不走 -/// activate_window 的情况。与 activate_window 共用 swap 标记,先取先得、不重复 reload。 +/// Consumes the shared marker for native unminimize paths that bypass `activate_window`. #[cfg(target_os = "macos")] pub fn reload_main_window_if_needed() { if !take_webview_needs_reload() { diff --git a/src-tauri/src/utils/singleton.rs b/src-tauri/src/utils/singleton.rs index 44127e2cc..a39d8fcc7 100644 --- a/src-tauri/src/utils/singleton.rs +++ b/src-tauri/src/utils/singleton.rs @@ -1,19 +1,4 @@ -/// Macro to generate singleton pattern for structs -/// -/// Usage: -/// ```rust,ignore -/// use crate::utils::singleton::singleton; -/// -/// struct MyStruct { -/// value: i32, -/// } -/// impl MyStruct { -/// fn new() -> Self { -/// MyStruct { value: 0 } -/// } -/// } -/// singleton!(MyStruct, INSTANCE); -/// ``` +/// Implements `global()` using the supplied `OnceCell` static. #[macro_export] macro_rules! singleton { ($struct_name:ty, $instance_name:ident) => { diff --git a/src-tauri/src/utils/speed.rs b/src-tauri/src/utils/speed.rs index df5e1bb76..c8f529019 100644 --- a/src-tauri/src/utils/speed.rs +++ b/src-tauri/src/utils/speed.rs @@ -1,10 +1,6 @@ -//! 网络速率格式化工具 - -/// 速率显示升档阈值:保证显示值不超过三位数(显示层约定,与换算基数无关) +/// Promote units before the rendered value exceeds three digits. const SPEED_DISPLAY_THRESHOLD: f64 = 1000.0; -/// 速率展示单位顺序 const SPEED_UNITS: [&str; 5] = ["B/s", "K/s", "M/s", "G/s", "T/s"]; -/// 预计算 1024 的幂次方,避免运行时重复计算 pow const SCALES: [f64; 5] = [ 1.0, 1024.0, @@ -13,10 +9,6 @@ const SCALES: [f64; 5] = [ 1024.0 * 1024.0 * 1024.0 * 1024.0, ]; -/// 将字节/秒格式化为可读速率字符串 -/// -/// # Arguments -/// * `bytes_per_sec` - 每秒字节数 pub fn format_bytes_per_second(bytes_per_sec: u64) -> String { if bytes_per_sec < SPEED_DISPLAY_THRESHOLD as u64 { return format!("{bytes_per_sec}B/s"); @@ -47,7 +39,6 @@ mod tests { fn format_handles_byte_boundaries() { assert_eq!(format_bytes_per_second(0), "0B/s"); assert_eq!(format_bytes_per_second(999), "999B/s"); - // 1000 >= SPEED_DISPLAY_THRESHOLD,升档为 K/s(保证不超过三位数) assert_eq!(format_bytes_per_second(1000), "1.0K/s"); assert_eq!(format_bytes_per_second(1024), "1.0K/s"); } @@ -55,7 +46,6 @@ mod tests { #[test] fn format_handles_decimal_and_integer_rules() { assert_eq!(format_bytes_per_second(9 * 1024), "9.0K/s"); - // 9.999 K/s:rounded_1dp = 10.0,不满足 < 10,应显示整数 "10K/s" assert_eq!(format_bytes_per_second(10 * 1024 - 1), "10K/s"); assert_eq!(format_bytes_per_second(10 * 1024), "10K/s"); assert_eq!(format_bytes_per_second(123 * 1024), "123K/s"); @@ -63,7 +53,6 @@ mod tests { #[test] fn format_handles_unit_promotion_after_rounding() { - // 999.5 K/s 四舍五入为 1000,≥ SPEED_DISPLAY_THRESHOLD,升档为 1.0M/s assert_eq!(format_bytes_per_second(999 * 1024 + 512), "1.0M/s"); assert_eq!(format_bytes_per_second(1024 * 1024), "1.0M/s"); assert_eq!(format_bytes_per_second(1536 * 1024), "1.5M/s"); diff --git a/src-tauri/src/utils/tmpl.rs b/src-tauri/src/utils/tmpl.rs index 5d421a8c3..ddc4d5a42 100644 --- a/src-tauri/src/utils/tmpl.rs +++ b/src-tauri/src/utils/tmpl.rs @@ -1,6 +1,3 @@ -//! Some config file template - -/// template for new a profile item pub const ITEM_LOCAL: &str = "# Profile Template for Clash Verge proxies: [] @@ -10,7 +7,6 @@ proxy-groups: [] rules: [] "; -/// enhanced profile pub const ITEM_MERGE: &str = "# Profile Enhancement Merge Template for Clash Verge profile: @@ -21,7 +17,6 @@ pub const ITEM_MERGE_EMPTY: &str = "# Profile Enhancement Merge Template for Cla "; -/// enhanced profile pub const ITEM_SCRIPT: &str = "// Define main function (script entry) function main(config, profileName) { @@ -29,7 +24,6 @@ function main(config, profileName) { } "; -/// enhanced profile pub const ITEM_RULES: &str = "# Profile Enhancement Rules Template for Clash Verge prepend: [] @@ -39,7 +33,6 @@ append: [] delete: [] "; -/// enhanced profile pub const ITEM_PROXIES: &str = "# Profile Enhancement Proxies Template for Clash Verge prepend: [] @@ -49,7 +42,6 @@ append: [] delete: [] "; -/// enhanced profile pub const ITEM_GROUPS: &str = "# Profile Enhancement Groups Template for Clash Verge prepend: [] diff --git a/src-tauri/src/utils/tray_speed.rs b/src-tauri/src/utils/tray_speed.rs index a0233ea3d..7f11c7124 100644 --- a/src-tauri/src/utils/tray_speed.rs +++ b/src-tauri/src/utils/tray_speed.rs @@ -1,7 +1,4 @@ -//! macOS 托盘速率富文本渲染模块 -//! -//! 通过 objc2 调用 NSAttributedString 实现托盘速率的富文本显示, -//! 支持等宽字体、跟随菜单栏外观配色、两行定宽布局。 +//! macOS tray speed rendering via `NSAttributedString`. use std::cell::RefCell; @@ -17,36 +14,24 @@ use objc2_app_kit::{ }; use objc2_foundation::{NSAttributedString, NSDictionary, NSNumber, NSString}; -/// 富文本渲染使用的字号(适配两行在托盘栏的高度) const TRAY_FONT_SIZE: f64 = 9.5; -/// 两行文本的固定行高,避免不同菜单栏高度/缩放下使用系统默认行高导致裁剪 +/// Fixed metrics prevent clipping across menu-bar scales. const TRAY_LINE_HEIGHT: f64 = 10.0; -/// 两行文本的行间距 const TRAY_LINE_SPACING: f64 = 0.0; -/// 两行文本整体行高倍数(用于进一步压缩文本块高度) const TRAY_LINE_HEIGHT_MULTIPLE: f64 = 1.00; -/// 文本块段前偏移(用于将两行文本整体下移) const TRAY_PARAGRAPH_SPACING_BEFORE: f64 = 0.0; -/// 基线基准位移按字体上沿比例生成(避免硬编码常量) const TRAY_BASELINE_OFFSET_GLYPH_HEIGHT_RATIO: f64 = 3.0; -/// Tauri tray-icon 将图标缩放为 18pt;这里额外预留图标、图文间距与系统内边距 +/// Tauri uses an 18pt icon; reserve room for the icon and system padding. const TRAY_STATUS_ITEM_EXTRA_WIDTH: f64 = 30.0; -/// 典型 6 字符速率文本的最小宽度,避免 0B/s 等短文本让状态项反复收缩 +/// Avoid status-item width jitter for short values such as `0B/s`. const TRAY_STATUS_ITEM_MIN_LENGTH: f64 = 58.0; -/// AppKit 的 NSVariableStatusItemLength。清空速率标题后恢复系统按图标自适应 const NS_VARIABLE_STATUS_ITEM_LENGTH: f64 = -1.0; thread_local! { static LAST_DISPLAY_STR: RefCell = const { RefCell::new(String::new()) }; } -/// 将上行/下行速率格式化为两行定宽文本 -/// -/// # Arguments -/// * `up` - 上行速率(字节/秒) -/// * `down` - 下行速率(字节/秒) fn format_tray_speed(up: u64, down: u64) -> String { - // 上行放在第一行,下行放在第二行;通过上下布局表达方向,不再显示箭头字符。 let up_str = format_bytes_per_second(up); let down_str = format_bytes_per_second(down); format!("{:>6}\n{:>6}", up_str, down_str) @@ -54,11 +39,8 @@ fn format_tray_speed(up: u64, down: u64) -> String { fn build_attributes(button_height: f64) -> Retained> { unsafe { - // 等宽系统字体,确保数字不跳动 let font = NSFont::monospacedSystemFontOfSize_weight(TRAY_FONT_SIZE, NSFontWeightRegular); - // 与 NSStatusBarButton 原生标题一致,并按每块屏幕的菜单栏外观动态解析颜色 let color = NSColor::controlTextColor(); - // 段落样式:右对齐,保证定宽视觉一致 let para_style = NSMutableParagraphStyle::new(); para_style.setAlignment(NSTextAlignment::Right); para_style.setLineBreakMode(NSLineBreakMode::ByClipping); @@ -83,11 +65,6 @@ fn build_attributes(button_height: f64) -> Retained>, @@ -111,15 +88,7 @@ fn sync_click_target_frame(button: &NSStatusBarButton) { } } -/// 在主线程下设置 NSStatusItem 按钮的标题内容 -/// -/// 依赖 Tauri `with_inner_tray_icon` 保证回调在主线程执行; -/// 若意外在非主线程调用,`MainThreadMarker::new()` 返回 `None` 并记录警告。 -/// -/// # Arguments -/// * `status_item` - macOS 托盘 NSStatusItem 引用 -/// * `text` - 标题字符串内容 -/// * `show_speed` - 是否以速率富文本样式绘制;false 时清空为普通空标题 +/// `with_inner_tray_icon` should run this on the main thread; fail safely if it does not. fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &NSString, show_speed: bool) { let Some(mtm) = MainThreadMarker::new() else { logging!(warn, Type::Tray, "托盘速率富文本设置跳过:非主线程调用"); @@ -141,12 +110,6 @@ fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &NSStrin sync_click_target_frame(&button); } -/// 将速率以富文本形式设置到 NSStatusItem 的按钮上 -/// -/// # Arguments -/// * `status_item` - macOS 托盘 NSStatusItem 引用 -/// * `up` - 上行速率(字节/秒) -/// * `down` - 下行速率(字节/秒) pub fn set_speed_attributed_title(status_item: &NSStatusItem, up: u64, down: u64) { let speed_text = format_tray_speed(up, down); LAST_DISPLAY_STR.with(|last| { @@ -160,10 +123,6 @@ pub fn set_speed_attributed_title(status_item: &NSStatusItem, up: u64, down: u64 }); } -/// 清除 NSStatusItem 按钮上的富文本速率显示 -/// -/// # Arguments -/// * `status_item` - macOS 托盘 NSStatusItem 引用 pub fn clear_speed_attributed_title(status_item: &NSStatusItem) { LAST_DISPLAY_STR.with(|last| { last.borrow_mut().clear(); diff --git a/src-tauri/src/utils/window_manager.rs b/src-tauri/src/utils/window_manager.rs index 82e88381f..87bf3a531 100644 --- a/src-tauri/src/utils/window_manager.rs +++ b/src-tauri/src/utils/window_manager.rs @@ -6,39 +6,25 @@ use std::pin::Pin; use std::time::Duration; use tauri::{Manager as _, WebviewWindow, Wry}; -/// 窗口操作结果 #[derive(Debug, Clone, Copy, PartialEq)] pub enum WindowOperationResult { - /// 窗口已显示并获得焦点 Shown, - /// 窗口已隐藏 Hidden, - /// 创建了新窗口 Created, - /// 摧毁了窗口 Destroyed, - /// 操作失败 Failed, - /// 无需操作 NoAction, } -/// 窗口状态 #[derive(Debug, Clone, Copy, PartialEq)] pub enum WindowState { - /// 窗口可见且有焦点 VisibleFocused, - /// 窗口可见但无焦点 VisibleUnfocused, - /// 窗口最小化 Minimized, - /// 窗口隐藏 Hidden, - /// 窗口不存在 NotExist, } -// 窗口操作防抖机制 const WINDOW_OPERATION_DEBOUNCE_MS: u64 = 625; static WINDOW_OPERATION_LIMITER: Lazy = Lazy::new(|| { Limiter::new( @@ -55,7 +41,6 @@ fn should_handle_window_operation() -> bool { allow } -/// 统一的窗口管理器 pub struct WindowManager; impl WindowManager { @@ -112,15 +97,12 @@ impl WindowManager { } } - /// 获取主窗口实例 pub fn get_main_window() -> Option> { let app_handle = handle::Handle::app_handle(); app_handle.get_webview_window("main") } - /// 智能显示主窗口 pub async fn show_main_window() -> WindowOperationResult { - // 防抖检查 if !should_handle_window_operation() { return WindowOperationResult::NoAction; } @@ -161,7 +143,6 @@ impl WindowManager { } } - /// 切换主窗口显示状态(显示/隐藏) pub async fn toggle_main_window() -> WindowOperationResult { if !should_handle_window_operation() { return WindowOperationResult::NoAction; @@ -178,10 +159,8 @@ impl WindowManager { } } - // 窗口不存在时创建新窗口 async fn handle_not_exist_toggle() -> WindowOperationResult { logging!(info, Type::Window, "窗口不存在,将创建新窗口"); - // 由于已经有防抖保护,直接调用内部方法 if Self::create_window(true).await { WindowOperationResult::Created } else { @@ -189,7 +168,6 @@ impl WindowManager { } } - // 隐藏主窗口 fn hide_main_window(window: Option<&WebviewWindow>) -> WindowOperationResult { logging!(info, Type::Window, "窗口可见,将隐藏窗口"); if let Some(window) = window { @@ -209,7 +187,6 @@ impl WindowManager { } } - // 激活已存在的主窗口 fn activate_existing_main_window(window: Option<&WebviewWindow>) -> WindowOperationResult { logging!(info, Type::Window, "窗口存在但被隐藏或最小化,将激活窗口"); if let Some(window) = window { @@ -220,14 +197,12 @@ impl WindowManager { } } - /// 激活窗口(取消最小化、显示、设置焦点) fn activate_window(window: &WebviewWindow) -> WindowOperationResult { logging!(info, Type::Window, "开始激活窗口"); #[cfg(target_os = "macos")] Self::set_macos_activation_policy_regular(); - // 渲染进程曾被系统终止:先 reload,并把 show+focus 交给 on_page_load(Finished), - // 内容就绪再显示,避免白屏闪烁。reload 成功才 defer,失败则走下方直接显示。 + // After renderer termination, defer show/focus until reload finishes to avoid a white flash. #[allow(unused_mut)] let mut defer_show_to_page_load = false; #[cfg(target_os = "macos")] @@ -241,7 +216,6 @@ impl WindowManager { let mut operations_successful = true; - // 1. 如果窗口最小化,先取消最小化 if window.is_minimized().unwrap_or(false) { logging!(info, Type::Window, "窗口已最小化,正在取消最小化"); if let Err(e) = window.unminimize() { @@ -250,7 +224,6 @@ impl WindowManager { } } - // 2/3. 显示 + 焦点(reload 分支跳过,交给 on_page_load) if !defer_show_to_page_load { if let Err(e) = window.show() { logging!(warn, Type::Window, "显示窗口失败: {}", e); @@ -264,11 +237,9 @@ impl WindowManager { #[cfg(target_os = "windows")] { - // Windows 尝试额外的激活方法 if let Err(e) = window.set_always_on_top(true) { logging!(debug, Type::Window, "设置置顶失败(非关键错误): {}", e); } - // 立即取消置顶 if let Err(e) = window.set_always_on_top(false) { logging!(debug, Type::Window, "取消置顶失败(非关键错误): {}", e); } @@ -283,23 +254,19 @@ impl WindowManager { } } - /// 检查窗口是否可见 pub fn is_main_window_visible(window: Option<&WebviewWindow>) -> bool { window.map(|w| w.is_visible().unwrap_or(false)).unwrap_or(false) } - /// 检查窗口是否有焦点 pub fn is_main_window_focused(window: Option<&WebviewWindow>) -> bool { window.map(|w| w.is_focused().unwrap_or(false)).unwrap_or(false) } - /// 检查窗口是否最小化 pub fn is_main_window_minimized(window: Option<&WebviewWindow>) -> bool { window.map(|w| w.is_minimized().unwrap_or(false)).unwrap_or(false) } - /// 创建新窗口,防抖避免重复调用 - /// 窗口创建后保持隐藏,由前端 index.html 在 overlay 渲染后调用 show,避免主题闪烁 + /// Keep new windows hidden until the frontend overlay renders, avoiding a theme flash. pub fn create_window(should_create: bool) -> Pin + Send>> { Box::pin(async move { logging!(info, Type::Window, "开始创建主窗口, should_create={}", should_create); @@ -325,7 +292,6 @@ impl WindowManager { }) } - /// 摧毁窗口 pub fn destroy_main_window() -> WindowOperationResult { if let Some(window) = Self::get_main_window() { let _ = window.destroy(); @@ -340,7 +306,6 @@ impl WindowManager { WindowOperationResult::Failed } - /// 获取详细的窗口状态信息 fn get_window_status_info() -> String { let (window, state) = Self::get_main_window_with_state(); let is_visible = Self::is_main_window_visible(window.as_ref()); diff --git a/src/components/home/clash-mode-card.tsx b/src/components/home/clash-mode-card.tsx index 6ad6ae397..261a3b54f 100644 --- a/src/components/home/clash-mode-card.tsx +++ b/src/components/home/clash-mode-card.tsx @@ -63,12 +63,9 @@ export const ClashModeCard = () => { const { isCoreDataPending } = useCoreDataStatus() const { refreshClashConfig } = useAppRefreshers() - // 点击后到后端确认前的乐观模式,使按钮立即响应 const [optimisticMode, setOptimisticMode] = useState(null) - // 主源:mihomo /configs 的实时 mode(最准,但依赖严格反序列化,可能整体失败) const controllerMode = toClashMode(clashConfig?.mode) - // 主源不可用时,启用两个容错兜底来源 const needFallback = !controllerMode const { data: runtimeConfig, isPending: isRuntimeConfigPending } = useRuntimeConfig(needFallback) @@ -78,9 +75,7 @@ export const ClashModeCard = () => { isPending: isBackendModePending, refetch: refetchBackendMode, } = useClashMode(needFallback) - // backendMode(已保存 clash 配置)在每次切换时都会被 change_clash_mode 同步更新, - // 而 runtimeMode(生成的运行时配置)在 API 切换后不会刷新、可能陈旧, - // 因此优先用 backendMode,避免陈旧 runtime mode 遮住新值。 + // Saved config is refreshed on mode changes; runtime config may be stale. const fallbackMode = toClashMode(backendMode) ?? runtimeMode const resolvedMode = controllerMode ?? fallbackMode @@ -92,37 +87,29 @@ export const ClashModeCard = () => { ? '\u00A0' : t('home.components.clashMode.errors.communication') - // 切换模式的处理函数:乐观更新 + 真实失败回滚并提示 const onChangeMode = useLockFn(async (mode: ClashMode) => { if (mode === currentMode) return if (verge?.auto_close_connection) { closeAllConnections() } - // 乐观置为目标模式,按钮立即反映点击 setOptimisticMode(mode) try { - // patchClashMode 现在会在后端 PATCH 失败时 reject await patchClashMode(mode) } catch (error) { - // 真实失败:回滚乐观状态并提示用户 setOptimisticMode(null) showNotice.error(error) return } - // 成功:写穿主源缓存,使实时 mode 立即反映新值——即使随后的 /configs refetch - // 失败(TanStack 会保留旧 data),controllerMode 也不会再压过新值导致闪回。 - // 若主源从未成功过(old 为 undefined)则保持不动,改由兜底来源反映。 + // Write through the live cache to avoid flashing the old mode during refetch. setCacheData(['getClashConfig'], (old) => old ? { ...old, mode } : old, ) - // 刷新主源与兜底源以与后端对齐,待数据落地后再清除乐观状态 await Promise.allSettled([refreshClashConfig(), refetchBackendMode()]) setOptimisticMode(null) }) - // 按钮样式 const buttonStyles = (mode: ClashMode) => ({ cursor: 'pointer', px: 2, @@ -159,7 +146,6 @@ export const ClashModeCard = () => { : {}, }) - // 描述样式 const descriptionStyles = { width: '95%', textAlign: 'center', @@ -176,7 +162,6 @@ export const ClashModeCard = () => { return ( - {/* 模式选择按钮组 */} { ))} - {/* 说明文本区域 */} { [getProfileStorageKey], ) - // 统一代理选择器 const { handleSelectChange } = useProxySelection({ onSuccess: () => { refreshProxy() @@ -460,12 +455,10 @@ export const CurrentProxyCard = () => { }, }) - // 判断模式 const mode = clashConfig?.mode?.toLowerCase() || 'rule' const isGlobalMode = mode === 'global' const isDirectMode = mode === 'direct' - // Sorting type state const [sortType, setSortType] = useState(() => { const savedSortType = localStorage.getItem(STORAGE_KEY_SORT_TYPE) return savedSortType ? (Number(savedSortType) as ProxySortType) : 0 @@ -474,7 +467,6 @@ export const CurrentProxyCard = () => { const [selectedGroupName, setSelectedGroupName] = useState('') const [openSelect, setOpenSelect] = useState(null) const delayButtonRef = useRef(null) - // Sorting reads delays from a store outside React; this hands them over as a value. const delays = useGroupDelays(selectedGroupName || null) const autoCheckInProgressRef = useRef(false) @@ -618,8 +610,7 @@ export const CurrentProxyCard = () => { } const previousProxy = selectedGroup.now const nextName = option.member.ref.name - // Recorded like any other selection, global mode included: what the profile holds is - // what gets re-applied when a core starts, so a choice it never learns about is undone. + // The profile selection is the durable source across core restarts and run modes. handleSelectChange( selectedGroup.name, previousProxy, @@ -631,7 +622,6 @@ export const CurrentProxyCard = () => { [handleSelectChange, isDirectMode, selectedGroup, unsortedProxyOptions], ) - // 导航到代理页面 const goToProxies = useCallback(() => { navigate('/proxies') }, [navigate]) @@ -645,7 +635,6 @@ export const CurrentProxyCard = () => { ? delayManager.getDelayFix(currentMember, selectedGroupName) : -1 - // 信号图标(增加非空校验) const signalInfo = currentProxy && selectedGroupName ? getSignalIcon(currentDelay, t) @@ -728,14 +717,12 @@ export const CurrentProxyCard = () => { autoDelayEnabled, ]) - // 排序类型变更 const handleSortTypeChange = useCallback(() => { const newSortType = ((sortType + 1) % 3) as ProxySortType setSortType(newSortType) localStorage.setItem(STORAGE_KEY_SORT_TYPE, newSortType.toString()) }, [sortType]) - // 延迟测试 const handleCheckDelay = useLockFn(async () => { const groupName = selectedGroupName if (!groupName || isDirectMode) return @@ -782,7 +769,6 @@ export const CurrentProxyCard = () => { [handleCheckDelay], ) - // 计算要显示的代理选项(增加非空校验) const proxyOptions = useMemo( () => isDirectMode @@ -802,7 +788,6 @@ export const CurrentProxyCard = () => { ], ) - // 获取排序图标 const getSortIcon = (): React.ReactElement => { switch (sortType) { case 1: @@ -814,7 +799,6 @@ export const CurrentProxyCard = () => { } } - // 获取排序提示文本 const getSortTooltip = (): string => { switch (sortType) { case 0: @@ -890,7 +874,6 @@ export const CurrentProxyCard = () => { ) : currentProxy || (!isDirectMode && selectedGroup) ? ( - {/* 代理节点信息显示 */} { sx={{ mr: 0.5 }} /> )} - {/* 节点特性 */} {currentProxy?.udp && ( )} @@ -954,7 +936,6 @@ export const CurrentProxyCard = () => { - {/* 显示延迟 */} {currentProxy && !isDirectMode && ( { /> )} - {/* 代理组选择器 */} { - {/* 代理节点选择器 */} void toggleStyle: () => void } -type TimeRange = 1 | 5 | 10 // 分钟 +type TimeRange = 1 | 5 | 10 -// 悬浮提示数据接口 interface TooltipData { x: number y: number @@ -45,21 +42,21 @@ interface TooltipData { downSpeed: string timestamp: string visible: boolean - dataIndex: number // 添加数据索引用于高亮 - highlightY: number // 高亮Y轴位置 + dataIndex: number + highlightY: number } const MAX_POINTS = 300 -const TARGET_FPS = 15 // 降低帧率减少闪烁 +const TARGET_FPS = 15 const LINE_WIDTH_UP = 2.5 const LINE_WIDTH_DOWN = 2.5 const LINE_WIDTH_GRID = 0.5 -const ALPHA_GRADIENT = 0.15 // 降低渐变透明度 +const ALPHA_GRADIENT = 0.15 const ALPHA_LINE = 0.9 const PADDING_TOP = 16 -const PADDING_RIGHT = 16 // 增加右边距确保时间戳完整显示 -const PADDING_BOTTOM = 32 // 进一步增加底部空间给时间轴和统计信息 -const PADDING_LEFT = 35 // 增加左边距为Y轴标签留出空间 +const PADDING_RIGHT = 16 +const PADDING_BOTTOM = 32 +const PADDING_LEFT = 35 const GRAPH_CONFIG = { maxPoints: MAX_POINTS, @@ -116,10 +113,6 @@ const displayDataReducer = ( ): ITrafficDataPoint[] => isSameTrafficData(current, payload) ? current : payload -/** - * 稳定版Canvas流量图表组件 - * 修复闪烁问题,添加时间轴显示 - */ export const EnhancedCanvasTrafficGraph = memo( function EnhancedCanvasTrafficGraph({ ref, @@ -130,11 +123,9 @@ export const EnhancedCanvasTrafficGraph = memo( const pause_render_traffic_stats_on_blur = verge.verge?.pause_render_traffic_stats_on_blur ?? true - // 使用增强版全局流量数据管理 const { dataPoints, requestRange, samplerStats } = useTrafficGraphDataEnhanced() - // 基础状态 const [timeRange, setTimeRange] = useState(10) const [chartStyle, setChartStyle] = useState<'bezier' | 'line'>('bezier') @@ -142,7 +133,6 @@ export const EnhancedCanvasTrafficGraph = memo( typeof document !== 'undefined' ? !document.hidden : true const isDocumentVisibleRef = useRef(initialFocusState) - // 悬浮提示状态 const [tooltipData, setTooltipData] = useState({ x: 0, y: 0, @@ -155,7 +145,6 @@ export const EnhancedCanvasTrafficGraph = memo( }) const tooltipDataRef = useRef(tooltipData) - // Canvas引用和渲染状态 const canvasRef = useRef(null) const hoverCanvasRef = useRef(null) const drawFrameRef = useRef(undefined) @@ -170,7 +159,6 @@ export const EnhancedCanvasTrafficGraph = memo( const lastDataTimestampRef = useRef(0) const dataStaleRef = useRef(false) - // 当前显示的数据缓存 const [displayData, dispatchDisplayData] = useReducer( displayDataReducer, [], @@ -178,7 +166,6 @@ export const EnhancedCanvasTrafficGraph = memo( const debounceTimeoutRef = useRef(null) const [currentFPS, setCurrentFPS] = useState(GRAPH_CONFIG.targetFPS) - // 主题颜色配置 const colors = useMemo( () => ({ up: theme.palette.secondary.main, @@ -190,17 +177,15 @@ export const EnhancedCanvasTrafficGraph = memo( [theme], ) - // 更新显示数据(防抖处理) const updateDisplayData = useCallback((newData: ITrafficDataPoint[]) => { if (debounceTimeoutRef.current !== null) { window.clearTimeout(debounceTimeoutRef.current) } debounceTimeoutRef.current = window.setTimeout(() => { dispatchDisplayData(newData) - }, 50) // 50ms防抖 + }, 50) }, []) - // 监听数据变化 useEffect(() => { updateDisplayData(dataPoints) @@ -274,7 +259,6 @@ export const EnhancedCanvasTrafficGraph = memo( } }, [handleFocusStateChange]) - // Y轴坐标计算 - 线性映射 const calculateY = useCallback( ( value: number, @@ -331,7 +315,6 @@ export const EnhancedCanvasTrafficGraph = memo( [computeYScale, displayData], ) - // 鼠标悬浮处理 - 计算最近的数据点 const handleMouseMove = useCallback( (event: React.MouseEvent) => { if (displayData.length === 0) return @@ -361,7 +344,6 @@ export const EnhancedCanvasTrafficGraph = memo( const effectiveWidth = rect.width - padding.left - padding.right if (effectiveWidth <= 0) return - // 计算最接近的数据点索引 const relativeMouseX = mouseX - padding.left const ratio = Math.max( 0, @@ -373,16 +355,13 @@ export const EnhancedCanvasTrafficGraph = memo( const dataPoint = displayData[dataIndex] - // 格式化流量数据 const [upValue, upUnit] = parseTraffic(dataPoint.up) const [downValue, downUnit] = parseTraffic(dataPoint.down) - // 格式化时间戳 const timeStr = dataPoint.timestamp ? formatTrafficName(dataPoint.timestamp) : t('home.components.traffic.unknownTime') - // 计算数据点对应的Y坐标位置(用于高亮) const { topValue: tvH, bottomValue: bvH } = yScale const upY = calculateY(dataPoint.up, rect.height, tvH, bvH) const downY = calculateY(dataPoint.down, rect.height, tvH, bvH) @@ -423,7 +402,6 @@ export const EnhancedCanvasTrafficGraph = memo( [displayData, calculateY, yScale, t], ) - // 鼠标离开处理 const handleMouseLeave = useCallback(() => { pendingMousePositionRef.current = null @@ -437,10 +415,8 @@ export const EnhancedCanvasTrafficGraph = memo( ) }, []) - // 获取智能Y轴刻度(三刻度系统:最小值、中间值、最大值) const getYAxisTicks = useCallback( (topValue: number, bottomValue: number, height: number) => { - // 格式化流量数值 const formatTrafficValue = (bytes: number): string => { if (bytes === 0) return '0' if (bytes < 1024) return `${Math.round(bytes)}B` @@ -450,13 +426,11 @@ export const EnhancedCanvasTrafficGraph = memo( const padding = GRAPH_CONFIG.padding - // 强制显示三个刻度:底部、中间、顶部 - const topY = padding.top + 10 // 避免与顶部时间范围按钮重叠 - const bottomY = height - padding.bottom - 5 // 避免与底部时间轴重叠 + const topY = padding.top + 10 + const bottomY = height - padding.bottom - 5 const middleY = (topY + bottomY) / 2 const middleValue = (bottomValue + topValue) / 2 - // 创建三个固定位置的刻度 const ticks = [ { value: bottomValue, @@ -480,7 +454,6 @@ export const EnhancedCanvasTrafficGraph = memo( [], ) - // 绘制Y轴刻度线和标签 const drawYAxis = useCallback( ( ctx: CanvasRenderingContext2D, @@ -497,13 +470,12 @@ export const EnhancedCanvasTrafficGraph = memo( ctx.save() ticks.forEach((tick, index) => { - const isBottomTick = index === 0 // 最底部的刻度 - const isTopTick = index === ticks.length - 1 // 最顶部的刻度 + const isBottomTick = index === 0 + const isTopTick = index === ticks.length - 1 - // 绘制水平刻度线,只绘制关键刻度线 if (isBottomTick || isTopTick) { ctx.strokeStyle = colors.grid - ctx.lineWidth = isBottomTick ? 0.8 : 0.4 // 底部刻度线稍粗 + ctx.lineWidth = isBottomTick ? 0.8 : 0.4 ctx.globalAlpha = isBottomTick ? 0.25 : 0.15 ctx.beginPath() @@ -512,7 +484,6 @@ export const EnhancedCanvasTrafficGraph = memo( ctx.stroke() } - // 绘制Y轴标签 ctx.fillStyle = colors.text ctx.font = "8px -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif" @@ -520,7 +491,6 @@ export const EnhancedCanvasTrafficGraph = memo( ctx.textAlign = 'right' ctx.textBaseline = 'middle' - // 为标签添加更清晰的背景(仅在必要时) if (tick.label !== '0') { const labelWidth = ctx.measureText(tick.label).width ctx.globalAlpha = 0.15 @@ -533,7 +503,6 @@ export const EnhancedCanvasTrafficGraph = memo( ) } - // 绘制标签文字 ctx.globalAlpha = 0.9 ctx.fillStyle = colors.text ctx.fillText(tick.label, padding.left - 4, tick.y) @@ -544,38 +513,36 @@ export const EnhancedCanvasTrafficGraph = memo( [colors.grid, colors.text, colors.background, getYAxisTicks], ) - // 获取时间范围对应的最佳时间显示策略 const getTimeDisplayStrategy = useCallback( (timeRangeMinutes: TimeRange) => { switch (timeRangeMinutes) { - case 1: // 1分钟:更密集的时间标签,显示 MM:SS + case 1: return { - maxLabels: 6, // 减少到6个,更适合短时间 + maxLabels: 6, formatTime: formatTrafficMinuteSecond, - intervalSeconds: 10, // 每10秒一个标签,更合理 - minPixelDistance: 35, // 减少间距,允许更多标签 + intervalSeconds: 10, + minPixelDistance: 35, } - case 5: // 5分钟:中等密度,显示 HH:MM + case 5: return { - maxLabels: 6, // 6个标签比较合适 + maxLabels: 6, formatTime: formatTrafficHourMinute, - intervalSeconds: 30, // 约30秒间隔 - minPixelDistance: 38, // 减少间距,允许更多标签 + intervalSeconds: 30, + minPixelDistance: 38, } - case 10: // 10分钟:标准密度,显示 HH:MM + case 10: default: return { - maxLabels: 8, // 保持8个 + maxLabels: 8, formatTime: formatTrafficHourMinute, - intervalSeconds: 60, // 1分钟间隔 - minPixelDistance: 40, // 减少间距,允许更多标签 + intervalSeconds: 60, + minPixelDistance: 40, } } }, [], ) - // 绘制时间轴 const drawTimeAxis = useCallback( ( ctx: CanvasRenderingContext2D, @@ -597,22 +564,18 @@ export const EnhancedCanvasTrafficGraph = memo( "10px -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif" ctx.globalAlpha = 0.7 - // 根据数据长度和时间范围智能选择显示间隔 const targetLabels = Math.min(strategy.maxLabels, data.length) const step = Math.max(1, Math.floor(data.length / (targetLabels - 1))) - // 使用策略中定义的最小像素间距 const minPixelDistance = strategy.minPixelDistance || 45 const actualStep = Math.max( step, Math.ceil((data.length * minPixelDistance) / effectiveWidth), ) - // 收集要显示的时间点 const timePoints: Array<{ index: number; x: number; label: string }> = [] - // 添加第一个时间点 if (data.length > 0 && data[0].timestamp) { timePoints.push({ index: 0, @@ -621,7 +584,6 @@ export const EnhancedCanvasTrafficGraph = memo( }) } - // 添加中间的时间点 for ( let i = actualStep; i < data.length - actualStep; @@ -638,12 +600,10 @@ export const EnhancedCanvasTrafficGraph = memo( }) } - // 添加最后一个时间点(如果不会与前面的重叠) if (data.length > 1 && data[data.length - 1].timestamp) { const lastX = width - padding.right const lastPoint = timePoints[timePoints.length - 1] - // 确保最后一个标签与前一个标签有足够间距 if (!lastPoint || lastX - lastPoint.x >= minPixelDistance) { timePoints.push({ index: data.length - 1, @@ -653,16 +613,12 @@ export const EnhancedCanvasTrafficGraph = memo( } } - // 绘制时间标签 timePoints.forEach((point, index) => { if (index === 0) { - // 第一个标签左对齐 ctx.textAlign = 'left' } else if (index === timePoints.length - 1) { - // 最后一个标签右对齐 ctx.textAlign = 'right' } else { - // 中间标签居中对齐 ctx.textAlign = 'center' } @@ -674,7 +630,6 @@ export const EnhancedCanvasTrafficGraph = memo( [colors.text, timeRange, getTimeDisplayStrategy], ) - // 绘制网格线 const drawGrid = useCallback( (ctx: CanvasRenderingContext2D, width: number, height: number) => { const padding = GRAPH_CONFIG.padding @@ -686,7 +641,6 @@ export const EnhancedCanvasTrafficGraph = memo( ctx.lineWidth = GRAPH_CONFIG.lineWidth.grid ctx.globalAlpha = 0.7 - // 水平网格线 const horizontalLines = 4 for (let i = 1; i <= horizontalLines; i++) { const y = padding.top + (effectiveHeight / (horizontalLines + 1)) * i @@ -696,7 +650,6 @@ export const EnhancedCanvasTrafficGraph = memo( ctx.stroke() } - // 垂直网格线 const verticalLines = 6 for (let i = 1; i <= verticalLines; i++) { const x = padding.left + (effectiveWidth / (verticalLines + 1)) * i @@ -711,7 +664,6 @@ export const EnhancedCanvasTrafficGraph = memo( [colors.grid], ) - // 绘制流量线条 const drawTrafficLine = useCallback( ( ctx: CanvasRenderingContext2D, @@ -736,7 +688,6 @@ export const EnhancedCanvasTrafficGraph = memo( ctx.save() - // 绘制渐变填充 if (withGradient && chartStyle === 'bezier') { const gradient = ctx.createLinearGradient( 0, @@ -777,7 +728,6 @@ export const EnhancedCanvasTrafficGraph = memo( ctx.fill() } - // 绘制主线条 ctx.beginPath() ctx.strokeStyle = color ctx.lineWidth = GRAPH_CONFIG.lineWidth.up @@ -846,7 +796,6 @@ export const EnhancedCanvasTrafficGraph = memo( [syncCanvasSize], ) - // 主绘制函数 const drawGraph = useCallback(() => { const canvas = canvasRef.current if (!canvas || displayData.length === 0) { @@ -864,16 +813,12 @@ export const EnhancedCanvasTrafficGraph = memo( const { topValue, bottomValue } = yScale - // 绘制Y轴刻度线(背景层) drawYAxis(ctx, cssWidth, cssHeight, topValue, bottomValue) - // 绘制网格 drawGrid(ctx, cssWidth, cssHeight) - // 绘制时间轴 drawTimeAxis(ctx, cssWidth, cssHeight, displayData) - // 绘制下载线(背景层) drawTrafficLine( ctx, displayData, @@ -886,7 +831,6 @@ export const EnhancedCanvasTrafficGraph = memo( bottomValue, ) - // 绘制上传线(前景层) drawTrafficLine( ctx, displayData, @@ -937,15 +881,13 @@ export const EnhancedCanvasTrafficGraph = memo( ctx.strokeStyle = colors.text ctx.lineWidth = 1 ctx.globalAlpha = 0.6 - ctx.setLineDash([4, 4]) // 虚线效果 + ctx.setLineDash([4, 4]) - // 绘制垂直指示线 ctx.beginPath() ctx.moveTo(dataX, padding.top) ctx.lineTo(dataX, cssHeight - padding.bottom) ctx.stroke() - // 绘制水平指示线(高亮Y轴位置) ctx.beginPath() ctx.moveTo(padding.left, currentTooltip.highlightY) ctx.lineTo(cssWidth - padding.right, currentTooltip.highlightY) @@ -1048,7 +990,6 @@ export const EnhancedCanvasTrafficGraph = memo( } }, []) - // 切换时间范围 const handleTimeRangeClick = useCallback((event: React.MouseEvent) => { event.stopPropagation() setTimeRange((prev) => { @@ -1056,12 +997,10 @@ export const EnhancedCanvasTrafficGraph = memo( }) }, []) - // 切换图表样式 const toggleStyle = useCallback(() => { setChartStyle((prev) => (prev === 'bezier' ? 'line' : 'bezier')) }, []) - // 兼容性方法 const appendData = useCallback((data: ITrafficItem) => { debugLog( '[EnhancedCanvasTrafficGraphV2] appendData called (using global data):', @@ -1069,7 +1008,6 @@ export const EnhancedCanvasTrafficGraph = memo( ) }, []) - // 暴露方法给父组件 useImperativeHandle( ref, () => ({ @@ -1079,7 +1017,6 @@ export const EnhancedCanvasTrafficGraph = memo( [appendData, toggleStyle], ) - // 获取时间范围文本 const getTimeRangeText = useCallback(() => { return t('home.components.traffic.patterns.minutes', { time: timeRange, @@ -1125,7 +1062,6 @@ export const EnhancedCanvasTrafficGraph = memo( /> )} - {/* 控制层覆盖 */} - {/* 时间范围按钮 */} - {/* 图例 */} - {/* 样式指示器 */} - {/* 数据统计指示器(左下角) */} - {/* 悬浮提示框 */} {tooltipData.visible && ( ( )) -// 获取国旗表情 const getCountryFlag = (countryCode: string | undefined) => { if (!countryCode) return '' const codePoints = countryCode @@ -97,16 +95,12 @@ const IPInfoCardContainer = forwardRef( }, ) -// IP信息卡片组件 export const IpInfoCard = () => { const { t } = useTranslation() const [showIp, setShowIp] = useState(false) const appWindow = useMemo(() => getCurrentWebviewWindow(), []) - // track ip info card has been in viewport or not - // hasIntersected default to false, and will be true once the card is in viewport - // and will never be false again afterwards (unless resetIntersected is called or - // the component is unmounted) + // Once intersected, refreshes stay enabled until unmount. const [containerRef, hasIntersected, _resetIntersected] = useIntersection({ rootMargin: '0px', }) @@ -118,7 +112,6 @@ export const IpInfoCard = () => { const { data: ipInfo, error, isLoading, refetch: mutate } = useIPInfo() - // function useEffectEvent const onCountdownTick = useEffectEvent(async () => { const now = Date.now() const ts = ipInfo?.lastFetchTs @@ -131,35 +124,22 @@ export const IpInfoCard = () => { if (remaining <= 0) { if ( - // has intersected at least once - // this avoids unncessary revalidation if user never scrolls down, - // then we will only load initially once. hasIntersected && - // is online navigator.onLine && - // there is no ongoing revalidation already scheduled countdown.type !== 'revalidating' && - // window is visible (await appWindow.isVisible()) ) { setCountdown({ type: 'revalidating' }) - // we do not care about the result of mutate here. after mutate is done, - // simply wait for next interval tick with `setCountdown({ type: "countdown", ... })` try { await mutate() } finally { - // in case mutate throws error, we still need to reset the countdown state setCountdown({ type: 'countdown', remainingSeconds: IP_REFRESH_SECONDS, }) } } else { - // do nothing. we even skip "setCountdown" to reduce re-renders - // - // but the remaining time still <= 0, and setInterval is not stopped, this - // callback will still be regularly triggered, as soon as the window is visible - // or network online again, we mutate() immediately in the following tick. + // Keep the expired state so the next eligible tick refreshes immediately. } } else { setCountdown({ @@ -169,14 +149,10 @@ export const IpInfoCard = () => { } }) - // Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired useEffect(() => { let timer: number | null = null - // Do not add document.hidden check here as it is not reliable in Tauri. - // - // Thank god IntersectionObserver is a DOM API that relies on DOM/webview - // instead of Tauri, which is reliable enough. + // Prefer DOM intersection state; Tauri visibility is unreliable on some platforms. if (hasIntersected) { console.debug( 'IP info card has entered the viewport, starting the countdown interval.', @@ -188,25 +164,17 @@ export const IpInfoCard = () => { ) } - // This will fire when the window is minimized or restored document.addEventListener('visibilitychange', onVisibilityChange) - // Tauri's visibility change detection is actually broken on some platforms: - // https://github.com/tauri-apps/tauri/issues/10592 - // - // It is working on macOS though (tested). - // So at least we should try to pause countdown on supported platforms to - // reduce power consumption. + // Best-effort power saving; see https://github.com/tauri-apps/tauri/issues/10592. function onVisibilityChange() { if (document.hidden) { console.debug('Document hidden, pause the interval') - // Pause the timer if (timer != null) { clearInterval(timer) timer = null } } else if (hasIntersected) { console.debug('Document visible, resume the interval') - // Resume the timer only when previous one is cleared if (timer == null) { timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL) } @@ -274,7 +242,6 @@ export const IpInfoCard = () => { overflow: 'hidden', }} > - {/* 左侧:国家和IP地址 */} { /> - {/* 右侧:组织、ISP和位置信息 */} { const { isAdminMode, isSidecarMode, mutateSystemState } = useSystemState() const { installServiceAndRestartCore } = useServiceInstaller() - // 自动检查更新逻辑(lastCheckUpdate 由 useUpdate 统一管理) const { checkUpdate: triggerCheckUpdate, lastCheckUpdate } = useUpdate(true) const [osInfo, setOsInfo] = useState('') @@ -42,7 +41,6 @@ export const SystemInfoCard = () => { [lastCheckUpdate], ) - // 初始化系统信息 useEffect(() => { getSystemInfo() .then((info) => { @@ -61,7 +59,6 @@ export const SystemInfoCard = () => { .catch(console.error) }, []) - // 如果启用了自动检查更新但没有记录,设置当前时间并延迟检查 useEffect(() => { if (!verge?.auto_check_update) return if (readLastCheckTime() !== null) return @@ -73,12 +70,10 @@ export const SystemInfoCard = () => { return () => window.clearTimeout(timeoutId) }, [verge?.auto_check_update, triggerCheckUpdate]) - // 导航到设置页面 const goToSettings = useCallback(() => { navigate('/settings') }, [navigate]) - // 切换自启动状态 const toggleAutoLaunch = useCallback(async () => { if (!verge) return try { @@ -88,7 +83,6 @@ export const SystemInfoCard = () => { } }, [verge, patchVerge]) - // 点击运行模式处理,Sidecar或纯管理员模式允许安装服务 const handleRunningModeClick = useCallback(async () => { if (isSidecarMode || (isAdminMode && isSidecarMode)) { await installServiceAndRestartCore() @@ -101,7 +95,6 @@ export const SystemInfoCard = () => { mutateSystemState, ]) - // 检查更新 const onCheckUpdate = useLockFn(async () => { try { const result = await triggerCheckUpdate() @@ -119,16 +112,13 @@ export const SystemInfoCard = () => { } }) - // 是否启用自启动 const autoLaunchEnabled = useMemo( () => verge?.enable_auto_launch || false, [verge], ) - // 运行模式样式 const runningModeStyle = useMemo( () => ({ - // Sidecar或纯管理员模式允许安装服务 cursor: isSidecarMode || (isAdminMode && isSidecarMode) ? 'pointer' : 'default', textDecoration: @@ -143,10 +133,8 @@ export const SystemInfoCard = () => { [isSidecarMode, isAdminMode], ) - // 获取模式图标和文本 const getModeIcon = () => { if (isAdminMode) { - // 判断是否为组合模式(管理员+服务) if (!isSidecarMode) { return ( <> @@ -184,10 +172,8 @@ export const SystemInfoCard = () => { } } - // 获取模式文本 const getModeText = () => { if (isAdminMode) { - // 判断是否同时处于服务模式 if (!isSidecarMode) { return t('home.components.systemInfo.badges.adminServiceMode') } @@ -199,7 +185,6 @@ export const SystemInfoCard = () => { } } - // 只有当verge存在时才渲染内容 if (!verge) return null return ( diff --git a/src/components/layout/layout-traffic.tsx b/src/components/layout/layout-traffic.tsx index 90869b246..5a248e561 100644 --- a/src/components/layout/layout-traffic.tsx +++ b/src/components/layout/layout-traffic.tsx @@ -17,12 +17,10 @@ import parseTraffic from '@/utils/parse-traffic' import { TrafficGraph, type TrafficRef } from './traffic-graph' -// setup the traffic export const LayoutTraffic = () => { const { t } = useTranslation() const { verge } = useVerge() - // whether hide traffic graph const trafficGraph = verge?.traffic_graph ?? true const displayMemory = verge?.enable_memory_usage ?? true @@ -36,7 +34,6 @@ export const LayoutTraffic = () => { response: { data: memory }, } = useMemoryData({ enabled: displayMemory && pageVisible }) - // 监听数据变化,为图表添加数据点 useEffect(() => { if (trafficRef.current) { trafficRef.current.appendData({ @@ -48,7 +45,6 @@ export const LayoutTraffic = () => { } }, [traffic]) - // 使用parseTraffic统一处理转换,保持与首页一致的显示格式 const [up, upUnit] = parseTraffic(traffic?.up || 0) const [down, downUnit] = parseTraffic(traffic?.down || 0) const [inuse, inuseUnit] = parseTraffic(memory?.inuse || 0) @@ -96,7 +92,6 @@ export const LayoutTraffic = () => { {...boxStyle} sx={{ ...boxStyle.sx, - // opacity: traffic?.is_fresh ? 1 : 0.6, }} > { {...boxStyle} sx={{ ...boxStyle.sx, - // opacity: traffic?.is_fresh ? 1 : 0.6, }} > { sx={{ ...boxStyle.sx, cursor: 'auto', - // opacity: memory?.is_fresh ? 1 : 0.6, }} color={'disabled'} - onClick={async () => { - // isDebug && (await gc()); - }} + onClick={async () => {}} > {inuse} diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx index 9bc2de23d..2954b8d91 100644 --- a/src/components/profile/profile-item.tsx +++ b/src/components/profile/profile-item.tsx @@ -102,7 +102,6 @@ const ProfileItemBase = (props: ProfileItemProps) => { const loadingCache = useLoadingCache() const setLoadingCache = useSetLoadingCache() - // 新增状态:是否显示下次更新时间 const [showNextUpdate, setShowNextUpdate] = useState(false) const showNextUpdateRef = useRef(false) const [nextUpdateTime, setNextUpdateTime] = useState('') @@ -126,7 +125,6 @@ const ProfileItemBase = (props: ProfileItemProps) => { const { uid, name = 'Profile', extra, updated = 0, option } = itemData - // 获取下次更新时间的函数 const fetchNextUpdateTimeCallback = useCallback( async (forceRefresh = false) => { if ( @@ -136,9 +134,7 @@ const ProfileItemBase = (props: ProfileItemProps) => { try { debugLog(`尝试获取配置 ${itemData.uid} 的下次更新时间`) - // 如果需要强制刷新,先触发Timer.refresh() if (forceRefresh) { - // 这里可以通过一个新的API来触发刷新,但目前我们依赖patch_profile中的刷新 debugLog(`强制刷新定时器任务`) } @@ -149,13 +145,11 @@ const ProfileItemBase = (props: ProfileItemProps) => { const nextUpdateDate = dayjs(nextUpdate * 1000) const now = dayjs() - // 如果已经过期,显示"更新失败" if (nextUpdateDate.isBefore(now)) { setNextUpdateTime( t('profiles.components.profileItem.status.lastUpdateFailed'), ) } else { - // 否则显示剩余时间 const diffMinutes = nextUpdateDate.diff(now, 'minute') if (diffMinutes < 60) { @@ -197,7 +191,6 @@ const ProfileItemBase = (props: ProfileItemProps) => { ) const fetchNextUpdateTime = useLockFn(fetchNextUpdateTimeCallback) - // 切换显示模式的函数 const toggleUpdateTimeDisplay = (e: React.MouseEvent) => { e.stopPropagation() @@ -212,7 +205,6 @@ const ProfileItemBase = (props: ProfileItemProps) => { showNextUpdateRef.current = showNextUpdate }, [showNextUpdate]) - // 当组件加载或更新间隔变化时更新下次更新时间 useEffect(() => { if (showNextUpdate) { fetchNextUpdateTime() @@ -224,7 +216,6 @@ const ProfileItemBase = (props: ProfileItemProps) => { updated, ]) - // 页面统一订阅定时器事件,这里只响应当前配置的更新信号 useEffect(() => { if (timerUpdateRevision === 0 || !showNextUpdateRef.current) return @@ -247,9 +238,6 @@ const ProfileItemBase = (props: ProfileItemProps) => { fetchNextUpdateTime() }, [completedUpdateRevision, fetchNextUpdateTime]) - // local file mode - // remote file mode - // remote file mode const hasUrl = !!itemData.url const hasExtra = !!extra // only subscription url has extra info const hasHome = !!itemData.home // only subscription url has home page @@ -265,7 +253,6 @@ const ProfileItemBase = (props: ProfileItemProps) => { const loading = loadingCache.has(itemData.uid) - // interval update fromNow field const [, forceRefresh] = useReducer((value: number) => value + 1, 0) useEffect(() => { if (!hasUrl) return @@ -275,7 +262,6 @@ const ProfileItemBase = (props: ProfileItemProps) => { const handler = () => { const now = Date.now() const lastUpdate = updated * 1000 - // 大于一天的不管 if (now - lastUpdate >= 24 * 36e5) return const wait = now - lastUpdate >= 36e5 ? 30e5 : 5e4 @@ -394,7 +380,6 @@ const ProfileItemBase = (props: ProfileItemProps) => { setAnchorEl(null) setLoading(true) - // 根据类型设置初始更新选项 const option: Partial = {} if (type === 0) { option.with_proxy = false @@ -410,15 +395,11 @@ const ProfileItemBase = (props: ProfileItemProps) => { } try { - // 调用后端更新(后端会自动处理回退逻辑) const payload = Object.keys(option).length > 0 ? option : undefined await updateProfile(itemData.uid, payload) - // 更新成功,刷新列表 void mutateProfiles() } catch { - // 更新完全失败(包括后端的回退尝试) - // 不需要做处理,后端会通过事件通知系统发送错误 } finally { setLoading(false) } @@ -522,7 +503,6 @@ const ProfileItemBase = (props: ProfileItemProps) => { handler: () => { setAnchorEl(null) if (batchMode) { - // If in batch mode, just toggle selection instead of showing delete confirmation if (onSelectionChange) { onSelectionChange() } @@ -584,7 +564,6 @@ const ProfileItemBase = (props: ProfileItemProps) => { handler: () => { setAnchorEl(null) if (batchMode) { - // If in batch mode, just toggle selection instead of showing delete confirmation if (onSelectionChange) { onSelectionChange() } @@ -640,7 +619,6 @@ const ProfileItemBase = (props: ProfileItemProps) => { { - // 如果正在激活中,阻止重复点击 if (activating) { e.preventDefault() e.stopPropagation() @@ -734,7 +712,6 @@ const ProfileItemBase = (props: ProfileItemProps) => { - {/* only if has url can it be updated */} {hasUrl && ( { disabled={loading} onClick={(e) => { e.stopPropagation() - // 如果正在激活或加载中,阻止更新操作 if (activating || loading) { return } @@ -761,7 +737,6 @@ const ProfileItemBase = (props: ProfileItemProps) => { )} - {/* the second line show url's info or description */} { <> @@ -824,7 +799,6 @@ const ProfileItemBase = (props: ProfileItemProps) => { } - {/* the third line show extra info or last updated time */} {hasExtra ? ( diff --git a/src/components/profile/profile-viewer.tsx b/src/components/profile/profile-viewer.tsx index 3a1565953..1a75e725f 100644 --- a/src/components/profile/profile-viewer.tsx +++ b/src/components/profile/profile-viewer.tsx @@ -31,8 +31,6 @@ export interface ProfileViewerRef { edit: (item: IProfileItem) => void } -// create or edit the profile -// remote / local type ProfileViewerProps = Props & { ref?: Ref } // 同后端 constants::profile::MIN_UPDATE_INTERVAL @@ -45,7 +43,6 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { const [loading, setLoading] = useState(false) const { profiles } = useProfiles() - // file input const fileDataRef = useRef(null) const { control, watch, setValue, reset, handleSubmit, getValues } = @@ -94,7 +91,6 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { handleSubmit(async (form) => { setLoading(true) try { - // 基本验证 if (!form.type) { throw new Error(t('profiles.modals.profileForm.errors.typeRequired')) } @@ -102,7 +98,6 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { throw new Error(t('profiles.modals.profileForm.errors.urlRequired')) } - // 处理表单数据 const option = form.option ? { ...form.option } : undefined if (option?.timeout_seconds) { option.timeout_seconds = +option.timeout_seconds @@ -123,16 +118,14 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { const isRemote = form.type === 'remote' const isUpdate = openType === 'edit' - // 判断是否是当前激活的配置 const isActivating = isUpdate && form.uid === (profiles?.current ?? '') - // 保存原始代理设置以便回退成功后恢复 + // Preserve proxy settings when the remote retry succeeds through another route. const originalOptions = { with_proxy: form.option?.with_proxy, self_proxy: form.option?.self_proxy, } - // 执行创建或更新操作,本地配置不需要回退机制 if (!isRemote) { if (openType === 'new') { await createProfile(item, fileDataRef.current) @@ -145,9 +138,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { await patchProfile(form.uid, item) } } else { - // 远程配置使用回退机制 try { - // 尝试正常操作 if (openType === 'new') { await createProfile(item, fileDataRef.current) } else { @@ -159,12 +150,10 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { await patchProfile(form.uid, item) } } catch { - // 首次创建/更新失败,尝试使用自身代理 showNotice.info( 'profiles.modals.profileForm.feedback.notifications.creationRetry', ) - // 使用自身代理的配置 const retryItem = { ...item, option: { @@ -174,7 +163,6 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { }, } - // 使用自身代理再次尝试 if (openType === 'new') { await createProfile(retryItem, fileDataRef.current) } else { @@ -185,7 +173,6 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { } await patchProfile(form.uid, retryItem) - // 编辑模式下恢复原始代理设置 await patchProfile(form.uid, { option: originalOptions }) } @@ -195,12 +182,10 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { } } - // 成功后的操作 setOpen(false) setTimeout(() => reset(), 500) fileDataRef.current = null - // 优化:UI先关闭,异步通知父组件 setTimeout(() => { onChange(isActivating) }, 0) @@ -359,7 +344,6 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { control={control} render={({ field }) => { const interval = Number(field.value) - // 0 和空值表示不自动更新,不提醒 const tooFrequent = Number.isFinite(interval) && interval > 0 && diff --git a/src/components/proxy/provider-button.tsx b/src/components/proxy/provider-button.tsx index a4b263310..84fa690e7 100644 --- a/src/components/proxy/provider-button.tsx +++ b/src/components/proxy/provider-button.tsx @@ -26,7 +26,6 @@ import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context' import { showNotice } from '@/services/notice-service' import parseTraffic from '@/utils/parse-traffic' -// 样式化组件 - 类型框 const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({ display: 'inline-block', border: '1px solid #ccc', @@ -39,7 +38,6 @@ const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({ lineHeight: 1.25, })) -// 解析过期时间 const parseExpire = (expire?: number) => { if (!expire) return '-' return dayjs(expire * 1000).format('YYYY-MM-DD') @@ -54,15 +52,12 @@ export const ProviderButton = () => { const providers = proxyView?.providers ?? [] const providerUnavailable = proxyView?.providerState === 'unavailable' - // 更新单个代理提供者 const updateProvider = useLockFn(async (name: string) => { try { - // 设置更新状态 setUpdating((prev) => ({ ...prev, [name]: true })) await updateProxyProvider(name) - // 刷新数据 await refreshProxy() showNotice.success( @@ -77,22 +72,18 @@ export const ProviderButton = () => { message: String(err), }) } finally { - // 清除更新状态 setUpdating((prev) => ({ ...prev, [name]: false })) } }) - // 更新所有代理提供者 const updateAllProviders = useLockFn(async () => { try { - // 获取所有provider的名称 const allProviders = providers.map(({ name }) => name) if (allProviders.length === 0) { showNotice.info('proxies.feedback.notifications.provider.none') return } - // 设置所有provider为更新中状态 const newUpdating = allProviders.reduce( (acc, key) => { acc[key] = true @@ -102,19 +93,15 @@ export const ProviderButton = () => { ) setUpdating(newUpdating) - // 改为串行逐个更新所有provider for (const name of allProviders) { try { await updateProxyProvider(name) - // 每个更新完成后更新状态 setUpdating((prev) => ({ ...prev, [name]: false })) } catch (err) { console.error(`更新 ${name} 失败`, err) - // 继续执行下一个,不中断整体流程 } } - // 刷新数据 await refreshProxy() showNotice.success('proxies.feedback.notifications.provider.allUpdated') @@ -123,7 +110,6 @@ export const ProviderButton = () => { message: String(err), }) } finally { - // 清除所有更新状态 setUpdating({}) } }) @@ -190,7 +176,6 @@ export const ProviderButton = () => { : null const isUpdating = updating[key] - // 订阅信息 const sub = provider.subscriptionInfo const hasSubInfo = !!sub const upload = sub?.upload || 0 @@ -198,7 +183,6 @@ export const ProviderButton = () => { const total = sub?.total || 0 const expire = sub?.expire || 0 - // 流量使用进度 const progress = total > 0 ? Math.min( @@ -272,7 +256,6 @@ export const ProviderButton = () => { } secondary={ <> - {/* 订阅信息 */} {hasSubInfo && ( <> { - {/* 进度条 */} item.type === 'remote' || item.type === 'local' -/** - * What the proxy page should show. - * - * Deliberately does *not* answer "is there anything to render". That question is answered by - * the list itself, in `hasRenderableItems`, because predicting it from beside the renderer is - * how the two came to disagree. This decides only the things knowable without building a list: - * direct mode, still-loading, and having no usable subscription to render from. - */ +/** Decides only pre-render states; the built list owns whether it has renderable content. */ export type ProxyListState = | { kind: 'direct' } | { kind: 'loading' } @@ -67,12 +60,7 @@ export const resolveProxyListState = ({ return { kind: 'render' } } -/** - * Why a list that turned out to be empty is empty. - * - * Only reachable once the renderer has built its list and found nothing, so it explains an - * observed emptiness rather than predicting one. - */ +/** Explains an empty list only after the renderer observes it. */ export const resolveEmptyListReason = ({ runningMode, isProxyViewError, diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index 912c1c593..b2e36b2cb 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -65,11 +65,6 @@ interface Props { chainConfigData?: string | null } -/** - * The empty state to draw when the render list turns out to contain nothing. - * - * Shared by both list components so the observation and its explanation stay together. - */ function useEmptyRenderList() { const { isProxyViewError } = useProxiesData() const { runningMode } = useSystemData() @@ -103,7 +98,6 @@ function useProxyRenderState( const timeout = verge?.default_latency_timeout || 10000 - // 测全部延迟 const handleCheckAll = useStableCallback( useLockFn(async (groupName: string) => { debugLog(`[ProxyGroups] 开始测试所有延迟,组: ${groupName}`) @@ -133,8 +127,6 @@ function useProxyRenderState( } catch (error) { console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error) } finally { - // Re-sorting is no longer poked from here: the delay store announces that the test - // settled and the render list recomputes from that. onProxies() } }), @@ -254,7 +246,6 @@ function ChainProxyGroups(props: { const virtualItems = virtualizer.getVirtualItems() const activeStickyIndex = activeStickyIndexRef.current - // 从 localStorage 恢复滚动位置 useLayoutEffect(() => { if (renderList.length === 0) return const node = parentRef.current @@ -335,7 +326,6 @@ function ChainProxyGroups(props: { } }) - // The list is built; whether it holds anything is now an observation, not a guess. if (!hasRenderableItems(renderList)) return emptyList return ( @@ -376,11 +366,9 @@ function NormalProxyGroups(props: { mode: string }) { } = useProxyRenderState(mode, false, null) const emptyList = useEmptyRenderList() const renderFirstRef = useRef(true) - // 恢复滚动位置期间设为 true,避免程序化滚动触发的 scroll 事件把中间值写回存储 + // Do not persist intermediate positions produced while restoring virtual scroll. const isRestoringRef = useRef(false) - // 目前无法使用 StickyVirtualList 的 initialOffset 值设置初始化,具体原因需排查 - // 从 localStorage 恢复滚动位置 useLayoutEffect(() => { if (renderList.length === 0) return if (!renderFirstRef.current) return @@ -388,15 +376,12 @@ function NormalProxyGroups(props: { mode: string }) { if (!node) return const savedPosition = getScrollPosition() - // 未保存过位置或位置为 0(顶部)时无需恢复 if (!savedPosition) { renderFirstRef.current = false return } - // 虚拟列表初始使用预估高度,真实高度测量完成后总高度才会稳定。 - // 尤其是过滤后节点数变少时,预估总高度常常不足以一次性滚动到目标位置, - // 因此跨帧重试,直到到达目标位置(或内容确实不够高)为止。 + // Retry across frames until virtual-list measurements can reach the saved offset. isRestoringRef.current = true let rafId = 0 let attempts = 0 @@ -436,7 +421,6 @@ function NormalProxyGroups(props: { mode: string }) { const handleScroll = useCallback( (event: Event) => { - // 恢复位置过程中产生的滚动不写回存储,避免中间的钳制值覆盖真实位置 if (isRestoringRef.current) return const target = event.target as HTMLElement | null const nextScrollTop = target?.scrollTop ?? 0 @@ -480,7 +464,6 @@ function NormalProxyGroups(props: { mode: string }) { [handleProxyGroupChange], ) - // 滚到对应的节点 const handleLocation = useStableCallback((group: ProxyGroupView) => { if (!group) return const { name, now } = group @@ -501,7 +484,6 @@ function NormalProxyGroups(props: { mode: string }) { } }) - // 定位到指定的代理组 const handleGroupLocationByName = useCallback( (groupName: string) => { const index = renderList.findIndex( @@ -525,7 +507,6 @@ function NormalProxyGroups(props: { mode: string }) { return Array.from(new Set(names)) }, [renderList]) - // 点击代理组改变展开状态,先滚动到sticky的代理组位置,再收起展开状态 const handleGroupToggle = useCallback( async (group: ProxyGroupView) => { const index = renderList.findIndex( @@ -589,7 +570,6 @@ function NormalProxyGroups(props: { mode: string }) { [handleChangeProxy, handleCheckAll, onHeadState, handleLocation], ) - // The list is built; whether it holds anything is now an observation, not a guess. if (!hasRenderableItems(renderList)) return emptyList return ( @@ -605,7 +585,6 @@ function NormalProxyGroups(props: { mode: string }) { renderItem={renderProxyItem} /> - {/* 代理组导航栏 */} {mode === 'rule' && ( { case 'empty': return case 'render': - // Whether there is anything to draw is the list's own answer, given below. return isChainMode ? ( ) : ( diff --git a/src/components/proxy/use-filter-sort.ts b/src/components/proxy/use-filter-sort.ts index c5f68f235..20578c9ba 100644 --- a/src/components/proxy/use-filter-sort.ts +++ b/src/components/proxy/use-filter-sort.ts @@ -5,7 +5,6 @@ import { compileStringMatcher } from '@/utils/search-matcher' import type { ResolvedMemberOccurrence } from './use-render-list' -// default | delay | alphabet export type ProxySortType = 0 | 1 | 2 export type ProxySearchState = { @@ -27,16 +26,9 @@ export function filterSort( return sp } -/** - * 可以通过延迟数/节点类型 过滤 - */ const regex1 = /delay([=<>])(\d+|timeout|error)/i const regex2 = /type=(.*)/i -/** - * filter the proxy - * according to the regular conditions - */ function filterProxies( proxies: ResolvedMemberOccurrence[], groupName: string, @@ -90,9 +82,6 @@ function filterProxies( return proxies.filter(({ member }) => compiled.matcher(member.ref.name)) } -/** - * sort the proxy - */ function sortProxies( proxies: ResolvedMemberOccurrence[], groupName: string, diff --git a/src/components/proxy/use-render-list.test.ts b/src/components/proxy/use-render-list.test.ts index af4fd2e6c..f788e543c 100644 --- a/src/components/proxy/use-render-list.test.ts +++ b/src/components/proxy/use-render-list.test.ts @@ -5,21 +5,18 @@ import { hasRenderableItems, type IRenderItem } from './use-render-list' const group = (hidden = false) => ({ name: 'Proxies', hidden }) as unknown as IRenderItem['group'] -/** A group header row. On its own it is only content when the group is visible. */ const header = (hidden = false): IRenderItem => ({ type: 0, key: 'header', group: group(hidden), }) -/** A member row — in chain mode the list is made entirely of these. */ const member = (key: string): IRenderItem => ({ type: 2, key, group: group(), }) -/** A column of members, which is how the chain list renders at column counts above one. */ const memberColumn = (key: string): IRenderItem => ({ type: 4, key, @@ -32,9 +29,7 @@ describe('hasRenderableItems', () => { }) test('chain mode is answered by the chain list itself', () => { - // This is the case the old prediction got wrong in both directions: it asked whether any - // Selector or URLTest group existed, which is unrelated to what the chain list is built - // from. Here there are rows, so there is content, whatever groups happen to exist. + // Rendered rows, not group types, determine whether chain mode is empty. expect(hasRenderableItems([member('chain:a')])).toBe(true) expect(hasRenderableItems([memberColumn('chain-col:a')])).toBe(true) }) @@ -54,8 +49,7 @@ describe('hasRenderableItems', () => { }) test('rows inside a hidden but expanded group are still rows on screen', () => { - // Truthfulness over tidiness: the user can see these, so calling the page empty would be - // a lie. The old predicate said "empty" here. + // Visible member rows mean the page is not empty even without a visible group header. expect(hasRenderableItems([header(true), member('member:a')])).toBe(true) }) }) diff --git a/src/components/rule/provider-button.tsx b/src/components/rule/provider-button.tsx index 97b20731e..f59f46f28 100644 --- a/src/components/rule/provider-button.tsx +++ b/src/components/rule/provider-button.tsx @@ -24,7 +24,6 @@ import { updateRuleProvider } from 'tauri-plugin-mihomo-api' import { useAppRefreshers, useRulesData } from '@/providers/app-data-context' import { showNotice } from '@/services/notice-service' -// 辅助组件 - 类型框 const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({ display: 'inline-block', border: '1px solid #ccc', @@ -44,18 +43,14 @@ export const ProviderButton = () => { const { refreshRules, refreshRuleProviders } = useAppRefreshers() const [updating, setUpdating] = useState>({}) - // 检查是否有提供者 const hasProviders = Object.keys(ruleProviders || {}).length > 0 - // 更新单个规则提供者 const updateProvider = useLockFn(async (name: string) => { try { - // 设置更新状态 setUpdating((prev) => ({ ...prev, [name]: true })) await updateRuleProvider(name) - // 刷新数据 await refreshRules() await refreshRuleProviders() @@ -71,22 +66,18 @@ export const ProviderButton = () => { message: String(err), }) } finally { - // 清除更新状态 setUpdating((prev) => ({ ...prev, [name]: false })) } }) - // 更新所有规则提供者 const updateAllProviders = useLockFn(async () => { try { - // 获取所有provider的名称 const allProviders = Object.keys(ruleProviders || {}) if (allProviders.length === 0) { showNotice.info('rules.feedback.notifications.provider.none') return } - // 设置所有provider为更新中状态 const newUpdating = allProviders.reduce( (acc, key) => { acc[key] = true @@ -96,19 +87,15 @@ export const ProviderButton = () => { ) setUpdating(newUpdating) - // 改为串行逐个更新所有provider for (const name of allProviders) { try { await updateRuleProvider(name) - // 每个更新完成后更新状态 setUpdating((prev) => ({ ...prev, [name]: false })) } catch (err) { console.error(`更新 ${name} 失败`, err) - // 继续执行下一个,不中断整体流程 } } - // 刷新数据 await refreshRules() await refreshRuleProviders() @@ -118,7 +105,6 @@ export const ProviderButton = () => { message: String(err), }) } finally { - // 清除所有更新状态 setUpdating({}) } }) diff --git a/src/components/setting/mods/dns-viewer.tsx b/src/components/setting/mods/dns-viewer.tsx index 9d18dba00..7c6004b64 100644 --- a/src/components/setting/mods/dns-viewer.tsx +++ b/src/components/setting/mods/dns-viewer.tsx @@ -132,7 +132,6 @@ function parseList(str: string): string[] { .filter(Boolean) } -// 默认DNS配置 const DEFAULT_DNS_CONFIG = { enable: true, listen: ':53', @@ -251,13 +250,12 @@ export function DnsViewer({ ref }: { ref?: Ref }) { hosts: '', }) - // 用于YAML编辑模式, 初始化标记:阻止 updateValuesFromYaml 在配置加载前误执行 + // Prevent YAML-to-form sync before configuration loads. const [yamlContent, setYamlContent] = useReducer( (_: string, next: string) => next, '{ isInitValue: true }', ) - // 从配置对象更新表单值 const updateValuesFromConfig = useCallback((config: any) => { if (!config) return @@ -388,7 +386,6 @@ export function DnsViewer({ ref }: { ref?: Ref }) { setYamlContent(yaml.dump(config, { forceQuotes: true })) }, [generateDnsConfig, values.hosts]) - // 重置为默认值 const resetToDefaults = useCallback(() => { setValues({ enable: DEFAULT_DNS_CONFIG.enable, @@ -425,7 +422,6 @@ export function DnsViewer({ ref }: { ref?: Ref }) { updateYamlFromValues() }, [updateYamlFromValues]) - // 从YAML更新表单值 const updateValuesFromYaml = useCallback(() => { try { const parsedYaml = yaml.load(yamlContent) as any @@ -503,14 +499,11 @@ export function DnsViewer({ ref }: { ref?: Ref }) { [initDnsConfig], ) - // 生成DNS配置对象 - // 处理保存操作 const onSave = useLockFn(async () => { try { let config: Record if (visualization) { - // 使用表单值生成配置 config = {} const dnsConfig = generateDnsConfig() @@ -523,7 +516,6 @@ export function DnsViewer({ ref }: { ref?: Ref }) { config.hosts = hosts } } else { - // 使用YAML编辑器的值 const parsedConfig = yaml.load(yamlContent) if (typeof parsedConfig !== 'object' || parsedConfig === null) { throw new Error(t('settings.modals.dns.errors.invalid')) @@ -531,10 +523,8 @@ export function DnsViewer({ ref }: { ref?: Ref }) { config = parsedConfig as Record } - // 保存配置 await invoke('save_dns_config', { dnsConfig: config }) - // 验证配置 const validation = await invoke( 'validate_dns_config', {}, @@ -547,7 +537,6 @@ export function DnsViewer({ ref }: { ref?: Ref }) { : 'Configuration validation skipped' let cleanErrorMsg = errorMsg - // 提取关键错误信息 if (errorMsg.includes('level=error')) { const errorLines = errorMsg .split('\n') @@ -575,7 +564,6 @@ export function DnsViewer({ ref }: { ref?: Ref }) { return } - // 如果DNS开关当前是打开的,则需要应用新的DNS配置 if (clash?.dns?.enable) { await invoke('apply_dns_config', { apply: true }) mutateClash() @@ -588,12 +576,10 @@ export function DnsViewer({ ref }: { ref?: Ref }) { } }) - // YAML编辑器内容变更处理 const handleYamlChange = (value?: string) => { setYamlContent(value || '') } - // 处理表单值变化 const handleChange = (field: string) => (event: any) => { const value = event.target.type === 'checkbox' @@ -606,7 +592,6 @@ export function DnsViewer({ ref }: { ref?: Ref }) { [field]: value, } - // 当可视化编辑模式下的值变化时,自动更新YAML if (visualization) { setTimeout(() => { updateYamlFromValues() @@ -667,7 +652,6 @@ export function DnsViewer({ ref }: { ref?: Ref }) { onCancel={() => setOpen(false)} onOk={onSave} > - {/* Warning message */} }) { /> - {/* Hosts 配置部分 */} { - // 合并现有源和开发URL,并去重 const allOrigins = [...origins, ...DEV_URLS] const uniqueOrigins = [...new Set(allOrigins)] return uniqueOrigins } -// 过滤基础URL(确保后续添加) const filterBaseOriginsForUI = (origins: string[]) => { return origins.filter((origin: string) => !DEV_URLS.includes(origin.trim())) } -// 统一使用的按钮样式 const buttonStyle = { borderRadius: '8px', textTransform: 'none', @@ -47,7 +40,6 @@ const buttonStyle = { }, } -// 添加按钮样式 const addButtonStyle = { ...buttonStyle, backgroundColor: '#4CAF50', @@ -57,7 +49,6 @@ const addButtonStyle = { }, } -// 删除按钮样式 const deleteButtonStyle = { ...buttonStyle, backgroundColor: '#FF5252', @@ -85,7 +76,6 @@ export const HeaderConfiguration = forwardRef( const lastKeyRef = useRef(0) // 用于生成唯一的key - // CORS配置状态管理 const [corsConfig, setCorsConfig] = useState<{ allowPrivateNetwork: boolean allowOrigins: AllowOriginItem[] @@ -101,7 +91,6 @@ export const HeaderConfiguration = forwardRef( } }) - // 处理CORS配置变更 const handleCorsConfigChange = ( key: 'allowPrivateNetwork' | 'allowOrigins', value: boolean | AllowOriginItem[], @@ -112,7 +101,6 @@ export const HeaderConfiguration = forwardRef( })) } - // 添加新的允许来源 const handleAddOrigin = () => { lastKeyRef.current += 1 handleCorsConfigChange('allowOrigins', [ @@ -121,24 +109,20 @@ export const HeaderConfiguration = forwardRef( ]) } - // 更新允许来源列表中的某一项 const handleUpdateOrigin = (index: number, value: string) => { const newOrigins = [...corsConfig.allowOrigins] newOrigins[index] = { ...newOrigins[index], value } handleCorsConfigChange('allowOrigins', newOrigins) } - // 删除允许来源列表中的某一项 const handleDeleteOrigin = (index: number) => { const newOrigins = [...corsConfig.allowOrigins] newOrigins.splice(index, 1) handleCorsConfigChange('allowOrigins', newOrigins) } - // 保存配置请求 const { loading, run: saveConfig } = useRequest( async () => { - // 保存时使用完整的源列表(包括开发URL) const fullOrigins = getFullOrigins( corsConfig.allowOrigins.map((origin) => origin.value), ) diff --git a/src/components/setting/mods/guard-state.tsx b/src/components/setting/mods/guard-state.tsx index 09227e73c..c85d6d9d2 100644 --- a/src/components/setting/mods/guard-state.tsx +++ b/src/components/setting/mods/guard-state.tsx @@ -40,18 +40,15 @@ export function GuardState(props: Props) { childProps[valueProps] = value childProps[onChangeProps] = async (...args: any[]) => { - // 多次操作无效 if (lockRef.current) return lockRef.current = true try { const newValue = onFormat ? onFormat(...args) : (args[0] as T) - // 先在ui上响应操作 onChange(newValue) const now = Date.now() - // save the old value if (waitTime <= 0 || now - lastRef.current >= waitTime) { saveRef.current = value } @@ -62,14 +59,12 @@ export function GuardState(props: Props) { await onGuard(newValue, value!) lockRef.current = false } else { - // debounce guard clearTimeout(timeRef.current) timeRef.current = setTimeout(async () => { try { await onGuard(newValue, saveRef.current!) } catch (err: any) { - // 状态回退 onChange(saveRef.current!) onCatch(err) } finally { @@ -78,7 +73,6 @@ export function GuardState(props: Props) { }, waitTime) } } catch (err: any) { - // 状态回退 onChange(saveRef.current!) onCatch(err) lockRef.current = false diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index 706bbfc17..4a3c33cfc 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -191,7 +191,6 @@ export const SysproxyViewer = forwardRef((props, ref) => { const { systemProxyAddress } = useSystemData() const displayedMixedPort = useDisplayedMixedPort() - // 为当前状态计算系统代理地址 const getSystemProxyAddress = useMemo(() => { const isPacMode = value.pac ?? false @@ -250,14 +249,11 @@ export const SysproxyViewer = forwardRef((props, ref) => { close: () => setOpen(false), })) - // 获取网络接口和主机名 const fetchNetworkInterfaces = async () => { try { - // 获取系统网络接口信息 const interfaces = await getNetworkInterfacesInfo() const ipAddresses: string[] = [] - // 从interfaces中提取IPv4和IPv6地址 interfaces.forEach((iface) => { iface.addr.forEach((address) => { if (address.V4 && address.V4.ip) { @@ -269,7 +265,6 @@ export const SysproxyViewer = forwardRef((props, ref) => { }) }) - // 获取当前系统的主机名 let hostname = '' try { hostname = await getSystemHostname() @@ -278,12 +273,9 @@ export const SysproxyViewer = forwardRef((props, ref) => { console.error('获取主机名失败:', err) } - // 构建选项列表 const options = ['127.0.0.1', 'localhost'] - // 确保主机名添加到列表,即使它是空字符串也记录下来 if (hostname) { - // 如果主机名不是localhost或127.0.0.1,则添加它 if (hostname !== 'localhost' && hostname !== '127.0.0.1') { hostname = hostname + '.local' options.push(hostname) @@ -295,16 +287,13 @@ export const SysproxyViewer = forwardRef((props, ref) => { debugLog('主机名为空') } - // 添加IP地址 options.push(...ipAddresses) - // 去重 const uniqueOptions = Array.from(new Set(options)) debugLog('最终选项列表:', uniqueOptions) setHostOptions(uniqueOptions) } catch (error) { console.error('获取网络接口失败:', error) - // 失败时至少提供基本选项 setHostOptions(['127.0.0.1', 'localhost']) } } @@ -325,7 +314,6 @@ export const SysproxyViewer = forwardRef((props, ref) => { return } - // 修改验证规则,允许IP和主机名 const ipv4Regex = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/ const ipv6Regex = @@ -369,7 +357,6 @@ export const SysproxyViewer = forwardRef((props, ref) => { let pacContent = value.pac_content if (pacContent) { pacContent = pacContent.replace(/%proxy_host%/g, value.proxy_host) - // 将 mixed-port 转换为字符串 const mixedPortStr = displayedMixedPort.toString() pacContent = pacContent.replace(/%mixed-port%/g, mixedPortStr) } @@ -378,7 +365,6 @@ export const SysproxyViewer = forwardRef((props, ref) => { patch.pac_file_content = pacContent } - // 处理IPv6地址,如果是IPv6地址但没有被方括号包围,则添加方括号 let proxyHost = value.proxy_host if ( ipv6Regex.test(proxyHost) && @@ -392,7 +378,6 @@ export const SysproxyViewer = forwardRef((props, ref) => { patch.proxy_host = proxyHost } - // 判断是否需要重置系统代理 const needResetProxy = value.pac !== proxy_auto_config || proxyHost !== proxy_host || @@ -402,7 +387,6 @@ export const SysproxyViewer = forwardRef((props, ref) => { Promise.resolve().then(async () => { try { - // 乐观更新本地状态 if (Object.keys(patch).length > 0) { mutateVerge({ ...verge, ...patch }, false) } @@ -413,7 +397,6 @@ export const SysproxyViewer = forwardRef((props, ref) => { try { await invalidateProxyState() - // 如果需要重置代理且代理当前启用 if (needResetProxy && enabled) { const [currentSysProxy, currentAutoProxy] = await Promise.all([ getSystemProxy(), @@ -439,7 +422,6 @@ export const SysproxyViewer = forwardRef((props, ref) => { console.error('配置保存失败:', err) mutateVerge() showNotice.error(err) - // setOpen(true); } }) }) @@ -587,7 +569,6 @@ export const SysproxyViewer = forwardRef((props, ref) => { setValue((v) => ({ ...v, use_default: e, - // 当取消选择use_default且当前bypass为空时,填充默认值 bypass: nextBypass, })) return @@ -697,6 +678,5 @@ const FlexBox = styled('div')` .label { flex: none; - //width: 85px; } ` diff --git a/src/components/shared/traffic-error-boundary.tsx b/src/components/shared/traffic-error-boundary.tsx index 0cdfedc7b..75d77b110 100644 --- a/src/components/shared/traffic-error-boundary.tsx +++ b/src/components/shared/traffic-error-boundary.tsx @@ -20,10 +20,6 @@ interface State { showDetails: boolean } -/** - * 流量统计专用错误边界组件 - * 处理图表和流量统计组件的错误,提供优雅的降级体验 - */ export class TrafficErrorBoundary extends Component { private retryCount = 0 private maxRetries = 3 @@ -39,7 +35,6 @@ export class TrafficErrorBoundary extends Component { } static getDerivedStateFromError(error: Error): Partial { - // 更新状态以显示降级UI return { hasError: true, error } } @@ -51,17 +46,14 @@ export class TrafficErrorBoundary extends Component { 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, @@ -72,8 +64,6 @@ export class TrafficErrorBoundary extends Component { } console.error('[TrafficErrorBoundary] 错误报告:', errorReport) - // TODO: 发送到错误监控服务 - // sendErrorReport(errorReport); } private handleRetry = () => { @@ -104,12 +94,10 @@ export class TrafficErrorBoundary extends Component { render() { if (this.state.hasError) { - // 如果提供了自定义降级组件,使用它 if (this.props.fallbackComponent) { return this.props.fallbackComponent } - // 默认错误UI return ( { } } -/** - * 错误降级UI组件 - */ interface TrafficErrorFallbackProps { error: Error | null errorInfo: ErrorInfo | null @@ -283,10 +268,6 @@ const TrafficErrorFallback: React.FC = ({ ) } -/** - * 轻量级流量统计错误边界 - * 用于小型流量显示组件,提供最小化的错误UI - */ export const LightweightTrafficErrorBoundary: React.FC<{ children: ReactNode }> = ({ children }) => { diff --git a/src/hooks/use-group-delays.ts b/src/hooks/use-group-delays.ts index 6b9f862c8..f8e3ce6c0 100644 --- a/src/hooks/use-group-delays.ts +++ b/src/hooks/use-group-delays.ts @@ -4,23 +4,7 @@ import delayManager, { type DelaySnapshot } from '@/services/delay' const NO_DELAYS: DelaySnapshot = { of: () => -1 } -/** - * The delays a list sorts by, as a value it can depend on. - * - * Delays live in a module-level store outside React, so a memo that reads them has nothing - * React can see and goes stale — which is why the two places that sorted by delay each grew - * their own way of forcing a recompute, and why both missed the single-proxy path. - * - * `useSyncExternalStore` rather than an effect plus state: the value is read during render - * while the store notifies from an animation frame, so a settle landing between the two would - * otherwise be dropped with nothing left to re-trigger it. It also keeps the identity stable - * between settles, which a snapshot rebuilt per render would not — that would re-sort on - * every render, which is worse than the hack it replaces. - * - * The store announces when a test *settles*, not on every measurement: a list re-sorting on - * each result would reshuffle for the length of a batch, moving rows out from under the - * pointer. Per-proxy displays stay live on their own subscription and are unaffected. - */ +/** Exposes the external delay store to React, updating sort order only after a test settles. */ export const useGroupDelays = (group: string | null): DelaySnapshot => { const subscribe = useCallback( (onSettle: () => void) => @@ -35,18 +19,11 @@ export const useGroupDelays = (group: string | null): DelaySnapshot => { return useSyncExternalStore(subscribe, read, read) } -/** - * The delays for several groups at once, keyed by group name. - * - * The map's identity changes whenever any of them settles, but each group's own entry keeps - * its identity unless that group settled — so a per-group cache is not thrown away wholesale - * because a neighbouring group finished a test. - */ +/** Preserves unchanged group snapshot identities when another group settles. */ export const useGroupsDelays = ( groups: readonly string[], ): ReadonlyMap => { - // Joined so the subscription tracks membership rather than the array identity, which a - // list rebuilds on every render. + // Subscribe by group membership; callers rebuild the array on every render. const groupKey = groups.join(' ') const subscribe = useCallback( diff --git a/src/hooks/use-record-selection.test.tsx b/src/hooks/use-record-selection.test.tsx index a637f2393..2c5bb82c2 100644 --- a/src/hooks/use-record-selection.test.tsx +++ b/src/hooks/use-record-selection.test.tsx @@ -69,11 +69,7 @@ describe('useRecordSelection', () => { }) it('does not derive anything from a rendered selection list', () => { - // The regression this pins. It used to build the whole list from the profile it had - // rendered, so two selections made before that list refreshed were both derived from the - // same stale snapshot and the later one dropped the earlier group. Since a core start - // re-applies whatever the profile holds, the dropped choice came back on the next restart. - // Each call now carries one group, and the backend merges against the current profile. + // Pair-wise writes let the backend merge concurrent choices against fresh state. record('Proxy', 'Node A') record('Fallback', 'Node C') @@ -91,8 +87,7 @@ describe('useRecordSelection', () => { }) it('reports a failed write without throwing at the caller', async () => { - // Recording runs after the core has already been switched; failing the switch over it would - // be wrong, so the caller is never made to handle it. + // Persistence failure cannot roll back a core selection that already succeeded. const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) recordSelectedNode.mockRejectedValueOnce(new Error('no profile')) diff --git a/src/hooks/use-record-selection.ts b/src/hooks/use-record-selection.ts index 1f30f88f7..bb2ed36f2 100644 --- a/src/hooks/use-record-selection.ts +++ b/src/hooks/use-record-selection.ts @@ -3,21 +3,8 @@ import { useCallback } from 'react' import { forgetSelectedNode, recordSelectedNode } from '@/services/cmds' /** - * Record which node a group is on, in the profile. - * - * The core keeps its own copy in `cache.db` and restores it when it starts, but neither half of - * that is something the app can rely on: `profile.store-selected` comes from a merge template a - * user is free to replace, and a service older than the durable runtime generation starts every - * core in a directory nothing has ever run in. So the app re-applies what the profile says once - * the core is up. - * - * Which makes this the rule: a selection the profile does not know about is one the next start - * will undo. Every path that moves a group has to come through here. - * - * Only the group and the node are sent. Sending the whole selection list — which is what this - * did while it built the array from `useProfiles().current` — made two selections made before - * that list refreshed into one overwriting the other, because both were derived from the same - * stale snapshot. The merge happens on the backend, against whatever the profile holds by then. + * Persists every group selection because core-local state is not durable across all run modes. + * Send only the changed pair so concurrent selections merge against fresh backend state. */ export const useRecordSelection = () => { return useCallback(async (groupName: string, proxyName: string) => { @@ -29,9 +16,6 @@ export const useRecordSelection = () => { }, []) } -/** - * Forget the persisted node selection for a group after its runtime fixed node is released. - */ export const useForgetSelection = () => { return useCallback(async (groupName: string) => { try { diff --git a/src/hooks/use-system-proxy-state.ts b/src/hooks/use-system-proxy-state.ts index 731773381..9958fa381 100644 --- a/src/hooks/use-system-proxy-state.ts +++ b/src/hooks/use-system-proxy-state.ts @@ -15,7 +15,6 @@ import { useQuery, } from '@/services/query-client' -// 系统代理状态检测统一逻辑 export const useSystemProxyState = () => { const { verge, mutateVerge } = useVerge() const { sysproxy } = useSystemData() @@ -33,7 +32,6 @@ export const useSystemProxyState = () => { const { proxy_auto_config, proxy_host } = verge ?? {} - // OS 实际状态:enable + 地址匹配本应用 const indicator = (() => { const host = proxy_host || '127.0.0.1' if (proxy_auto_config) { @@ -46,7 +44,7 @@ export const useSystemProxyState = () => { } })() - // "最后一次生效"模式:快速连续点击时,只执行最终状态 + // Coalesce rapid clicks so only the latest requested state is applied. const pendingRef = useRef(null) const busyRef = useRef(false) @@ -82,7 +80,7 @@ export const useSystemProxyState = () => { (prev) => (prev ? { ...prev, enable_system_proxy: confirmed } : prev), false, ) - // Queued requests assumed the failed state was reached. + // Queued requests were based on a state that never landed. pendingRef.current = null throw error } finally { diff --git a/src/hooks/use-system-state.ts b/src/hooks/use-system-state.ts index 0f04eeb7a..691f01f2d 100644 --- a/src/hooks/use-system-state.ts +++ b/src/hooks/use-system-state.ts @@ -9,10 +9,7 @@ import { useVisibility } from './use-visibility' export const runStateQueryKey = ['getRuntimeState'] as const -/** - * Until the first snapshot arrives, assume the least capable environment: no service, no - * elevation, nothing asked of the user. Guessing "ready" here would flash a usable TUN toggle. - */ +/** Fail closed until the first snapshot so TUN never flashes as available. */ const unknownRunState: RunState = { mode: 'NotRunning', service: 'unknown', @@ -26,13 +23,7 @@ const unknownRunState: RunState = { serviceNeedsAttention: false, } -/** - * The Run State: how the core is running and what backs it. - * - * One query key, kept fresh by `verge://run-state-changed` rather than polling. Every derived - * answer is computed in Rust and travels with the snapshot, so there is exactly one definition - * of "TUN can work" in the app. - */ +/** Event-driven run state; Rust owns all derived availability decisions. */ export function useSystemState() { const pageVisible = useVisibility() @@ -43,7 +34,7 @@ export function useSystemState() { } = useQuery({ queryKey: runStateQueryKey, queryFn: getRuntimeState, - // A safety net only: transitions are pushed, so this is not the primary path. + // A safety net only; transitions normally arrive by event. refetchInterval: pageVisible ? 30000 : false, refetchOnWindowFocus: true, refetchOnReconnect: true, diff --git a/src/hooks/use-update.ts b/src/hooks/use-update.ts index fb7df6125..37dff3055 100644 --- a/src/hooks/use-update.ts +++ b/src/hooks/use-update.ts @@ -19,15 +19,10 @@ export const updateLastCheckTime = (timestamp?: number): number => { return now } -// --- useUpdate hook --- - export const useUpdate = (enabled: boolean = true) => { const { verge } = useVerge() const { auto_check_update } = verge || {} - // Determine if we should check for updates - // If enabled is explicitly false, don't check - // Otherwise, respect the auto_check_update setting (or default to true if null/undefined for manual triggers) const shouldCheck = enabled && auto_check_update !== false const fetchUpdate = async () => { @@ -52,7 +47,6 @@ export const useUpdate = (enabled: boolean = true) => { return { data } } - // Shared last check timestamp const { data: lastCheckUpdate } = useQuery({ queryKey: [LAST_CHECK_KEY], queryFn: readLastCheckTime, diff --git a/src/pages/_layout/hooks/use-layout-events.ts b/src/pages/_layout/hooks/use-layout-events.ts index 46394b4fa..84ad96aaf 100644 --- a/src/pages/_layout/hooks/use-layout-events.ts +++ b/src/pages/_layout/hooks/use-layout-events.ts @@ -34,17 +34,13 @@ export const useLayoutEvents = ( 'getAutotemProxy', ]) }, - // The Run State is pushed, not polled: every transition carries the new snapshot, so it - // is written straight into the cache instead of triggering a fetch. + // Transitions carry the full run-state snapshot, so write it directly to cache. 'verge://run-state-changed': (payload) => { void setCacheDataAsync(runStateQueryKey, payload) }, 'verge://notice-message': handleNotice, }, - // The Run State is the one thing here that arrives *only* by event, so it is the one - // thing a missed event leaves stale — until the next transition, which during startup can - // be a long time. Re-read once the listener is live, so the gap between the first read - // and the subscription cannot outlive the subscription. + // Re-read event-only state after subscribing to close the initial race window. () => revalidateKeys(['getRuntimeState']), ) }, [handleNotice]) diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index cbb3ac7e7..60f6397a5 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -54,7 +54,6 @@ import { deleteProfile, enhanceProfiles, getProfiles, - //restartCore, getRuntimeLogs, importProfile, reorderProfile, @@ -121,7 +120,6 @@ interface ProfileSwitchRequest { force: boolean } -// 记录profile切换状态 const debugProfileSwitch = (action: string, profile: string, extra?: any) => { const timestamp = new Date().toISOString().substring(11, 23) debugLog(`[Profile-Debug][${timestamp}] ${action}: ${profile}`, extra || '') @@ -146,7 +144,6 @@ const ProfilePage = () => { Map >(() => new Map()) - // Batch selection states const [batchMode, setBatchMode] = useState(false) const [selectedProfiles, setSelectedProfiles] = useState>( () => new Set(), @@ -223,18 +220,14 @@ const ProfilePage = () => { } }, [addListener, mutateProfiles]) - // 添加紧急恢复功能 const onEmergencyRefresh = useLockFn(async () => { debugLog('[紧急刷新] 开始强制刷新所有数据') try { - // 只失效 profiles 相关 query,不影响 WS 订阅、IP 缓存等其他 query await revalidateQueries([['getProfiles'], ['getRuntimeLogs']]) - // 强制重新获取配置数据 await mutateProfiles() - // 等待状态稳定后增强配置 await new Promise((resolve) => setTimeout(resolve, 500)) await onEnhance(false) @@ -263,7 +256,6 @@ const ProfilePage = () => { const viewerRef = useRef(null) const configRef = useRef(null) - // distinguish type const profileItems = useMemo(() => { const items = profiles.items || [] @@ -278,7 +270,6 @@ const ProfilePage = () => { const onImport = async () => { if (!url) return - // 校验url是否为http/https if (!/^https?:\/\//i.test(url)) { showNotice.error('profiles.page.feedback.errors.invalidUrl') return @@ -291,7 +282,6 @@ const ProfilePage = () => { await performRobustRefresh() } try { - // 尝试正常导入 await importProfile(url) await handleImportSuccess('shared.feedback.notifications.importSuccess') } catch (initialErr) { @@ -305,7 +295,6 @@ const ProfilePage = () => { showNotice.info('profiles.page.feedback.notifications.importRetry') try { - // 使用自身代理尝试导入 await importProfile(url, { with_proxy: false, self_proxy: true, @@ -314,7 +303,6 @@ const ProfilePage = () => { 'shared.feedback.notifications.importWithClashProxy', ) } catch (retryErr) { - // 回退导入也失败 showNotice.error( 'profiles.page.feedback.notifications.importFail', retryErr, @@ -326,8 +314,7 @@ const ProfilePage = () => { } } - // 强化的刷新策略 - // maxRetries 设为 1:useProfiles 内部 useQuery 已配置 retry:3,业务层只需 1 次额外重试 + // `useProfiles` already retries three times; add only one business-level retry. const performRobustRefresh = async () => { let retryCount = 0 const maxRetries = 1 @@ -337,10 +324,8 @@ const ProfilePage = () => { try { debugLog(`[导入刷新] 第${retryCount + 1}次尝试刷新配置数据`) - // 强制刷新,绕过所有缓存 await mutateProfiles() - // 等待状态稳定 await new Promise((resolve) => setTimeout(resolve, baseDelay * (retryCount + 1)), ) @@ -356,10 +341,8 @@ const ProfilePage = () => { } } - // 所有重试失败后的最后尝试 console.warn(`[导入刷新] 常规刷新失败,尝试清除缓存重新获取`) try { - // 清除缓存并重新获取 await fetchCacheData(['getProfiles'], getProfiles) await onEnhance(false) showNotice.error( @@ -566,7 +549,6 @@ const ProfilePage = () => { } }) - // 更新所有订阅 const loadingCache = useLoadingCache() const setLoadingCache = useSetLoadingCache() const setLoadingProfiles = useCallback( @@ -643,7 +625,6 @@ const ProfilePage = () => { await Promise.allSettled(Array.from({ length: active }, worker)) } finally { setLoadingProfiles(uids, false) - // 避免长时间批量更新后列表数据过晚刷新 void mutateProfiles() } }, @@ -664,11 +645,9 @@ const ProfilePage = () => { if (text) setUrl(text) } - // Batch selection functions const toggleBatchMode = () => { setBatchMode(!batchMode) if (!batchMode) { - // Entering batch mode - clear previous selections setSelectedProfiles(new Set()) } } @@ -701,11 +680,11 @@ const ProfilePage = () => { const getSelectionState = () => { if (selectedProfiles.size === 0) { - return 'none' // 无选择 + return 'none' } else if (selectedProfiles.size === profileItems.length) { - return 'all' // 全选 + return 'all' } else { - return 'partial' // 部分选择 + return 'partial' } } @@ -713,7 +692,6 @@ const ProfilePage = () => { if (selectedProfiles.size === 0) return try { - // Get all currently activating profiles const currentActivating = profiles.current && selectedProfiles.has(profiles.current) ? [profiles.current] @@ -721,7 +699,6 @@ const ProfilePage = () => { setActivatings((prev) => [...new Set([...prev, ...currentActivating])]) - // Delete all selected profiles for (const uid of selectedProfiles) { await deleteProfile(uid) } @@ -729,12 +706,10 @@ const ProfilePage = () => { await mutateProfiles() await mutateLogs() - // If any deleted profile was current, enhance profiles if (currentActivating.length > 0) { await onEnhance(false) } - // Clear selections and exit batch mode setSelectedProfiles(new Set()) setBatchMode(false) @@ -835,7 +810,6 @@ const ProfilePage = () => { )} ) : ( - // Batch mode header { onSave={async (prev, curr) => { if (prev !== curr && profiles.current === item.uid) { await onEnhance(false) - // await restartCore(); - // Notice.success(t("settings.feedback.notifications.clash.restartSuccess"), 1000); } }} onDelete={() => { @@ -1053,7 +1025,6 @@ const ProfilePage = () => { ref={viewerRef} onChange={async (isActivating) => { mutateProfiles() - // 只有更改当前激活的配置时才触发全局重新加载 if (isActivating) { await onEnhance(false) } diff --git a/src/providers/app-data-provider.test.tsx b/src/providers/app-data-provider.test.tsx index a5a4f003b..3c04a14da 100644 --- a/src/providers/app-data-provider.test.tsx +++ b/src/providers/app-data-provider.test.tsx @@ -2,14 +2,7 @@ import { render, screen } from '@testing-library/react' import { afterEach, beforeEach, expect, test, vi } from 'vitest' -/** - * Stub the Tauri boundary and nothing above it. - * - * Everything the provider mounts — SWR queries, the config hooks, the event subscription, - * the mihomo plugin — goes through `invoke` or `listen`, so replacing just those two leaves - * all the real application code running. A test that stubbed the hooks instead could not - * catch what this one exists to catch. - */ +// Stub only the Tauri boundary so the real provider tree still renders. vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(async () => undefined), Channel: class {}, @@ -22,7 +15,6 @@ vi.mock('@tauri-apps/api/event', () => ({ import { AppDataProvider } from './app-data-provider' beforeEach(() => { - // The provider's queries poll; keep them from firing during the assertion. vi.useFakeTimers({ shouldAdvanceTime: true }) }) @@ -30,14 +22,7 @@ afterEach(() => { vi.useRealTimers() }) -/** - * Mounting the provider is the whole test. - * - * A hook inside `AppDataProvider` that reads a context `AppDataProvider` itself supplies - * throws during render and blanks the entire UI. That shipped once. `typecheck`, `lint`, - * `knip` and `web:build` all passed with it present, and so did an independent review — - * nothing but rendering can see it. - */ +// Rendering catches a provider reading its own context, which static checks cannot detect. test('AppDataProvider mounts and renders its children', () => { render( @@ -49,9 +34,6 @@ test('AppDataProvider mounts and renders its children', () => { }) test('AppDataProvider does not consume a context it provides', () => { - // The failure mode is specific enough to name: `useCtx` throws - // " must be used within AppDataProvider" when a provider reads its own context, - // which is indistinguishable at the type level from a legitimate call site. const errors: unknown[] = [] const consoleError = vi .spyOn(console, 'error') diff --git a/src/services/cmds.ts b/src/services/cmds.ts index e626632ec..9cb55b8f4 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -83,13 +83,11 @@ export async function getClashInfo() { return invoke('get_clash_info') } -// Fault-tolerant current proxy mode read (does not depend on mihomo /configs -// strict BaseConfig deserialization); used as a fallback for the home mode card. +// Fallback mode read independent of strict mihomo `/configs` deserialization. export async function getClashMode() { return invoke('get_clash_mode') } -// Get runtime config which controlled by verge export async function getRuntimeConfig() { return invoke('get_runtime_config') } @@ -126,17 +124,11 @@ export async function syncTrayProxySelection() { return invoke('sync_tray_proxy_selection') } -/** - * Record which node a group is on, in the current profile. - * - * Group and node, not the whole selection list: the merge happens in the backend against the - * profile as it stands, so two selections made in quick succession cannot overwrite each other. - */ +/** Sends one selection pair so the backend can merge against current profile state. */ export async function recordSelectedNode(groupName: string, node: string) { return invoke('record_selected_node', { groupName, node }) } -/** Forget the persisted node selection for one proxy group in the current profile. */ export async function forgetSelectedNode(groupName: string) { return invoke('forget_selected_node', { groupName }) } @@ -373,7 +365,6 @@ export async function listLocalBackup() { return invoke('list_local_backup') } -// 获取当前运行模式 export type RunningMode = 'Service' | 'Sidecar' | 'NotRunning' type ServiceHealth = @@ -389,12 +380,7 @@ type PendingServiceAction = | 'reinstall' | 'forceReinstall' -/** - * How the core is running and what backs it, as one consistent snapshot. - * - * The derived answers travel with it — `tunCapable`, `serviceUsable`, - * `serviceNeedsAttention` — so nothing here is recomputed from the raw fields. - */ +/** Consistent core/service snapshot with backend-derived availability flags. */ export interface RunState { mode: RunningMode service: ServiceHealth @@ -412,40 +398,31 @@ export const getRuntimeState = async () => { return invoke('get_runtime_state') } -/** Operation associated with a pending failure. */ export type FailedOperation = | 'systemProxyEnable' | 'systemProxyDisable' | 'systemProxyRestore' | 'systemProxyGuard' -/** Latest unresolved failure for one stable code. */ export interface PendingFailure { - /** Stable table key. */ code: string - /** Full diagnostic chain. */ detail: string operation: FailedOperation - /** Identity for repeated failures under the same code. */ sequence: number } -/** Read unresolved failures without consuming them. */ export const getPendingFailures = async () => { return invoke('get_pending_failures') } -// 获取应用运行时间 export const getAppUptime = async () => { return invoke('get_app_uptime') } -// 安装系统服务 export const installService = async () => { return invoke('install_service') } -// 卸载系统服务 export const uninstallService = async () => { return invoke('uninstall_service') } diff --git a/src/services/delay.test.ts b/src/services/delay.test.ts index d544ee871..81b3f7230 100644 --- a/src/services/delay.test.ts +++ b/src/services/delay.test.ts @@ -1,10 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -/** - * The delay API is the only thing stubbed. What is under test is when the manager announces - * that a group has settled — the granularity that decides whether a sorted list re-orders - * once per test or once per result. - */ +// Stub only the delay API; these tests cover group-settle notification granularity. vi.mock('tauri-plugin-mihomo-api', () => ({ delayProxyByName: vi.fn(async () => ({ delay: 120 })), healthcheckNodeInProvider: vi.fn(async () => ({ delay: 120 })), @@ -26,7 +22,6 @@ const node = (name: string) => }, }) as unknown as ResolvedProxyMember -/** The manager coalesces notifications onto a frame; give it one. */ const nextFrame = () => new Promise((resolve) => setTimeout(resolve, 0)) let settles = 0 @@ -45,8 +40,7 @@ afterEach(() => { describe('group settle notifications', () => { test('a single test settles the group once', async () => { - // The bug this fixes: a single-proxy retest updated that proxy's own display but never - // told the list to re-sort, so a group sorted by delay kept a stale order. + // A single-proxy retest must invalidate delay sorting. await delayManager.checkDelay(node('a') as never, 'g', 5000) await nextFrame() @@ -54,9 +48,7 @@ describe('group settle notifications', () => { }) test('a batch settles the group once, however many proxies it covered', async () => { - // The granularity that matters. Announcing per result would re-sort continuously for - // the length of the test, moving rows out from under the pointer — and the symptom - // would be jumpy scrolling, which nothing else here could assert. + // A batch announces once to avoid reordering rows as individual results arrive. const proxies = Array.from({ length: 20 }, (_, index) => node(`n${index}`)) await delayManager.checkListDelay(proxies as never, 'g', 5000, 4) @@ -75,8 +67,7 @@ describe('group settle notifications', () => { }) test('two listeners on one group both hear it', async () => { - // Group listeners used to be one-per-group, so a second view silently replaced the - // first one's subscription. + // Multiple views must not replace one another's group subscriptions. let second = 0 const stop = delayManager.addGroupListener('g', () => { second += 1 @@ -91,9 +82,7 @@ describe('group settle notifications', () => { }) test('a single test inside a running batch waits for the batch', async () => { - // The window the batch counter exists for: the per-row delay button and "test all" hold - // separate locks, so a user can start one inside the other. Announcing then would sort a - // half-measured group, which is the reordering this design is meant to prevent. + // Nested batches must wait for the outermost settle before announcing. const proxies = Array.from({ length: 6 }, (_, index) => node(`n${index}`)) const batch = delayManager.checkListDelay(proxies as never, 'g', 5000, 2) diff --git a/src/services/delay.ts b/src/services/delay.ts index 51213e972..6b188b8f6 100644 --- a/src/services/delay.ts +++ b/src/services/delay.ts @@ -13,7 +13,6 @@ import { import { debugLog } from '@/utils/debug' import { classifyDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay' -/** A group's delays, handed to sorting as a value it can depend on. */ export type DelaySnapshot = { of: (member: ResolvedProxyMember) => number } @@ -32,22 +31,16 @@ class DelayManager { private cache = new Map() private urlMap = new Map() - // 每个节点的监听 private listenerMap = new Map void>() - // 每个分组的监听 private groupListenerMap = new Map void>>() - /// A stable handle per group, replaced when that group settles. Consumers compare its - /// identity, so it must not be rebuilt on every read. + // Consumers compare snapshot identity; replace it only when the group settles. private groupSnapshots = new Map() - /// Keyed by the joined group names a consumer asked for; cleared whenever any group - /// settles, so the map identity changes while unaffected groups keep theirs. private groupSetSnapshots = new Map< string, ReadonlyMap >() - /// Batches in flight per group. A single test that lands inside one must not announce: - /// sorting from a half-measured group is the reordering this design exists to avoid. + // Suppress sort notifications until every measurement in a group batch settles. private activeBatches = new Map() private pendingItemUpdates = new Map() @@ -126,27 +119,14 @@ class DelayManager { private queueGroupNotification(group: string) { if ((this.activeBatches.get(group) ?? 0) > 0) return - // Dropped so the next read builds a fresh identity. Only this group's snapshot changes; - // the set-level map is rebuilt too, but its other entries keep their identities. + // Invalidate the set wrapper while retaining unaffected per-group identities. this.groupSnapshots.delete(group) this.groupSetSnapshots.clear() this.pendingGroupUpdates.add(group) this.scheduleGroupFlush() } - /** - * A handle to this group's delays whose identity changes only when a test settles. - * - * Read during render and compared by identity, so it is cached rather than rebuilt: a - * fresh object per read would make every consumer recompute on every render. - */ - /** - * The delays for a set of groups, keyed by group name. - * - * Cached so its identity is stable between settles, while each group's own snapshot keeps - * its identity unless *that* group settled — which is what lets a per-group cache survive - * a test in a neighbouring group. - */ + /** Cached set snapshot for `useSyncExternalStore` identity comparisons. */ groupsDelays(groupKey: string): ReadonlyMap { const cached = this.groupSetSnapshots.get(groupKey) if (cached) return cached @@ -159,6 +139,7 @@ class DelayManager { return snapshots } + /** Cached group snapshot whose identity changes only when that group settles. */ groupDelays(group: string): DelaySnapshot { const existing = this.groupSnapshots.get(group) if (existing) return existing @@ -180,7 +161,6 @@ class DelayManager { debugLog( `[DelayManager] 获取测试URL,组: ${group}, URL: ${url || '未设置'}`, ) - // 如果未设置URL,返回默认URL return url || 'http://cp.cloudflare.com/generate_204' } @@ -198,16 +178,7 @@ class DelayManager { this.listenerMap.delete(key) } - /** - * Called when a delay test for `group` has settled: once per single test, and once for a - * whole batch however many proxies it covered. - * - * "Settled" rather than "changed" on purpose. Per-proxy display updates are already live - * through `setListener`; this exists for the things that must not move while results are - * still arriving, chiefly sort order. - */ - /// Returns its own unsubscribe, so two views may watch the same group without one - /// silently replacing the other's listener. + /** Multiple views may subscribe independently; notifications occur only after settle. */ addGroupListener(group: string, listener: () => void): () => void { const listeners = this.groupListenerMap.get(group) ?? new Set() listeners.add(listener) @@ -278,13 +249,11 @@ class DelayManager { } if (details?.history && details.history.length > 0) { - // 0ms以error显示 return details.history[details.history.length - 1].delay || 1e6 } return -1 } - // 统一延迟测试检测 async unifiedDelayCheck( name: string, url: string, @@ -296,12 +265,7 @@ class DelayManager { return delayProxyByName(name, url, timeout) } - /** - * Test one proxy, then tell the group its ordering may have changed. - * - * The announcement is the point at which a sorted list is allowed to re-sort. It is - * deliberately *not* made per result inside a batch — see `checkListDelay`. - */ + /** A single test may notify immediately; a batch defers notification until it settles. */ async checkDelay( member: InteractableProxyMember, group: string, @@ -328,7 +292,6 @@ class DelayManager { `[DelayManager] 开始测试延迟,代理: ${name}, 组: ${group}, 超时: ${timeout}ms`, ) - // 先将状态设置为测试中 this.setDelay(name, group, -2) const startTime = Date.now() @@ -337,18 +300,15 @@ class DelayManager { const url = this.getUrl(group) debugLog(`[DelayManager] 调用API测试延迟,代理: ${name}, URL: ${url}`) - // 设置超时处理, delay = 0 为超时 const timeoutPromise = new Promise((resolve) => { setTimeout(() => resolve({ delay: 0 }), timeout) }) - // 使用Promise.race来实现超时控制 const result = await Promise.race([ this.unifiedDelayCheck(apiName, url, timeout, providerName), timeoutPromise, ]) - // 确保至少显示500ms的加载动画 const elapsedTime = Date.now() - startTime if (elapsedTime < 500) { await new Promise((resolve) => setTimeout(resolve, 500 - elapsedTime)) @@ -360,7 +320,6 @@ class DelayManager { return this.setDelay(name, group, delay, { elapsed }) } catch (error) { - // 确保至少显示500ms的加载动画 await new Promise((resolve) => setTimeout(resolve, 500)) console.error(`[DelayManager] 延迟测试出错,代理: ${name}`, error) const delay = 1e6 // error @@ -381,7 +340,6 @@ class DelayManager { ) const names = proxies.map((member) => member.ref.name) this.activeBatches.set(group, (this.activeBatches.get(group) ?? 0) + 1) - // 设置正在延迟测试中 names.forEach((name) => { this.setDelay(name, group, -2) }) @@ -395,34 +353,28 @@ class DelayManager { const currName = currMember.ref.name try { - // 确保API调用前状态为测试中 this.setDelay(currName, group, -2) - // 添加一些随机延迟,避免所有请求同时发出和返回 + // Stagger requests so a batch does not hit the core at once. if (index > 1) { - // 第一个不延迟,保持响应性 await new Promise((resolve) => setTimeout(resolve, Math.random() * 200), ) } - // Measured without announcing: a sorted list that re-ordered on every result would - // reshuffle continuously for the length of the test, with rows moving out from under - // the pointer. The group is told once, below, when the batch has settled. + // Do not reorder a list around the pointer while batch results are arriving. await this.measureDelay(currMember, group, timeout) } catch (error) { console.error( `[DelayManager] 批量测试单个代理出错,代理: ${currName}`, error, ) - // 设置为错误状态 this.setDelay(currName, group, 1e6) } return help() } - // 限制并发数,避免发送太多请求 const actualConcurrency = Math.min(concurrency, names.length, 10) debugLog(`[DelayManager] 实际并发数: ${actualConcurrency}`) @@ -434,8 +386,7 @@ class DelayManager { try { await Promise.all(promiseList) } finally { - // In a `finally` so a throw cannot leave the group unannounced: the proxies would sit - // at -2 and the order would stay stale with nothing later to repair it. + // Always release the batch and notify; otherwise failures leave stale sort state. const remaining = (this.activeBatches.get(group) ?? 1) - 1 if (remaining > 0) { this.activeBatches.set(group, remaining) @@ -474,8 +425,7 @@ class DelayManager { case 'error': return 'error.main' case 'measured': - // How a measurement is graded is this widget's own decision; the thresholds differ - // from the signal icon's on purpose, because a colour has fewer steps than four bars. + // Colour and signal bars intentionally use different grading thresholds. if (delay >= 400) return 'warning.main' if (delay >= 250) return 'primary.main' return 'success.main' diff --git a/src/services/events.ts b/src/services/events.ts index 2edf58500..6228d35ca 100644 --- a/src/services/events.ts +++ b/src/services/events.ts @@ -2,36 +2,20 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event' import type { RunState } from './cmds' -/** - * Every event pushed to the frontend, and what it carries. - * - * The names were string literals on both sides of the IPC seam, so renaming one was a silent - * no-op rather than a compile error. Declaring them here does not make the Rust side check - * itself, but it does mean the frontend has exactly one place to look and one payload type - * per name instead of a `listen` guess at each call site. - */ +/** Centralizes frontend event names and payload types for the unchecked Rust IPC seam. */ interface VergeEvents { - /** The core's own configuration changed. */ 'verge://refresh-clash-config': string - /** The app's configuration changed. */ 'verge://refresh-verge-config': string - /** The set of profiles changed. */ 'verge://refresh-profiles': string - /** The active profile's proxies changed. */ 'verge://refresh-proxy-config': null /** A backend message for the user: `[status, message]`. */ 'verge://notice-message': [string, string] - /** A profile's auto-update timer was rescheduled. */ 'verge://timer-updated': string - /** How the core is running changed; carries the whole snapshot. */ 'verge://run-state-changed': RunState - /** Pending failures changed; reread the table. */ 'verge://pending-failures-changed': null - /** Which profile is active changed. */ 'profile-changed': string 'profile-update-started': { uid?: string } 'profile-update-completed': { uid?: string } - /** Frontend to frontend: the home card asks the test page to run every test. */ 'verge://test-all': null } @@ -42,17 +26,8 @@ type VergeEventHandlers = { } /** - * Subscribe to backend events, returning the teardown. - * - * Registration is asynchronous, so a subscription can resolve after the caller has already - * torn down; that is tracked here rather than at each call site, where it was implemented - * four different ways and omitted in one of them. - * - * `onSubscribed` runs once every listener is live. Anything whose state arrives only by event - * needs it: between the first read and the listener being registered there is a gap, and a - * transition landing in it is not queued anywhere — it is simply missed, and the next event is - * whenever the backend happens to move again. Re-reading at that point closes the gap without - * going back to polling for it. + * Returns synchronous teardown for asynchronous registrations. `onSubscribed` lets callers + * reread event-only state after all listeners are live, closing the initial race window. */ export const subscribeVergeEvents = ( handlers: VergeEventHandlers, diff --git a/src/services/notice-service.ts b/src/services/notice-service.ts index 05bcb636d..aa9ccfd39 100644 --- a/src/services/notice-service.ts +++ b/src/services/notice-service.ts @@ -17,14 +17,11 @@ interface NoticeItem { readonly duration: number readonly message?: ReactNode readonly i18n?: NoticeTranslationDescriptor - /** Stable classified failure code. */ readonly code?: string - /** Requested operation when known. */ readonly operation?: FailedOperation timerId?: ReturnType } -/** Failures owned by the recovery dialog. */ const CODES_A_DIALOG_REPORTS = new Set([ 'SYSPROXY_PRIVILEGE_REQUIRED', 'SYSPROXY_SIDECAR_WHILE_SERVICE_READY', @@ -61,7 +58,6 @@ const DEFAULT_DURATIONS: Readonly> = { } const TRANSLATION_KEY_PATTERN = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+$/ -/** Localized explanation for each stable failure code. */ const CODED_ERROR_TRANSLATION_KEYS: Readonly> = { CLASH_CONFIG_UPDATE_FAILED: 'settings.feedback.errors.clash.configUpdateFailed', @@ -106,7 +102,6 @@ interface ParsedNoticeExtras { params?: Record raw?: unknown duration?: number - /** Code extracted before translation parameters are flattened. */ nestedCode?: string } @@ -166,7 +161,6 @@ function parseNoticeExtras(extras: NoticeExtra[]): ParsedNoticeExtras { } } -/** Replace structured failures in translation params with readable detail. */ function readableParams( source: Record, ): Record { @@ -178,7 +172,6 @@ function readableParams( ) } -/** Find a classified failure nested in translation parameters. */ function nestedFailureCode( source?: Record, ): string | undefined { @@ -243,10 +236,8 @@ function isPlainRecord(value: unknown): value is Record { return proto === Object.prototype || proto === null } -/** Maximum code points rendered in a notice. */ const MAX_RENDERED_TEXT_CHARS = 500 -/** Bound resolved display text by code point and append an ellipsis when truncated. */ export function boundNoticeText(text: string): string { let end = 0 let counted = 0 @@ -267,7 +258,6 @@ function createRawDescriptor(message: string): NoticeTranslationDescriptor { } } -/** Messages longer than any real translation key are treated as raw text. */ const MAX_TRANSLATION_KEY_CHARS = 200 function isLikelyTranslationKey(key: string) { @@ -309,13 +299,9 @@ function extractDisplayText(input: unknown): string | undefined { } } -/** What a failed Tauri command reports. */ export interface CommandFailure { - /** Stable identifier for the kind of failure, when the backend could classify it. */ code?: string - /** Requested operation when known by the command. */ operation?: FailedOperation - /** Human-readable diagnostic detail. */ detail: string } @@ -328,19 +314,16 @@ function isCommandFailure(input: unknown): input is CommandFailure { ) } -/** Extract display text from a structured or legacy failure. */ export function errorDetail(input: unknown): string { return extractDisplayText(input) ?? '' } -/** Parse a classified command failure for notice rendering. */ function parseCommandFailure(input: unknown) { if (!isCommandFailure(input) || !input.code) return undefined const explanation = CODED_ERROR_TRANSLATION_KEYS[input.code] return { translationKey: explanation ?? 'shared.feedback.errors.operationFailed', - /** Whether this code has a specific explanation. */ explained: explanation !== undefined, detail: input.detail.trim() || undefined, } @@ -513,16 +496,8 @@ function failureOperation(input: unknown): FailedOperation | undefined { } /** - * Shows a global notice; `showNotice.success / error / info` are the usual entry points. - * - * - `message`: i18n key string, `{ key, params }`, ReactNode, Error/any value (message is extracted) - * - `extras` parsed left-to-right: first plain object is i18n params; next value is raw payload; first number overrides duration (ms, 0 = persistent; defaults: success 3000 / info 5000 / error 8000) - * - Returns a notice id for manual closing via `hideNotice(id)` - * - * @example showNotice.success("profiles.page.feedback.notifications.batchDeleted"); - * @example showNotice.error(err); // pass an Error directly - * @example showNotice.error("shared.feedback.notifications.common.refreshFailed", err); // Simply pass an Error directly; but we recommend using { err } with i18n key and placeholders. - * @example showNotice.error("profiles.page.feedback.errors.invalidUrl", { url }, 4000); + * Shows a global notice. Extras are parsed as i18n params, raw payload, then duration; + * returns an ID accepted by `hideNotice`. */ export const showNotice: ShowNotice = Object.assign(baseShowNotice, { success: (message: NoticeContent, ...extras: NoticeExtra[]) => diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 5fe0b9a84..bbbf8db5d 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -11,9 +11,6 @@ type Platform = | 'cygwin' | 'netbsd' -/** - * defines in `vite.config.ts` - */ declare const OS_PLATFORM: Platform type ValidationOutcome = @@ -21,9 +18,6 @@ type ValidationOutcome = | { status: 'invalid'; kind: string; message: string } | { status: 'skipped'; reason: string } -/** - * Some interface for clash api - */ interface IConfigData { port: number mode: string @@ -213,12 +207,7 @@ interface IConnectionSetting { layout: 'table' | 'list' } -/** - * Some interface for command - */ - interface IClashInfo { - // status: string; mixed_port?: number // clash mixed port socks_port?: number // clash socks port redir_port?: number // clash redir port @@ -890,7 +879,6 @@ interface IVergeConfig { sysproxy_tray_icon?: boolean tun_tray_icon?: boolean enable_tray_speed?: boolean - // enable_tray_icon?: boolean; tray_proxy_groups_display_mode?: 'default' | 'inline' | 'disable' tray_inline_outbound_modes?: boolean enable_tun_mode?: boolean @@ -980,7 +968,6 @@ interface IWebDavConfig { password: string } -// Traffic monitor types interface ITrafficDataPoint { up: number down: number diff --git a/src/utils/debug.ts b/src/utils/debug.ts index 09fdfe15e..c84b820c2 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -47,10 +47,6 @@ const computeDebugEnabled = (): boolean => { const isDebugLoggingEnabled = () => cachedDebugEnabled ?? (cachedDebugEnabled = computeDebugEnabled()) -/** - * Logs to the console only when debug logging is enabled. - * Forwards all arguments to `console.log`; does nothing otherwise. - */ export const debugLog = (...args: any[]) => { if (!isDebugLoggingEnabled()) return console.log(...args) diff --git a/src/utils/delay.ts b/src/utils/delay.ts index 1c5e73a12..90dfb40fb 100644 --- a/src/utils/delay.ts +++ b/src/utils/delay.ts @@ -1,23 +1,9 @@ -/** - * What a delay number means, before anyone decides how to draw it. - * - * The core reports latency through a single number that encodes several non-measurements: - * sentinels for "never tested" and "testing now", zero and large values for timeouts, and - * absurd values for errors. Every surface agreed on those rules and restated them; they are - * stated once here. - * - * How a *measured* delay is graded — fast, slow, and the thresholds between — is deliberately - * not here. A three-colour chip and a four-bar signal icon draw different distinctions, and - * flattening them would be inventing a UI decision rather than removing a duplicate one. - */ +/** Normalizes latency values and the core's non-measurement sentinels. */ -/** The delay above which the core is considered to have timed out. */ export const DEFAULT_DELAY_TIMEOUT = 10000 -/** The value the core reports for a node whose test is in flight. */ const TESTING = -2 -/** Above this, the number is not a latency at all. */ const IMPLAUSIBLE_DELAY = 1e5 export type DelayState = @@ -25,7 +11,6 @@ export type DelayState = | 'untested' | 'error' | 'timeout' - /** An actual latency measurement, in milliseconds. */ | 'measured' export const classifyDelay = ( @@ -40,13 +25,7 @@ export const classifyDelay = ( return 'measured' } -/** - * The order delays sort in: measured first and fastest first, then timeouts, then errors, - * then everything we have no measurement for. - * - * Ranks are compared before values so that a non-measurement can never sort ahead of a real - * one just because its sentinel happens to be a small number. - */ +/** Rank separately so sentinel magnitudes cannot outrank real measurements. */ const rankOf = (state: DelayState): number => { switch (state) { case 'measured': @@ -62,7 +41,6 @@ const rankOf = (state: DelayState): number => { } } -/** A total order over delays, for sorting a proxy list fastest-first. */ export const compareByDelay = ( a: number, b: number, @@ -75,7 +53,6 @@ export const compareByDelay = ( const rankDifference = rankOf(aState) - rankOf(bState) if (rankDifference !== 0) return rankDifference - // Within a rank, only a real measurement has a meaningful magnitude. if (aState !== 'measured') return 0 return a - b }