diff --git a/.github/update.log b/.github/update.log index 5b86ece6f2..72c106bc60 100644 --- a/.github/update.log +++ b/.github/update.log @@ -1181,3 +1181,4 @@ Update On Mon Nov 10 19:41:15 CET 2025 Update On Tue Nov 11 19:39:14 CET 2025 Update On Wed Nov 12 19:37:25 CET 2025 Update On Thu Nov 13 19:41:20 CET 2025 +Update On Fri Nov 14 19:38:12 CET 2025 diff --git a/clash-nyanpasu/manifest/version.json b/clash-nyanpasu/manifest/version.json index ab9a20a2a3..7a1c1e1db7 100644 --- a/clash-nyanpasu/manifest/version.json +++ b/clash-nyanpasu/manifest/version.json @@ -2,7 +2,7 @@ "manifest_version": 1, "latest": { "mihomo": "v1.19.16", - "mihomo_alpha": "alpha-0b3159b", + "mihomo_alpha": "alpha-f6e494e", "clash_rs": "v0.9.2", "clash_premium": "2023-09-05-gdcc8d87", "clash_rs_alpha": "0.9.2-alpha+sha.87c7b2c" @@ -69,5 +69,5 @@ "linux-armv7hf": "clash-armv7-unknown-linux-gnueabihf" } }, - "updated_at": "2025-11-11T22:21:17.993Z" + "updated_at": "2025-11-13T22:21:28.265Z" } diff --git a/clash-verge-rev/.cargo/config.toml b/clash-verge-rev/.cargo/config.toml index beb596d41e..f894a1e97e 100644 --- a/clash-verge-rev/.cargo/config.toml +++ b/clash-verge-rev/.cargo/config.toml @@ -3,3 +3,7 @@ linker = "aarch64-linux-gnu-gcc" [target.armv7-unknown-linux-gnueabihf] linker = "arm-linux-gnueabihf-gcc" + +[alias] +clippy-all = "clippy --all-targets --all-features -- -D warnings" +clippy-only = "clippy --all-targets --features clippy -- -D warnings" diff --git a/clash-verge-rev/.github/ISSUE_TEMPLATE/bug_report.yml b/clash-verge-rev/.github/ISSUE_TEMPLATE/bug_report.yml index 2bc34225d1..d22312b0ed 100644 --- a/clash-verge-rev/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/clash-verge-rev/.github/ISSUE_TEMPLATE/bug_report.yml @@ -13,8 +13,8 @@ body: 1. 请 **确保** 您已经查阅了 [Clash Verge Rev 官方文档](https://clash-verge-rev.github.io/guide/term.html) 以及 [常见问题](https://clash-verge-rev.github.io/faq/windows.html) 2. 请 **确保** [已有的问题](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue) 中没有人提交过相似issue,否则请在已有的issue下进行讨论 3. 请 **务必** 给issue填写一个简洁明了的标题,以便他人快速检索 - 4. 请 **务必** 查看 [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) 版本更新日志 - 5. 请 **务必** 尝试 [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) 版本,确定问题是否仍然存在 + 4. 请 **务必** 查看 [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) 版本更新日志 + 5. 请 **务必** 尝试 [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) 版本,确定问题是否仍然存在 6. 请 **务必** 按照模板规范详细描述问题以及尝试更新 Alpha 版本,否则issue将会被直接关闭 ## Before submitting the issue, please make sure of the following checklist: @@ -22,8 +22,8 @@ body: 1. Please make sure you have read the [Clash Verge Rev official documentation](https://clash-verge-rev.github.io/guide/term.html) and [FAQ](https://clash-verge-rev.github.io/faq/windows.html) 2. Please make sure there is no similar issue in the [existing issues](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue), otherwise please discuss under the existing issue 3. Please be sure to fill in a concise and clear title for the issue so that others can quickly search - 4. Please be sure to check out [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) version update log - 5. Please be sure to try the [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) version to ensure that the problem still exists + 4. Please be sure to check out [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) version update log + 5. Please be sure to try the [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) version to ensure that the problem still exists 6. Please describe the problem in detail according to the template specification and try to update the Alpha version, otherwise the issue will be closed - type: textarea @@ -35,8 +35,8 @@ body: required: true - type: textarea attributes: - label: 软件版本 / Verge Version - description: 请提供Verge的具体版本,如果是alpha版本,请注明下载时间(精确到小时分钟) / Please provide the specific version of Verge. If it is an alpha version, please indicate the download time (accurate to hours and minutes) + label: 软件版本 / CVR Version + description: 请提供 CVR 的具体版本,如果是 AutoBuild 版本,请注明下载时间(精确到小时分钟) / Please provide the specific version of CVR. If it is an AutoBuild version, please indicate the download time (accurate to hours and minutes) render: text validations: required: true diff --git a/clash-verge-rev/.github/ISSUE_TEMPLATE/feature_request.yml b/clash-verge-rev/.github/ISSUE_TEMPLATE/feature_request.yml index da93d6fff3..aaa60d9b5d 100644 --- a/clash-verge-rev/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/clash-verge-rev/.github/ISSUE_TEMPLATE/feature_request.yml @@ -12,13 +12,13 @@ body: 1. 请 **确保** 您已经查阅了 [Clash Verge Rev 官方文档](https://clash-verge-rev.github.io/guide/term.html) 确认软件不存在类似的功能 2. 请 **确保** [已有的问题](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue) 中没有人提交过相似issue,否则请在已有的issue下进行讨论 3. 请 **务必** 给issue填写一个简洁明了的标题,以便他人快速检索 - 4. 请 **务必** 先下载 [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) 版本测试,确保该功能还未实现 + 4. 请 **务必** 先下载 [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) 版本测试,确保该功能还未实现 5. 请 **务必** 按照模板规范详细描述问题,否则issue将会被关闭 ## Before submitting the issue, please make sure of the following checklist: 1. Please make sure you have read the [Clash Verge Rev official documentation](https://clash-verge-rev.github.io/guide/term.html) to confirm that the software does not have similar functions 2. Please make sure there is no similar issue in the [existing issues](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue), otherwise please discuss under the existing issue 3. Please be sure to fill in a concise and clear title for the issue so that others can quickly search - 4. Please be sure to download the [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) version for testing to ensure that the function has not been implemented + 4. Please be sure to download the [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) version for testing to ensure that the function has not been implemented 5. Please describe the problem in detail according to the template specification, otherwise the issue will be closed - type: textarea diff --git a/clash-verge-rev/.github/ISSUE_TEMPLATE/i18n_request.yml b/clash-verge-rev/.github/ISSUE_TEMPLATE/i18n_request.yml index 451e8b62f5..c6b5e225b2 100644 --- a/clash-verge-rev/.github/ISSUE_TEMPLATE/i18n_request.yml +++ b/clash-verge-rev/.github/ISSUE_TEMPLATE/i18n_request.yml @@ -52,7 +52,7 @@ body: - type: input id: verge-version attributes: - label: 软件版本 / Verge Version - description: 请提供你使用的 Verge 具体版本 / Please provide the specific version of Verge you are using + label: 软件版本 / CVR Version + description: 请提供你使用的 CVR 具体版本 / Please provide the specific version of CVR you are using validations: required: true diff --git a/clash-verge-rev/.github/workflows/alpha.yml b/clash-verge-rev/.github/workflows/alpha.yml index 8f9836989c..a7221a3673 100644 --- a/clash-verge-rev/.github/workflows/alpha.yml +++ b/clash-verge-rev/.github/workflows/alpha.yml @@ -185,18 +185,18 @@ jobs: - name: Fetch UPDATE logs id: fetch_update_logs run: | - if [ -f "UPDATELOG.md" ]; then - UPDATE_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' UPDATELOG.md) + if [ -f "Changelog.md" ]; then + UPDATE_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' Changelog.md) if [ -n "$UPDATE_LOGS" ]; then echo "Found update logs" echo "UPDATE_LOGS<> $GITHUB_ENV echo "$UPDATE_LOGS" >> $GITHUB_ENV echo "EOF" >> $GITHUB_ENV else - echo "No update sections found in UPDATELOG.md" + echo "No update sections found in Changelog.md" fi else - echo "UPDATELOG.md file not found" + echo "Changelog.md file not found" fi shell: bash diff --git a/clash-verge-rev/.github/workflows/autobuild.yml b/clash-verge-rev/.github/workflows/autobuild.yml index f48788c5b5..0f978e8a3d 100644 --- a/clash-verge-rev/.github/workflows/autobuild.yml +++ b/clash-verge-rev/.github/workflows/autobuild.yml @@ -35,20 +35,7 @@ jobs: - name: Fetch UPDATE logs id: fetch_update_logs - run: | - if [ -f "UPDATELOG.md" ]; then - UPDATE_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' UPDATELOG.md) - if [ -n "$UPDATE_LOGS" ]; then - echo "Found update logs" - echo "UPDATE_LOGS<> $GITHUB_ENV - echo "$UPDATE_LOGS" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - else - echo "No update sections found in UPDATELOG.md" - fi - else - echo "UPDATELOG.md file not found" - fi + run: bash ./scripts/extract_update_logs.sh shell: bash - uses: pnpm/action-setup@v4 @@ -90,20 +77,20 @@ jobs: ### Windows (不再支持Win7) #### 正常版本(推荐) - - [64位(常用)](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_x64-setup.exe) | [ARM64(不常用)](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_arm64-setup.exe) + - [64位(常用)](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_x64-setup_windows.exe) | [ARM64(不常用)](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_arm64-setup_windows.exe) #### 内置Webview2版(体积较大,仅在企业版系统或无法安装webview2时使用) - [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_x64_fixed_webview2-setup.exe) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_arm64_fixed_webview2-setup.exe) ### macOS - - [Apple M芯片](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_aarch64.dmg) | [Intel芯片](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_x64.dmg) + - [Apple M芯片](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_aarch64_darwin.dmg) | [Intel芯片](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_x64_darwin.dmg) ### Linux #### DEB包(Debian系) 使用 apt ./路径 安装 - - [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_amd64.deb) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_arm64.deb) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_armhf.deb) + - [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_amd64_linux.deb) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_arm64.deb) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_armhf.deb) #### RPM包(Redhat系) 使用 dnf ./路径 安装 - - [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_amd64.rpm) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_aarch64.rpm) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_armhfp.rpm) + - [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge-${{ env.VERSION }}-1.x86_64_linux.rpm) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge-${{ env.VERSION }}-1.aarch64.rpm) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge-${{ env.VERSION }}-1.armhfp.rpm) ### FAQ - [常见问题](https://clash-verge-rev.github.io/faq/windows.html) @@ -158,7 +145,10 @@ jobs: uses: actions/checkout@v4 - name: Install Rust Stable - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.91.0" + targets: ${{ matrix.target }} - name: Add Rust Target run: rustup target add ${{ matrix.target }} @@ -169,7 +159,8 @@ jobs: workspaces: src-tauri cache-all-crates: true save-if: ${{ github.ref == 'refs/heads/dev' }} - shared-key: autobuild-shared + shared-key: autobuild-${{ runner.os }}-${{ matrix.target }} + key: ${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('src-tauri/Cargo.lock') }} - name: Install dependencies (ubuntu only) if: matrix.os == 'ubuntu-22.04' @@ -197,6 +188,14 @@ jobs: node-version: "22" cache: "pnpm" + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ~/.pnpm-store + key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + - name: Pnpm install and check run: | pnpm i @@ -205,6 +204,13 @@ jobs: - name: Release ${{ env.TAG_CHANNEL }} Version run: pnpm release-version autobuild-latest + - 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.91.0 || rustup target add ${{ matrix.target }} + rustup target list --installed + echo "Rust target ${{ matrix.target }} installed." + - name: Tauri build for Windows-macOS-Linux uses: tauri-apps/tauri-action@v0 env: @@ -248,7 +254,10 @@ jobs: uses: actions/checkout@v4 - name: Install Rust Stable - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.91.0" + targets: ${{ matrix.target }} - name: Add Rust Target run: rustup target add ${{ matrix.target }} @@ -259,7 +268,8 @@ jobs: workspaces: src-tauri cache-all-crates: true save-if: ${{ github.ref == 'refs/heads/dev' }} - shared-key: autobuild-shared + shared-key: autobuild-${{ runner.os }}-${{ matrix.target }} + key: ${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('src-tauri/Cargo.lock') }} - name: Install pnpm uses: pnpm/action-setup@v4 @@ -272,6 +282,14 @@ jobs: node-version: "22" cache: "pnpm" + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ~/.pnpm-store + key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + - name: Pnpm install and check run: | pnpm i @@ -329,6 +347,13 @@ jobs: gcc-arm-linux-gnueabihf \ g++-arm-linux-gnueabihf + - 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.91.0 || rustup target add ${{ matrix.target }} + rustup target list --installed + echo "Rust target ${{ matrix.target }} installed." + - name: Tauri Build for Linux run: | export PKG_CONFIG_ALLOW_CROSS=1 @@ -391,7 +416,8 @@ jobs: workspaces: src-tauri cache-all-crates: true save-if: ${{ github.ref == 'refs/heads/dev' }} - shared-key: autobuild-shared + shared-key: autobuild-${{ runner.os }}-${{ matrix.target }} + key: ${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('src-tauri/Cargo.lock') }} - name: Install pnpm uses: pnpm/action-setup@v4 @@ -404,6 +430,14 @@ jobs: node-version: "22" cache: "pnpm" + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ~/.pnpm-store + key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + - name: Pnpm install and check run: | pnpm i @@ -419,6 +453,13 @@ jobs: Remove-Item .\src-tauri\tauri.windows.conf.json Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json tauri.windows.conf.json + - 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.91.0 || rustup target add ${{ matrix.target }} + rustup target list --installed + echo "Rust target ${{ matrix.target }} installed." + - name: Tauri build for Windows id: build uses: tauri-apps/tauri-action@v0 @@ -482,20 +523,7 @@ jobs: - name: Fetch UPDATE logs id: fetch_update_logs - run: | - if [ -f "UPDATELOG.md" ]; then - UPDATE_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' UPDATELOG.md) - if [ -n "$UPDATE_LOGS" ]; then - echo "Found update logs" - echo "UPDATE_LOGS<> $GITHUB_ENV - echo "$UPDATE_LOGS" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - else - echo "No update sections found in UPDATELOG.md" - fi - else - echo "UPDATELOG.md file not found" - fi + run: bash ./scripts/extract_update_logs.sh shell: bash - name: Install Node @@ -538,20 +566,20 @@ jobs: ### Windows (不再支持Win7) #### 正常版本(推荐) - - [64位(常用)](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_x64-setup.exe) | [ARM64(不常用)](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_arm64-setup.exe) + - [64位(常用)](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_x64-setup_windows.exe) | [ARM64(不常用)](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_arm64-setup_windows.exe) #### 内置Webview2版(体积较大,仅在企业版系统或无法安装webview2时使用) - [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_x64_fixed_webview2-setup.exe) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_arm64_fixed_webview2-setup.exe) ### macOS - - [Apple M芯片](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_aarch64.dmg) | [Intel芯片](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_x64.dmg) + - [Apple M芯片](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_aarch64_darwin.dmg) | [Intel芯片](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_x64_darwin.dmg) ### Linux #### DEB包(Debian系) 使用 apt ./路径 安装 - - [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_amd64.deb) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_arm64.deb) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_armhf.deb) + - [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_amd64_linux.deb) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_arm64.deb) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_armhf.deb) #### RPM包(Redhat系) 使用 dnf ./路径 安装 - - [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_amd64.rpm) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_aarch64.rpm) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_armhfp.rpm) + - [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge-${{ env.VERSION }}-1.x86_64_linux.rpm) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge-${{ env.VERSION }}-1.aarch64.rpm) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge-${{ env.VERSION }}-1.armhfp.rpm) ### FAQ - [常见问题](https://clash-verge-rev.github.io/faq/windows.html) diff --git a/clash-verge-rev/.github/workflows/dev.yml b/clash-verge-rev/.github/workflows/dev.yml index 336c5e4227..e5ae26c327 100644 --- a/clash-verge-rev/.github/workflows/dev.yml +++ b/clash-verge-rev/.github/workflows/dev.yml @@ -74,11 +74,7 @@ jobs: - name: Install Rust Stable if: github.event.inputs[matrix.input] == 'true' - uses: dtolnay/rust-toolchain@stable - - - name: Add Rust Target - if: github.event.inputs[matrix.input] == 'true' - run: rustup target add ${{ matrix.target }} + uses: dtolnay/rust-toolchain@1.91.0 - name: Rust Cache if: github.event.inputs[matrix.input] == 'true' @@ -118,6 +114,14 @@ jobs: if: github.event.inputs[matrix.input] == 'true' run: pnpm release-version ${{ env.TAG_NAME }} + - name: Add Rust Target + if: github.event.inputs[matrix.input] == 'true' + run: | + # Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed + rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }} + rustup target list --installed + echo "Rust target ${{ matrix.target }} installed." + - name: Tauri build if: github.event.inputs[matrix.input] == 'true' uses: tauri-apps/tauri-action@v0 diff --git a/clash-verge-rev/.github/workflows/lint-clippy.yml b/clash-verge-rev/.github/workflows/lint-clippy.yml index e987a37efd..d7f3416ba7 100644 --- a/clash-verge-rev/.github/workflows/lint-clippy.yml +++ b/clash-verge-rev/.github/workflows/lint-clippy.yml @@ -22,6 +22,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Check src-tauri changes + if: github.event_name != 'workflow_dispatch' id: check_changes uses: dorny/paths-filter@v3 with: @@ -30,13 +31,18 @@ jobs: - 'src-tauri/**' - name: Skip if src-tauri not changed - if: steps.check_changes.outputs.rust != 'true' + if: github.event_name != 'workflow_dispatch' && steps.check_changes.outputs.rust != 'true' run: echo "No src-tauri changes, skipping clippy lint." - name: Continue if src-tauri changed - if: steps.check_changes.outputs.rust == 'true' + if: github.event_name != 'workflow_dispatch' && steps.check_changes.outputs.rust == 'true' run: echo "src-tauri changed, running clippy lint." + - name: Manual trigger - always run + if: github.event_name == 'workflow_dispatch' + run: | + echo "Manual trigger detected: skipping changes check and running clippy." + - name: Checkout Repository uses: actions/checkout@v4 @@ -53,9 +59,10 @@ jobs: uses: Swatinem/rust-cache@v2 with: workspaces: src-tauri + cache-all-crates: true save-if: false - cache-all-crates: false - shared-key: autobuild-shared + shared-key: autobuild-${{ runner.os }}-${{ matrix.target }} + key: ${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('src-tauri/Cargo.lock') }} - name: Install dependencies (ubuntu only) if: matrix.os == 'ubuntu-22.04' @@ -63,29 +70,13 @@ jobs: sudo apt-get update sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - - name: Install Node - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: "pnpm" - - - name: Pnpm install and check - run: | - pnpm i - pnpm run prebuild ${{ matrix.target }} - - # This workflow runs linting using cargo clippy. - # Note: If the web build step is skipped, - # cargo clippy will fail to run due to missing web dist in the Tauri environment. - - name: Build Web Assets - run: pnpm run web:build - env: - NODE_OPTIONS: "--max_old_space_size=4096" - - name: Run Clippy - run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --all-features -- -D warnings + working-directory: ./src-tauri + run: cargo clippy-all + + - name: Run Logging Check + working-directory: ./src-tauri + shell: bash + run: | + cargo install --git https://github.com/clash-verge-rev/clash-verge-logging-check.git + clash-verge-logging-check diff --git a/clash-verge-rev/.github/workflows/release.yml b/clash-verge-rev/.github/workflows/release.yml index 7f74de0d54..153a5f8c95 100644 --- a/clash-verge-rev/.github/workflows/release.yml +++ b/clash-verge-rev/.github/workflows/release.yml @@ -5,6 +5,7 @@ on: # ! 不再使用 workflow_dispatch 触发。 # workflow_dispatch: push: + # -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新 tags: - "v*.*.*" permissions: write-all @@ -72,20 +73,7 @@ jobs: - name: Fetch UPDATE logs id: fetch_update_logs - run: | - if [ -f "UPDATELOG.md" ]; then - UPDATE_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' UPDATELOG.md) - if [ -n "$UPDATE_LOGS" ]; then - echo "Found update logs" - echo "UPDATE_LOGS<> $GITHUB_ENV - echo "$UPDATE_LOGS" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - else - echo "No update sections found in UPDATELOG.md" - fi - else - echo "UPDATELOG.md file not found" - fi + run: bash ./scripts/extract_update_logs.sh shell: bash - name: Set Env @@ -143,7 +131,7 @@ jobs: name: "Clash Verge Rev ${{ env.TAG_NAME }}" body_path: release.txt draft: false - prerelease: false + prerelease: ${{ contains(github.ref_name, '-rc') }} token: ${{ secrets.GITHUB_TOKEN }} # generate_release_notes: true @@ -213,7 +201,8 @@ jobs: pnpm run prebuild ${{ matrix.target }} - name: Tauri build - uses: tauri-apps/tauri-action@v0 + # 上游 5.24 修改了 latest.json 的生成逻辑,且依赖 tauri-plugin-update 2.10.0 暂未发布,故锁定在 0.5.23 版本 + uses: tauri-apps/tauri-action@v0.5.23 env: NODE_OPTIONS: "--max_old_space_size=4096" GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -230,7 +219,7 @@ jobs: releaseName: "Clash Verge Rev ${{ github.ref_name }}" releaseBody: "Draft release, will be updated later." releaseDraft: true - prerelease: false + prerelease: ${{ contains(github.ref_name, '-rc') }} tauriScript: pnpm args: --target ${{ matrix.target }} includeUpdaterJson: true @@ -354,6 +343,7 @@ jobs: name: "Clash Verge Rev v${{env.VERSION}}" body: "See release notes for detailed changelog." token: ${{ secrets.GITHUB_TOKEN }} + prerelease: ${{ contains(github.ref_name, '-rc') }} files: | src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm @@ -409,7 +399,8 @@ jobs: - name: Tauri build id: build - uses: tauri-apps/tauri-action@v0 + # 上游 5.24 修改了 latest.json 的生成逻辑,且依赖 tauri-plugin-update 2.10.0 暂未发布,故锁定在 0.5.23 版本 + uses: tauri-apps/tauri-action@v0.5.23 env: NODE_OPTIONS: "--max_old_space_size=4096" GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -446,6 +437,7 @@ jobs: name: "Clash Verge Rev v${{steps.build.outputs.appVersion}}" body: "See release notes for detailed changelog." token: ${{ secrets.GITHUB_TOKEN }} + prerelease: ${{ contains(github.ref_name, '-rc') }} files: src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*setup* - name: Portable Bundle @@ -454,6 +446,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} release-update: + if: ${{ !contains(github.ref_name, '-rc') }} name: Release Update runs-on: ubuntu-latest needs: [update_tag] @@ -480,6 +473,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} release-update-for-fixed-webview2: + if: ${{ !contains(github.ref_name, '-rc') }} runs-on: ubuntu-latest needs: [update_tag] steps: @@ -505,6 +499,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} submit-to-winget: + if: ${{ !contains(github.ref_name, '-rc') }} name: Submit to Winget runs-on: ubuntu-latest needs: [update_tag, release-update] @@ -528,6 +523,7 @@ jobs: token: ${{ secrets.WINGET_TOKEN }} notify-telegram: + if: ${{ !contains(github.ref_name, '-rc') }} name: Notify Telegram runs-on: ubuntu-latest needs: @@ -543,20 +539,7 @@ jobs: - name: Fetch UPDATE logs id: fetch_update_logs - run: | - if [ -f "UPDATELOG.md" ]; then - UPDATE_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' UPDATELOG.md) - if [ -n "$UPDATE_LOGS" ]; then - echo "Found update logs" - echo "UPDATE_LOGS<> $GITHUB_ENV - echo "$UPDATE_LOGS" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - else - echo "No update sections found in UPDATELOG.md" - fi - else - echo "UPDATELOG.md file not found" - fi + run: bash ./scripts/extract_update_logs.sh shell: bash - name: Install Node diff --git a/clash-verge-rev/.gitignore b/clash-verge-rev/.gitignore index 9658768939..9983a23470 100644 --- a/clash-verge-rev/.gitignore +++ b/clash-verge-rev/.gitignore @@ -11,3 +11,4 @@ scripts/_env.sh .idea .old .eslintcache +target \ No newline at end of file diff --git a/clash-verge-rev/.husky/pre-commit b/clash-verge-rev/.husky/pre-commit index 4e2adc2774..4c21a04538 100755 --- a/clash-verge-rev/.husky/pre-commit +++ b/clash-verge-rev/.husky/pre-commit @@ -1,24 +1,57 @@ #!/bin/bash set -euo pipefail -echo "[pre-commit] Running lint-staged for JS/TS files..." -# Auto-fix staged JS/TS files, print warnings but don't fail commit -npx lint-staged || true +ROOT_DIR="$(git rev-parse --show-toplevel)" +cd "$ROOT_DIR" -# Check staged Rust files -RUST_FILES=$(git diff --cached --name-only | grep -E '^src-tauri/.*\.rs$' || true) -if [ -n "$RUST_FILES" ]; then - echo "[pre-commit] Running rustfmt and clippy on staged Rust files..." - cd src-tauri || exit - - # Auto-format Rust code - cargo fmt - - # Lint with clippy, print warnings but don't fail commit - cargo clippy || echo "⚠️ clippy found issues, but commit will continue." - - cd .. +if ! command -v pnpm >/dev/null 2>&1; then + echo "❌ pnpm is required for pre-commit checks." + exit 1 fi -echo "[pre-commit] Checks completed. Some warnings may exist, please review." -exit 0 +LOCALE_DIFF="$(git diff --cached --name-only --diff-filter=ACMR | grep -E '^src/locales/' || true)" +if [ -n "$LOCALE_DIFF" ]; then + echo "[pre-commit] Locale changes detected. Regenerating i18n types..." + pnpm i18n:types + if [ -d src/types/generated ]; then + echo "[pre-commit] Staging regenerated i18n type artifacts..." + git add src/types/generated + fi +fi + +echo "[pre-commit] Running pnpm format before lint..." +pnpm format + +echo "[pre-commit] Running lint-staged for JS/TS files..." +pnpm exec lint-staged + +RUST_FILES="$(git diff --cached --name-only --diff-filter=ACMR | grep -E '^src-tauri/.*\.rs$' || true)" +if [ -n "$RUST_FILES" ]; then + echo "[pre-commit] Formatting Rust changes with cargo fmt..." + ( + cd src-tauri + cargo fmt + ) + while IFS= read -r file; do + [ -n "$file" ] && git add "$file" + done <<< "$RUST_FILES" + + echo "[pre-commit] Linting Rust changes with cargo clippy..." + ( + cd src-tauri + cargo clippy-all + if ! command -v clash-verge-logging-check >/dev/null 2>&1; then + echo "[pre-commit] Installing clash-verge-logging-check..." + cargo install --git https://github.com/clash-verge-rev/clash-verge-logging-check.git + fi + clash-verge-logging-check + ) +fi + +TS_FILES="$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.(ts|tsx)$' || true)" +if [ -n "$TS_FILES" ]; then + echo "[pre-commit] Running TypeScript type check..." + pnpm typecheck +fi + +echo "[pre-commit] All checks completed successfully." diff --git a/clash-verge-rev/.husky/pre-push b/clash-verge-rev/.husky/pre-push index 0cc5195d66..43ffed41fe 100644 --- a/clash-verge-rev/.husky/pre-push +++ b/clash-verge-rev/.husky/pre-push @@ -1,32 +1,42 @@ #!/bin/bash set -euo pipefail -remote_name="$1" +remote_name="${1:-origin}" +remote_url="${2:-unknown}" -# --- Rust clippy for staged files in src-tauri --- -if git diff --cached --name-only | grep -q '^src-tauri/'; then - echo "[pre-push] Running clippy on src-tauri..." - cargo clippy --manifest-path ./src-tauri/Cargo.toml -- -D warnings || { - echo "❌ Clippy found issues in src-tauri. Please fix them before pushing." - exit 1 - } +ROOT_DIR="$(git rev-parse --show-toplevel)" +cd "$ROOT_DIR" + +if ! command -v pnpm >/dev/null 2>&1; then + echo "❌ pnpm is required for pre-push checks." + exit 1 fi -# --- JS/TS format check only for main repo --- -if git remote get-url "$remote_name" >/dev/null 2>&1; then - remote_url=$(git remote get-url "$remote_name") - if [[ "$remote_url" =~ github\.com[:/]+clash-verge-rev/clash-verge-rev(\.git)?$ ]]; then - echo "[pre-push] Detected push to clash-verge-rev/clash-verge-rev ($remote_url)" - echo "[pre-push] Running pnpm format:check..." - if ! pnpm format:check; then - echo "❌ Code format check failed. Please fix formatting before pushing." - exit 1 - fi - else - echo "[pre-push] Not pushing to target repo. Skipping format check." - fi +echo "[pre-push] Preparing to push to '$remote_name' ($remote_url). Running full validation..." + +echo "[pre-push] Checking Prettier formatting..." +pnpm format:check + +echo "[pre-push] Running ESLint..." +pnpm lint + +echo "[pre-push] Running TypeScript type checking..." +pnpm typecheck + +if command -v cargo >/dev/null 2>&1; then + echo "[pre-push] Verifying Rust formatting..." + ( + cd src-tauri + cargo fmt --check + ) + + echo "[pre-push] Running cargo clippy..." + ( + cd src-tauri + cargo clippy-all + ) else - echo "[pre-push] Remote '$remote_name' does not exist. Skipping format check." + echo "[pre-push] ⚠️ cargo not found; skipping Rust checks." fi echo "[pre-push] All checks passed." diff --git a/clash-verge-rev/.prettierignore b/clash-verge-rev/.prettierignore index 0a3b69148a..bc817cd59d 100644 --- a/clash-verge-rev/.prettierignore +++ b/clash-verge-rev/.prettierignore @@ -1,5 +1,5 @@ # README.md -# UPDATELOG.md +# Changelog.md # CONTRIBUTING.md pnpm-lock.yaml diff --git a/clash-verge-rev/CONTRIBUTING.md b/clash-verge-rev/CONTRIBUTING.md index 3b55dd9135..e23cd352fd 100644 --- a/clash-verge-rev/CONTRIBUTING.md +++ b/clash-verge-rev/CONTRIBUTING.md @@ -2,6 +2,10 @@ Thank you for your interest in contributing to Clash Verge Rev! This document provides guidelines and instructions to help you set up your development environment and start contributing. +## Internationalization (i18n) + +We welcome translations and improvements to existing locales. Please follow the detailed guidelines in [CONTRIBUTING_i18n.md](docs/CONTRIBUTING_i18n.md) for instructions on extracting strings, file naming conventions, testing translations, and submitting translation PRs. + ## Development Setup Before you start contributing to the project, you need to set up your development environment. Here are the steps you need to follow: diff --git a/clash-verge-rev/Changelog.md b/clash-verge-rev/Changelog.md new file mode 100644 index 0000000000..264ac63038 --- /dev/null +++ b/clash-verge-rev/Changelog.md @@ -0,0 +1,250 @@ +## v2.4.4 + +### 🐞 修复问题 + +- Linux 无法切换 TUN 堆栈 +- macOS service 启动项显示名称(试验性修改) + +
+ ✨ 新增功能 + +- **Mihomo(Meta) 内核升级至 v1.19.16** +- 支持连接页面各个项目的排序 +- 实现可选的自动备份 +- 连接页面支持查看已关闭的连接(最近最多 500 个已关闭连接) + +
+ +
+ 🚀 优化改进 + +- 替换前端信息编辑组件,提供更好性能 +- 优化后端内存和性能表现 +- 防止退出时可能的禁用 TUN 失败 +- i18n 支持 +- 优化备份设置布局 +- 优化流量图性能表现,实现动态 FPS 和窗口失焦自动暂停 + +
+ +## v2.4.3 + +**发行代号:澜** +代号释义:澜象征平稳与融合,本次版本聚焦稳定性、兼容性、性能与体验优化,全面提升整体可靠性。 + +特别感谢 @Slinetrac, @oomeow, @Lythrilla, @Dragon1573 的出色贡献 + +### 🐞 修复问题 + +- 优化服务模式重装逻辑,避免不必要的重复检查 +- 修复轻量模式退出无响应的问题 +- 修复托盘轻量模式支持退出/进入 +- 修复静默启动和自动进入轻量模式时,托盘状态刷新不再依赖窗口创建流程 +- macOS Tun/系统代理 模式下图标大小不统一 +- 托盘节点切换不再显示隐藏组 +- 修复前端 IP 检测无法使用 ipapi, ipsb 提供商 +- 修复MacOS 下 Tun开启后 系统代理无法打开的问题 +- 修复服务模式启动时,修改、生成配置文件或重启内核可能导致页面卡死的问题 +- 修复 Webdav 恢复备份不重启 +- 修复 Linux 开机后无法正常代理需要手动设置 +- 修复增加订阅或导入订阅文件时订阅页面无更新 +- 修复系统代理守卫功能不工作 +- 修复 KDE + Wayland 下多屏显示 UI 异常 +- 修复 Windows 深色模式下首次启动客户端标题栏颜色异常 +- 修复静默启动不加载完整 WebView 的问题 +- 修复 Linux WebKit 网络进程的崩溃 +- 修复无法导入订阅 +- 修复实际导入成功但显示导入失败的问题 +- 修复服务不可用时,自动关闭 Tun 模式导致应用卡死问题 +- 修复删除订阅时未能实际删除相关文件 +- 修复 macOS 连接界面显示异常 +- 修复规则配置项在不同配置文件间全局共享导致切换被重置的问题 +- 修复 Linux Wayland 下部分 GPU 可能出现的 UI 渲染问题 +- 修复自动更新使版本回退的问题 +- 修复首页自定义卡片在切换轻量模式时失效 +- 修复悬浮跳转导航失效 +- 修复小键盘热键映射错误 +- 修复前端无法及时刷新操作状态 +- 修复 macOS 从 Dock 栏退出轻量模式状态不同步 +- 修复 Linux 系统主题切换不生效 +- 修复 `允许自动更新` 字段使手动订阅刷新失效 +- 修复轻量模式托盘状态不同步 +- 修复一键导入订阅导致应用卡死崩溃的问题 + +
+ ✨ 新增功能 + +- **Mihomo(Meta) 内核升级至 v1.19.15** +- 支持前端修改日志(最大文件大小、最大保留数量) +- 新增链式代理图形化设置功能 +- 新增系统标题栏与程序标题栏切换 (设置-页面设置-倾向系统标题栏) +- 监听关机事件,自动关闭系统代理 +- 主界面“当前节点”卡片新增“延迟测试”按钮 +- 新增批量选择配置文件功能 +- Windows / Linux / MacOS 监听关机信号,优雅恢复网络设置 +- 新增本地备份功能 +- 主界面“当前节点”卡片新增自动延迟检测开关(默认关闭) +- 允许独立控制订阅自动更新 +- 托盘 `更多` 中新增 `关闭所有连接` 按钮 +- 新增左侧菜单栏的排序功能(右键点击左侧菜单栏) +- 托盘 `打开目录` 中新增 `应用日志` 和 `内核日志` +
+ +
+ 🚀 优化改进 + +- 重构并简化服务模式启动检测流程,消除重复检测 +- 重构并简化窗口创建流程 +- 重构日志系统,单个日志默认最大 10 MB +- 优化前端资源占用 +- 改进 macos 下系统代理设置的方法 +- 优化 TUN 模式可用性的判断 +- 移除流媒体检测的系统级提示(使用软件内通知) +- 优化后端 i18n 资源占用 +- 改进 Linux 托盘支持并添加 `--no-tray` 选项 +- Linux 现在在新生成的配置中默认将 TUN 栈恢复为 mixed 模式 +- 为代理延迟测试的 URL 设置增加了保护以及添加了安全的备用 URL +- 更新了 Wayland 合成器检测逻辑,从而在 Hyprland 会话中保留原生 Wayland 后端 +- 改进 Windows 和 Unix 的 服务连接方式以及权限,避免无法连接服务或内核 +- 修改内核默认日志级别为 Info +- 支持通过桌面快捷方式重新打开应用 +- 支持订阅界面输入链接后回车导入 +- 选择按延迟排序时每次延迟测试自动刷新节点顺序 +- 配置重载失败时自动重启核心 +- 启用 TUN 前等待服务就绪 +- 卸载 TUN 时会先关闭 +- 优化应用启动页 +- 优化首页当前节点对MATCH规则的支持 +- 允许在 `界面设置` 修改 `悬浮跳转导航延迟` +- 添加热键绑定错误的提示信息 +- 在 macOS 10.15 及更高版本默认包含 Mihomo-go122,以解决 Intel 架构 Mac 无法运行内核的问题 +- Tun 模式不可用时,禁用系统托盘的 Tun 模式菜单 +- 改进订阅更新方式,仍失败需打开订阅设置 `允许危险证书` +- 允许设置 Mihomo 端口范围 1000(含) - 65536(含) + +
+ +## v2.4.2 + +### ✨ 新增功能 + +- 增加托盘节点选择 + +### 🚀 性能优化 + +- 优化前端首页加载速度 +- 优化前端未使用 i18n 文件缓存 +- 优化后端内存占用 +- 优化后端启动速度 + +### 🐞 修复问题 + +- 修复首页节点切换失效的问题 +- 修复和优化服务检查流程 +- 修复2.4.1引入的订阅地址重定向报错问题 +- 修复 rpm/deb 包名称问题 +- 修复托盘轻量模式状态检测异常 +- 修复通过 scheme 导入订阅崩溃 +- 修复单例检测实效 +- 修复启动阶段可能导致的无法连接内核 +- 修复导入订阅无法 Auth Basic + +### 👙 界面样式 + +- 简化和改进代理设置样式 + +## v2.4.1 + +### 🏆 重大改进 + +- **应用响应速度提升**:采用全新异步处理架构,大幅提升应用响应速度和稳定性 + +### ✨ 新增功能 + +- **Mihomo(Meta) 内核升级至 v1.19.13** + +### 🚀 性能优化 + +- 优化热键响应速度,提升快捷键操作体验 +- 改进服务管理响应性,减少系统服务操作等待时间 +- 提升文件和配置处理性能 +- 优化任务管理和日志记录效率 +- 优化异步内存管理,减少内存占用并提升多任务处理效率 +- 优化启动阶段初始化性能 + +### 🐞 修复问题 + +- 修复应用在某些操作中可能出现的响应延迟问题 +- 修复任务管理中的潜在并发问题 +- 修复通过托盘重启应用无法恢复 +- 修复订阅在某些情况下无法导入 +- 修复无法新建订阅时使用远程链接 +- 修复卸载服务后的 tun 开关状态问题 +- 修复页面快速切换订阅时导致崩溃 +- 修复丢失工作目录时无法恢复环境 +- 修复从轻量模式恢复导致崩溃 + +### 👙 界面样式 + +- 统一代理设置样式 + +### 🗑️ 移除内容 + +- 移除启动阶段自动清理过期订阅 + +## v2.4.0 + +**发行代号:融** +代号释义: 「融」象征融合与贯通,寓意新版本通过全新 IPC 通信机制 将系统各部分紧密衔接,打破壁垒,实现更高效的 数据流通与全面性能优化。 + +### 🏆 重大改进 + +- **核心通信架构升级**:采用全新通信机制,提升应用性能和稳定性 +- **流量监控系统重构**:全新的流量监控界面,支持更丰富的数据展示 +- **数据缓存优化**:改进配置和节点数据缓存,提升响应速度 + +### ✨ 新增功能 + +- **Mihomo(Meta) 内核升级至 v1.19.12** +- 新增版本信息复制按钮 +- 增强型流量监控,支持更详细的数据分析 +- 新增流量图表多种显示模式 +- 新增强制刷新配置和节点缓存功能 +- 首页流量统计支持查看刻度线详情 + +### 🚀 性能优化 + +- 全面提升数据传输和处理效率 +- 优化内存使用,减少系统资源消耗 +- 改进流量图表渲染性能 +- 优化配置和节点刷新策略,从5秒延长到60秒 +- 改进数据缓存机制,减少重复请求 +- 优化异步程序性能 + +### 🐞 修复问题 + +- 修复系统代理状态检测和显示不一致问题 +- 修复系统主题窗口颜色不一致问题 +- 修复特殊字符 URL 处理问题 +- 修复配置修改后缓存不同步问题 +- 修复 Windows 安装器自启设置问题 +- 修复 macOS 下 Dock 图标恢复窗口问题 +- 修复 linux 下 KDE/Plasma 异常标题栏按钮 +- 修复架构升级后节点测速功能异常 +- 修复架构升级后流量统计功能异常 +- 修复架构升级后日志功能异常 +- 修复外部控制器跨域配置保存问题 +- 修复首页端口显示不一致问题 +- 修复首页流量统计刻度线显示问题 +- 修复日志页面按钮功能混淆问题 +- 修复日志等级设置保存问题 +- 修复日志等级异常过滤 +- 修复清理日志天数功能异常 +- 修复偶发性启动卡死问题 +- 修复首页虚拟网卡开关在管理模式下的状态问题 + +### 🔧 技术改进 + +- 统一使用新的内核通信方式 +- 新增外部控制器配置界面 +- 改进跨平台兼容性支持 diff --git a/clash-verge-rev/README.md b/clash-verge-rev/README.md index 2db0ab1fa3..b7e70f7dfe 100644 --- a/clash-verge-rev/README.md +++ b/clash-verge-rev/README.md @@ -9,6 +9,16 @@ A Clash Meta GUI based on Tauri. +

+ Languages: + 简体中文 · + English · + Español · + Русский · + 日本語 · + 한국어 +

+ ## Preview | Dark | Light | diff --git a/clash-verge-rev/crowdin.yml b/clash-verge-rev/crowdin.yml deleted file mode 100644 index 6063f529c3..0000000000 --- a/clash-verge-rev/crowdin.yml +++ /dev/null @@ -1,4 +0,0 @@ -files: - - source: /src/locales/en.json - translation: /src/locales - multilingual: 1 diff --git a/clash-verge-rev/docs/CONTRIBUTING_i18n.md b/clash-verge-rev/docs/CONTRIBUTING_i18n.md new file mode 100644 index 0000000000..2d9e6674f1 --- /dev/null +++ b/clash-verge-rev/docs/CONTRIBUTING_i18n.md @@ -0,0 +1,79 @@ +# CONTRIBUTING — i18n + +Thanks for helping localize Clash Verge Rev. This guide reflects the current architecture, where the React frontend and the Tauri backend keep their translation bundles separate. Follow the steps below to keep both sides in sync without stepping on each other. + +## Quick workflow + +- Update the language folder under `src/locales//`; use `src/locales/en/` as the canonical reference for keys and intent. +- Run `pnpm format:i18n` to align structure and `pnpm i18n:types` to refresh generated typings. +- If you touch backend copy, edit the matching YAML file in `src-tauri/locales/.yml`. +- Preview UI changes with `pnpm dev` (desktop shell) or `pnpm web:dev` (web only). +- Keep PRs focused and add screenshots whenever layout could be affected by text length. + +## Frontend locale structure + +Each locale folder mirrors the namespaces under `src/locales/en/`: + +``` +src/locales/ + en/ + connections.json + home.json + shared.json + ... + index.ts + zh/ + ... +``` + +- JSON files map to namespaces (for example `home.json` → `home.*`). Keep keys scoped to the file they belong to. +- `shared.json` stores reusable vocabulary (buttons, validations, etc.); feature-specific wording should live in the relevant namespace. +- `index.ts` re-exports a `resources` object that aggregates the namespace JSON files. When adding or removing namespaces, mirror the pattern from `src/locales/en/index.ts`. +- Frontend bundles are lazy-loaded by `src/services/i18n.ts`. Only languages listed in `supportedLanguages` are fetched at runtime, so append new codes there when you add a locale. + +Because backend translations now live in their own directory, you no longer need to run `pnpm prebuild` just to sync locales—the frontend folder is the sole source of truth for web bundles. + +## Tooling for frontend contributors + +- `pnpm format:i18n` → `node scripts/cleanup-unused-i18n.mjs --align --apply`. It aligns key ordering, removes unused entries, and keeps all locales in lock-step with English. +- `pnpm node scripts/cleanup-unused-i18n.mjs` (without flags) performs a dry-run audit. Use it to inspect missing or extra keys before committing. +- `pnpm i18n:types` regenerates `src/types/generated/i18n-keys.ts` and `src/types/generated/i18n-resources.ts`, ensuring TypeScript catches invalid key usage. +- For dynamic keys that the analyzer cannot statically detect, add explicit references in code or update the script whitelist to avoid false positives. + +## Backend (Tauri) locale bundles + +Native UI strings (tray menu, notifications, dialogs) use `rust-i18n` with YAML bundles stored in `src-tauri/locales/.yml`. These files are completely independent from the frontend JSON modules. + +- Keep `en.yml` semantically aligned with the Simplified Chinese baseline (`zh.yml`). Other locales may temporarily copy English if no translation is available yet. +- When a backend feature introduces new strings, update every YAML file to keep the key set consistent. Missing keys fall back to the default language (`zh`), so catching gaps early avoids mixed-language output. +- Rust code resolves the active language through `src-tauri/src/utils/i18n.rs`. No additional build step is required after editing YAML files; `tauri dev` and `tauri build` pick them up automatically. + +## Adding a new language + +1. Duplicate `src/locales/en/` into `src/locales//` and translate the JSON files while preserving key structure. +2. Update the locale’s `index.ts` to import every namespace. Matching the English file is the easiest way to avoid missing exports. +3. Append the language code to `supportedLanguages` in `src/services/i18n.ts`. +4. If the backend should expose the language, create `src-tauri/locales/.yml` and translate the keys used in existing YAML files. +5. Adjust `crowdin.yml` if the locale requires a special mapping for Crowdin. +6. Run `pnpm format:i18n`, `pnpm i18n:types`, and (optionally) `pnpm node scripts/cleanup-unused-i18n.mjs` in dry-run mode to confirm structure. + +## Authoring guidelines + +- **Reuse shared vocabulary** before introducing new phrases—check `shared.json` for common actions, statuses, and labels. +- **Prefer semantic keys** (`systemProxy`, `updateInterval`, `autoRefresh`) over positional ones (`item1`, `dialogTitle2`). +- **Document placeholders** using `{{placeholder}}` and ensure components supply the required values. +- **Group keys by UI responsibility** inside each namespace (`page`, `sections`, `forms`, `actions`, `tooltips`, `notifications`, `errors`, `tables`, `statuses`, etc.). +- **Keep strings concise** to avoid layout issues. If a translation needs more context, leave a PR note so reviewers can verify the UI. + +## Testing & QA + +- Launch the desktop shell with `pnpm dev` (or `pnpm web:dev`) and navigate through the affected views to confirm translations load and layouts behave. +- Run `pnpm test` if you touched code that consumes translations or adjusts formatting logic. +- For backend changes, trigger the relevant tray actions or notifications to verify the updated copy. +- Note any remaining untranslated sections or layout concerns in your PR description so maintainers can follow up. + +## Feedback & support + +- File an issue for missing context, tooling bugs, or localization gaps so we can track them. +- PRs that touch UI should include screenshots or GIFs whenever text length may affect layout. +- Mention the commands you ran (formatting, type generation, tests) in the PR checklist. If you need extra context or review help, request it via a PR comment. diff --git a/clash-verge-rev/UPDATELOG.md b/clash-verge-rev/docs/Changelog.history.md similarity index 88% rename from clash-verge-rev/UPDATELOG.md rename to clash-verge-rev/docs/Changelog.history.md index bde8494c29..115ab2d917 100644 --- a/clash-verge-rev/UPDATELOG.md +++ b/clash-verge-rev/docs/Changelog.history.md @@ -1,177 +1,3 @@ -## v2.4.3 - -### ✨ 新增功能 - -- **Mihomo(Meta) 内核升级至 v1.19.14** -- 支持前端修改日志(最大文件大小、最大保留数量) -- 新增链式代理图形化设置功能 -- 新增系统标题栏与程序标题栏切换 (设置-页面设置-倾向系统标题栏) -- 监听关机事件,自动关闭系统代理 -- 主界面“当前节点”卡片新增“延迟测试”按钮 -- 新增批量选择配置文件功能 - -### 🚀 优化改进 - -- 重构并简化服务模式启动检测流程,消除重复检测 -- 重构并简化窗口创建流程 -- 重构日志系统,单个日志默认最大 10 MB -- 优化前端资源占用 -- 改进 macos 下系统代理设置的方法 -- 优化 TUN 模式可用性的判断 -- 移除流媒体检测的系统级提示(使用软件内通知) -- 优化后端 i18n 资源占用 -- 改进 Linux 托盘支持并添加 `--no-tray` 选项 -- Linux 现在在新生成的配置中默认将 TUN 栈恢复为 mixed 模式 -- 为代理延迟测试的 URL 设置增加了保护以及添加了安全的备用 URL -- 更新了 Wayland 合成器检测逻辑,从而在 Hyprland 会话中保留原生 Wayland 后端 -- 改进 Windows 和 Unix 的 服务连接方式以及权限,避免无法连接服务或内核 - -### 🐞 修复问题 - -- 优化服务模式重装逻辑,避免不必要的重复检查 -- 修复轻量模式退出无响应的问题 -- 修复托盘轻量模式支持退出/进入 -- 修复静默启动和自动进入轻量模式时,托盘状态刷新不再依赖窗口创建流程 -- macOS Tun/系统代理 模式下图标大小不统一 -- 托盘节点切换不再显示隐藏组 -- 修复前端 IP 检测无法使用 ipapi, ipsb 提供商 -- 修复MacOS 下 Tun开启后 系统代理无法打开的问题 -- 修复服务模式启动时,修改、生成配置文件或重启内核可能导致页面卡死的问题 -- 修复 Webdav 恢复备份不重启 -- 修复 Linux 开机后无法正常代理需要手动设置 -- 修复增加订阅或导入订阅文件时订阅页面无更新 -- 修复系统代理守卫功能不工作 -- 修复 KDE + Wayland 下多屏显示 UI 异常 -- 修复 Windows 深色模式下首次启动客户端标题栏颜色异常 -- 修复静默启动不加载完整 WebView 的问题 -- 修复 Linux WebKit 网络进程的崩溃 -- 修复 Linux GNOME/KDE 桌面下,应用主题颜色选择“系统”后,不随操作系统主题(Dark/Light)切换 - -## v2.4.2 - -### ✨ 新增功能 - -- 增加托盘节点选择 - -### 🚀 性能优化 - -- 优化前端首页加载速度 -- 优化前端未使用 i18n 文件缓存 -- 优化后端内存占用 -- 优化后端启动速度 - -### 🐞 修复问题 - -- 修复首页节点切换失效的问题 -- 修复和优化服务检查流程 -- 修复2.4.1引入的订阅地址重定向报错问题 -- 修复 rpm/deb 包名称问题 -- 修复托盘轻量模式状态检测异常 -- 修复通过 scheme 导入订阅崩溃 -- 修复单例检测实效 -- 修复启动阶段可能导致的无法连接内核 -- 修复导入订阅无法 Auth Basic - -### 👙 界面样式 - -- 简化和改进代理设置样式 - -## v2.4.1 - -### 🏆 重大改进 - -- **应用响应速度提升**:采用全新异步处理架构,大幅提升应用响应速度和稳定性 - -### ✨ 新增功能 - -- **Mihomo(Meta) 内核升级至 v1.19.13** - -### 🚀 性能优化 - -- 优化热键响应速度,提升快捷键操作体验 -- 改进服务管理响应性,减少系统服务操作等待时间 -- 提升文件和配置处理性能 -- 优化任务管理和日志记录效率 -- 优化异步内存管理,减少内存占用并提升多任务处理效率 -- 优化启动阶段初始化性能 - -### 🐞 修复问题 - -- 修复应用在某些操作中可能出现的响应延迟问题 -- 修复任务管理中的潜在并发问题 -- 修复通过托盘重启应用无法恢复 -- 修复订阅在某些情况下无法导入 -- 修复无法新建订阅时使用远程链接 -- 修复卸载服务后的 tun 开关状态问题 -- 修复页面快速切换订阅时导致崩溃 -- 修复丢失工作目录时无法恢复环境 -- 修复从轻量模式恢复导致崩溃 - -### 👙 界面样式 - -- 统一代理设置样式 - -### 🗑️ 移除内容 - -- 移除启动阶段自动清理过期订阅 - -## v2.4.0 - -**发行代号:融** -代号释义: 「融」象征融合与贯通,寓意新版本通过全新 IPC 通信机制 将系统各部分紧密衔接,打破壁垒,实现更高效的 数据流通与全面性能优化。 - -### 🏆 重大改进 - -- **核心通信架构升级**:采用全新通信机制,提升应用性能和稳定性 -- **流量监控系统重构**:全新的流量监控界面,支持更丰富的数据展示 -- **数据缓存优化**:改进配置和节点数据缓存,提升响应速度 - -### ✨ 新增功能 - -- **Mihomo(Meta) 内核升级至 v1.19.12** -- 新增版本信息复制按钮 -- 增强型流量监控,支持更详细的数据分析 -- 新增流量图表多种显示模式 -- 新增强制刷新配置和节点缓存功能 -- 首页流量统计支持查看刻度线详情 - -### 🚀 性能优化 - -- 全面提升数据传输和处理效率 -- 优化内存使用,减少系统资源消耗 -- 改进流量图表渲染性能 -- 优化配置和节点刷新策略,从5秒延长到60秒 -- 改进数据缓存机制,减少重复请求 -- 优化异步程序性能 - -### 🐞 修复问题 - -- 修复系统代理状态检测和显示不一致问题 -- 修复系统主题窗口颜色不一致问题 -- 修复特殊字符 URL 处理问题 -- 修复配置修改后缓存不同步问题 -- 修复 Windows 安装器自启设置问题 -- 修复 macOS 下 Dock 图标恢复窗口问题 -- 修复 linux 下 KDE/Plasma 异常标题栏按钮 -- 修复架构升级后节点测速功能异常 -- 修复架构升级后流量统计功能异常 -- 修复架构升级后日志功能异常 -- 修复外部控制器跨域配置保存问题 -- 修复首页端口显示不一致问题 -- 修复首页流量统计刻度线显示问题 -- 修复日志页面按钮功能混淆问题 -- 修复日志等级设置保存问题 -- 修复日志等级异常过滤 -- 修复清理日志天数功能异常 -- 修复偶发性启动卡死问题 -- 修复首页虚拟网卡开关在管理模式下的状态问题 - -### 🔧 技术改进 - -- 统一使用新的内核通信方式 -- 新增外部控制器配置界面 -- 改进跨平台兼容性支持 - ## v2.3.2 ### 🐞 修复问题 diff --git a/clash-verge-rev/docs/README_en.md b/clash-verge-rev/docs/README_en.md new file mode 100644 index 0000000000..ae1ad194a4 --- /dev/null +++ b/clash-verge-rev/docs/README_en.md @@ -0,0 +1,125 @@ +

+ Clash +
+ Continuation of Clash Verge +
+

+ +

+A Clash Meta GUI built with Tauri. +

+ +

+ Languages: + 简体中文 · + English · + Español · + Русский · + 日本語 · + 한국어 +

+ +## Preview + +| Dark | Light | +| ----------------------------------- | ------------------------------------- | +| ![Dark Preview](./preview_dark.png) | ![Light Preview](./preview_light.png) | + +## Install + +Visit the [Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases) to download the installer that matches your platform.
+We provide packages for Windows (x64/x86), Linux (x64/arm64), and macOS 10.15+ (Intel/Apple). + +#### Choosing a Release Channel + +| Channel | Description | Link | +| :---------- | :-------------------------------------------------------------------- | :------------------------------------------------------------------------------------- | +| Stable | Official builds with high reliability, ideal for daily use. | [Release](https://github.com/clash-verge-rev/clash-verge-rev/releases) | +| Alpha (EOL) | Legacy builds used to validate the publish pipeline. | [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) | +| AutoBuild | Rolling builds for testing and feedback. Expect experimental changes. | [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) | + +#### Installation Guides & FAQ + +Read the [project documentation](https://clash-verge-rev.github.io/) for install steps, troubleshooting, and frequently asked questions. + +--- + +### Telegram Channel + +Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements. + +## Promotion + +#### [Doggygo VPN — Performance-oriented global accelerator](https://verge.dginv.click/#/register?code=oaxsAGo6) + +- High-performance overseas network service with free trials, discounted plans, streaming unlocks, and first-class Hysteria protocol support. +- Register through the exclusive Clash Verge link to get a 3-day trial with 1 GB of traffic per day: [Sign up](https://verge.dginv.click/#/register?code=oaxsAGo6) +- Exclusive 20% off coupon for Clash Verge users: `verge20` (limited to 500 uses) +- Discounted bundle from ¥15.8 per month for 160 GB, plus an additional 20% off for yearly billing +- Operated by an overseas team with reliable service and up to 50% revenue share +- Load-balanced clusters with high-speed dedicated routes (compatible with legacy clients), exceptionally low latency, smooth 4K playback +- First global provider to support the `Hysteria2` protocol—perfect fit for the Clash Verge client +- Supports streaming services and ChatGPT access +- Official site: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6) + +#### Build Infrastructure Sponsor — [YXVM Dedicated Servers](https://yxvm.com/aff.php?aff=827) + +Our builds and releases run on YXVM dedicated servers that deliver premium resources, strong performance, and high-speed networking. If downloads feel fast and usage feels snappy, it is thanks to robust hardware. + +🧩 Highlights of YXVM Dedicated Servers: + +- 🌎 Optimized global routes for dramatically faster downloads +- 🔧 Bare-metal resources instead of shared VPS capacity for maximum performance +- 🧠 Great for proxy workloads, hosting web/CDN services, CI/CD pipelines, or any high-load tasks +- 💡 Ready to use instantly with multiple datacenter options, including CN2 and IEPL +- 📦 The configuration used by this project is on sale—feel free to get the same setup +- 🎯 Want the same build environment? [Order a YXVM server today](https://yxvm.com/aff.php?aff=827) + +## Features + +- Built on high-performance Rust with the Tauri 2 framework +- Ships with the embedded [Clash.Meta (mihomo)](https://github.com/MetaCubeX/mihomo) core and supports switching to the `Alpha` channel +- Clean, polished UI with theme color controls, proxy group/tray icons, and `CSS Injection` +- Enhanced profile management (Merge and Script helpers) with configuration syntax hints +- System proxy controls, guard mode, and `TUN` (virtual network adapter) support +- Visual editors for nodes and rules +- WebDAV-based backup and sync for configurations + +### FAQ + +See the [FAQ page](https://clash-verge-rev.github.io/faq/windows.html) for platform-specific guidance. + +### Donation + +[Support Clash Verge Rev development](https://github.com/sponsors/clash-verge-rev) + +## Development + +See [CONTRIBUTING.md](../CONTRIBUTING.md) for detailed contribution guidelines. + +After installing all **Tauri** prerequisites, run the development shell with: + +```shell +pnpm i +pnpm run prebuild +pnpm dev +``` + +## Contributions + +Issues and pull requests are welcome! + +## Acknowledgement + +Clash Verge Rev builds on or draws inspiration from these projects: + +- [zzzgydi/clash-verge](https://github.com/zzzgydi/clash-verge): A Tauri-based Clash GUI for Windows, macOS, and Linux. +- [tauri-apps/tauri](https://github.com/tauri-apps/tauri): Build smaller, faster, more secure desktop apps with a web frontend. +- [Dreamacro/clash](https://github.com/Dreamacro/clash): A rule-based tunnel written in Go. +- [MetaCubeX/mihomo](https://github.com/MetaCubeX/mihomo): A rule-based tunnel written in Go. +- [Fndroid/clash_for_windows_pkg](https://github.com/Fndroid/clash_for_windows_pkg): A Clash GUI for Windows and macOS. +- [vitejs/vite](https://github.com/vitejs/vite): Next-generation frontend tooling with blazing-fast DX. + +## License + +GPL-3.0 License. See the [license file](../LICENSE) for details. diff --git a/clash-verge-rev/docs/README_es.md b/clash-verge-rev/docs/README_es.md new file mode 100644 index 0000000000..2380bfdd26 --- /dev/null +++ b/clash-verge-rev/docs/README_es.md @@ -0,0 +1,125 @@ +

+ Clash +
+ Continuación de Clash Verge +
+

+ +

+Una interfaz gráfica para Clash Meta construida con Tauri. +

+ +

+ Idiomas: + 简体中文 · + English · + Español · + Русский · + 日本語 · + 한국어 +

+ +## Vista previa + +| Oscuro | Claro | +| ----------------------------------- | ----------------------------------- | +| ![Vista oscura](./preview_dark.png) | ![Vista clara](./preview_light.png) | + +## Instalación + +Visita la [página de lanzamientos](https://github.com/clash-verge-rev/clash-verge-rev/releases) y descarga el instalador que corresponda a tu plataforma.
+Ofrecemos paquetes para Windows (x64/x86), Linux (x64/arm64) y macOS 10.15+ (Intel/Apple). + +#### Cómo elegir el canal de lanzamiento + +| Canal | Descripción | Enlace | +| :---------- | :----------------------------------------------------------------------------- | :------------------------------------------------------------------------------------- | +| Stable | Compilaciones oficiales de alta fiabilidad; ideales para el uso diario. | [Release](https://github.com/clash-verge-rev/clash-verge-rev/releases) | +| Alpha (EOL) | Compilaciones heredadas usadas para validar el flujo de publicación. | [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) | +| AutoBuild | Compilaciones continuas para pruebas y retroalimentación. Espera cambios beta. | [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) | + +#### Guías de instalación y preguntas frecuentes + +Consulta la [documentación del proyecto](https://clash-verge-rev.github.io/) para encontrar los pasos de instalación, solución de problemas y preguntas frecuentes. + +--- + +### Canal de Telegram + +Únete a [@clash_verge_rev](https://t.me/clash_verge_re) para enterarte de las novedades. + +## Promociones + +#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://verge.dginv.click/#/register?code=oaxsAGo6) + +- Servicio internacional de alto rendimiento con prueba gratuita, planes con descuento, desbloqueo de streaming y soporte de protocolo Hysteria de primera clase. +- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://verge.dginv.click/#/register?code=oaxsAGo6) +- Cupón exclusivo de 20% de descuento para usuarios de Clash Verge: `verge20` (limitado a 500 usos) +- Plan promocional desde ¥15.8 al mes con 160 GB, más 20% de descuento adicional por pago anual +- Equipo ubicado en el extranjero para un servicio confiable, con hasta 50% de comisión compartida +- Clústeres balanceados con rutas dedicadas de alta velocidad (compatibles con clientes antiguos), latencia extremadamente baja, reproducción 4K sin interrupciones +- Primer proveedor global que soporta el protocolo `Hysteria2`, ideal para el cliente Clash Verge +- Desbloquea servicios de streaming y acceso a ChatGPT +- Sitio oficial: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6) + +#### Patrocinador de la infraestructura de compilación — [Servidores dedicados YXVM](https://yxvm.com/aff.php?aff=827) + +Las compilaciones y lanzamientos del proyecto se ejecutan en servidores dedicados de YXVM, que proporcionan recursos premium, alto rendimiento y redes de alta velocidad. Si las descargas son rápidas y el uso es fluido, es gracias a este hardware robusto. + +🧩 Ventajas de los servidores dedicados YXVM: + +- 🌎 Rutas globales optimizadas para descargas significativamente más rápidas +- 🔧 Recursos bare-metal, en lugar de VPS compartidos, para obtener el máximo rendimiento +- 🧠 Ideales para proxys, alojamiento de sitios web/CDN, pipelines de CI/CD o cualquier carga elevada +- 💡 Listos para usar al instante, con múltiples centros de datos disponibles (incluidos CN2 e IEPL) +- 📦 La misma configuración utilizada por este proyecto está disponible para su compra +- 🎯 ¿Quieres el mismo entorno de compilación? [Solicita un servidor YXVM hoy](https://yxvm.com/aff.php?aff=827) + +## Funciones + +- Basado en Rust de alto rendimiento y en el framework Tauri 2 +- Incluye el núcleo integrado [Clash.Meta (mihomo)](https://github.com/MetaCubeX/mihomo) y permite cambiar al canal `Alpha` +- Interfaz limpia y elegante con controles de color de tema, iconos de grupos proxy/bandeja y `CSS Injection` +- Gestión avanzada de perfiles (herramientas Merge y Script) con sugerencias de sintaxis para configuraciones +- Control del proxy del sistema, modo guardián y soporte para `TUN` (adaptador de red virtual) +- Editores visuales para nodos y reglas +- Copias de seguridad y sincronización mediante WebDAV + +### Preguntas frecuentes + +Visita la [página de FAQ](https://clash-verge-rev.github.io/faq/windows.html) para obtener instrucciones específicas por plataforma. + +### Donaciones + +[Apoya el desarrollo de Clash Verge Rev](https://github.com/sponsors/clash-verge-rev) + +## Desarrollo + +Consulta [CONTRIBUTING.md](../CONTRIBUTING.md) para conocer las pautas de contribución. + +Después de instalar todos los requisitos de **Tauri**, ejecuta el entorno de desarrollo con: + +```shell +pnpm i +pnpm run prebuild +pnpm dev +``` + +## Contribuciones + +Se agradecen los issues y pull requests. + +## Agradecimientos + +Clash Verge Rev se basa en, o se inspira en, los siguientes proyectos: + +- [zzzgydi/clash-verge](https://github.com/zzzgydi/clash-verge): Interfaz gráfica para Clash basada en Tauri. Compatible con Windows, macOS y Linux. +- [tauri-apps/tauri](https://github.com/tauri-apps/tauri): Construye aplicaciones de escritorio más pequeñas, rápidas y seguras con un frontend web. +- [Dreamacro/clash](https://github.com/Dreamacro/clash): Túnel basado en reglas escrito en Go. +- [MetaCubeX/mihomo](https://github.com/MetaCubeX/mihomo): Túnel basado en reglas escrito en Go. +- [Fndroid/clash_for_windows_pkg](https://github.com/Fndroid/clash_for_windows_pkg): Interfaz de Clash para Windows y macOS. +- [vitejs/vite](https://github.com/vitejs/vite): Herramientas de frontend de nueva generación con una experiencia rapidísima. + +## Licencia + +Licencia GPL-3.0. Consulta el [archivo de licencia](../LICENSE) para más detalles. diff --git a/clash-verge-rev/docs/README_ja.md b/clash-verge-rev/docs/README_ja.md new file mode 100644 index 0000000000..b74b62d4c8 --- /dev/null +++ b/clash-verge-rev/docs/README_ja.md @@ -0,0 +1,125 @@ +

+ Clash +
+ Clash Verge の継続プロジェクト +
+

+ +

+Tauri で構築された Clash Meta GUI。 +

+ +

+ 言語: + 简体中文 · + English · + Español · + Русский · + 日本語 · + 한국어 +

+ +## プレビュー + +| ダーク | ライト | +| --------------------------------------- | ---------------------------------------- | +| ![ダークプレビュー](./preview_dark.png) | ![ライトプレビュー](./preview_light.png) | + +## インストール + +[リリースページ](https://github.com/clash-verge-rev/clash-verge-rev/releases) から、ご利用のプラットフォームに対応したインストーラーをダウンロードしてください。
+Windows (x64/x86)、Linux (x64/arm64)、macOS 10.15+ (Intel/Apple) をサポートしています。 + +#### リリースチャンネルの選び方 + +| チャンネル | 説明 | リンク | +| :---------- | :--------------------------------------------------------------- | :------------------------------------------------------------------------------------- | +| Stable | 安定版。信頼性が高く、日常利用に最適です。 | [Release](https://github.com/clash-verge-rev/clash-verge-rev/releases) | +| Alpha (EOL) | 公開フローの検証に使用した旧テスト版。 | [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) | +| AutoBuild | 継続的に更新されるテスト版。フィードバックや新機能検証向けです。 | [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) | + +#### インストール手順と FAQ + +詳しい導入手順やトラブルシュートは [ドキュメントサイト](https://clash-verge-rev.github.io/) を参照してください。 + +--- + +### Telegram チャンネル + +更新情報は [@clash_verge_rev](https://t.me/clash_verge_re) をフォローしてください。 + +## プロモーション + +#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://verge.dginv.click/#/register?code=oaxsAGo6) + +- 無料トライアル、割引プラン、ストリーミング解放、世界初の Hysteria プロトコル対応を備えた高性能海外ネットワークサービス。 +- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://verge.dginv.click/#/register?code=oaxsAGo6) +- Clash Verge 利用者限定 20% オフクーポン: `verge20`(先着 500 名) +- 月額 15.8 元で 160 GB を利用できるプラン、年額契約ならさらに 20% オフ +- 海外チーム運営による高信頼サービス、収益シェアは最大 50% +- 負荷分散クラスタと高速専用回線(旧クライアント互換)、極低レイテンシで 4K も快適 +- 世界初の `Hysteria2` プロトコル対応。Clash Verge クライアントとの相性抜群 +- ストリーミングおよび ChatGPT の利用にも対応 +- 公式サイト: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6) + +#### ビルド環境スポンサー — [YXVM 専用サーバー](https://yxvm.com/aff.php?aff=827) + +本プロジェクトのビルドとリリースは、YXVM の専用サーバーによって支えられています。高速ダウンロードや快適な操作性は、強力なハードウェアがあってこそです。 + +🧩 YXVM 専用サーバーの特長: + +- 🌎 最適化されたグローバル回線で圧倒的なダウンロード速度 +- 🔧 VPS とは異なるベアメタル資源で最高性能を発揮 +- 🧠 プロキシ運用、Web/CDN ホスティング、CI/CD など高負荷ワークロードに最適 +- 💡 複数データセンターから即時利用可能。CN2 や IEPL も選択可 +- 📦 本プロジェクトが使用している構成も販売中。同じ環境を入手できます +- 🎯 同じビルド体験をしたい方は [今すぐ YXVM サーバーを注文](https://yxvm.com/aff.php?aff=827) + +## 機能 + +- 高性能な Rust と Tauri 2 フレームワークに基づくデスクトップアプリ +- 組み込みの [Clash.Meta (mihomo)](https://github.com/MetaCubeX/mihomo) コアを搭載し、`Alpha` チャンネルへの切り替えも可能 +- テーマカラーやプロキシグループ/トレイアイコン、`CSS Injection` をカスタマイズできる洗練された UI +- 設定ファイルの管理および拡張(Merge・Script 支援)、構成シンタックスヒントを提供 +- システムプロキシ制御、ガード機能、`TUN`(仮想ネットワークアダプタ)モード +- ノードとルールのビジュアルエディタ +- WebDAV による設定のバックアップと同期 + +### FAQ + +プラットフォーム別の案内は [FAQ ページ](https://clash-verge-rev.github.io/faq/windows.html) を参照してください。 + +### 寄付 + +[Clash Verge Rev の開発を支援する](https://github.com/sponsors/clash-verge-rev) + +## 開発 + +詳細な貢献ガイドは [CONTRIBUTING.md](../CONTRIBUTING.md) をご覧ください。 + +**Tauri** の前提条件を整えたら、以下のコマンドで開発サーバーを起動できます: + +```shell +pnpm i +pnpm run prebuild +pnpm dev +``` + +## コントリビューション + +Issue や Pull Request を歓迎します。 + +## 謝辞 + +Clash Verge Rev は、以下のプロジェクトに影響を受けています。 + +- [zzzgydi/clash-verge](https://github.com/zzzgydi/clash-verge): Tauri ベースの Clash GUI。Windows / macOS / Linux に対応。 +- [tauri-apps/tauri](https://github.com/tauri-apps/tauri): Web フロントエンドで小型・高速・安全なデスクトップアプリを構築するためのフレームワーク。 +- [Dreamacro/clash](https://github.com/Dreamacro/clash): Go 製のルールベーストンネル。 +- [MetaCubeX/mihomo](https://github.com/MetaCubeX/mihomo): Go 製のルールベーストンネル。 +- [Fndroid/clash_for_windows_pkg](https://github.com/Fndroid/clash_for_windows_pkg): Windows / macOS 向けの Clash GUI。 +- [vitejs/vite](https://github.com/vitejs/vite): 次世代のフロントエンドツール群。高速な開発体験を提供。 + +## ライセンス + +GPL-3.0 ライセンス。詳細は [LICENSE](../LICENSE) を参照してください。 diff --git a/clash-verge-rev/docs/README_ko.md b/clash-verge-rev/docs/README_ko.md new file mode 100644 index 0000000000..997586ae72 --- /dev/null +++ b/clash-verge-rev/docs/README_ko.md @@ -0,0 +1,125 @@ +

+ Clash +
+ Clash Verge의 후속 프로젝트 +
+

+ +

+Tauri로 제작된 Clash Meta GUI. +

+ +

+ 언어: + 简体中文 · + English · + Español · + Русский · + 日本語 · + 한국어 +

+ +## 미리보기 + +| 다크 | 라이트 | +| ------------------------------------ | --------------------------------------- | +| ![다크 미리보기](./preview_dark.png) | ![라이트 미리보기](./preview_light.png) | + +## 설치 + +[릴리스 페이지](https://github.com/clash-verge-rev/clash-verge-rev/releases)에서 사용 중인 플랫폼에 맞는 설치 프로그램을 다운로드하세요.
+Windows (x64/x86), Linux (x64/arm64), macOS 10.15+ (Intel/Apple)을 지원합니다. + +#### 릴리스 채널 선택 + +| 채널 | 설명 | 링크 | +| :---------- | :----------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------- | +| Stable | 안정 릴리스. 신뢰성이 높아 일상 사용에 적합합니다. | [Release](https://github.com/clash-verge-rev/clash-verge-rev/releases) | +| Alpha (EOL) | 퍼블리시 파이프라인 검증에 사용되었던 구 테스트 채널입니다. | [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) | +| AutoBuild | 롤링 빌드 채널. 테스트와 피드백 용도로 권장되며, 실험적인 변경이 포함될 수 있습니다. | [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) | + +#### 설치 가이드 및 FAQ + +설치 방법, 트러블슈팅, 자주 묻는 질문은 [프로젝트 문서](https://clash-verge-rev.github.io/)를 참고하세요. + +--- + +### 텔레그램 채널 + +업데이트 공지는 [@clash_verge_rev](https://t.me/clash_verge_re)에서 확인하세요. + +## 프로모션 + +#### [Doggygo VPN — 고성능 글로벌 가속기](https://verge.dginv.click/#/register?code=oaxsAGo6) + +- 무료 체험, 할인 요금제, 스트리밍 해제, 선도적인 Hysteria 프로토콜 지원을 갖춘 고성능 해외 네트워크 서비스 +- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://verge.dginv.click/#/register?code=oaxsAGo6) +- Clash Verge 전용 20% 할인 코드: `verge20` (선착순 500회) +- 월 15.8위안부터 160GB 제공, 연간 결제 시 추가 20% 할인 +- 해외 팀 운영, 높은 신뢰성, 최대 50% 커미션 +- 로드밸런싱 클러스터, 고속 전용 회선(구 클라이언트 호환), 매우 낮은 지연, 4K도 쾌적 +- 세계 최초 `Hysteria2` 프로토콜 지원 — Clash Verge 클라이언트와 최적의 궁합 +- 스트리밍 및 ChatGPT 접근 지원 +- 공식 사이트: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6) + +#### 빌드 인프라 스폰서 — [YXVM 전용 서버](https://yxvm.com/aff.php?aff=827) + +본 프로젝트의 빌드 및 릴리스는 YXVM 전용 서버에서 구동됩니다. 빠른 다운로드와 경쾌한 사용감은 탄탄한 하드웨어 덕분입니다. + +🧩 YXVM 전용 서버 하이라이트: + +- 🌎 최적화된 글로벌 라우팅으로 대폭 빨라진 다운로드 +- 🔧 공유 VPS가 아닌 베어메탈 자원으로 최대 성능 제공 +- 🧠 프록시 워크로드, Web/CDN 호스팅, CI/CD, 고부하 작업에 적합 +- 💡 CN2 / IEPL 등 다양한 데이터센터 옵션, 즉시 사용 가능 +- 📦 본 프로젝트가 사용하는 구성도 판매 중 — 동일한 환경을 사용할 수 있습니다 +- 🎯 동일한 빌드 환경이 필요하다면 [지금 YXVM 서버 주문](https://yxvm.com/aff.php?aff=827) + +## 기능 + +- 고성능 Rust와 Tauri 2 프레임워크 기반 데스크톱 앱 +- 내장 [Clash.Meta (mihomo)](https://github.com/MetaCubeX/mihomo) 코어, `Alpha` 채널 전환 지원 +- 테마 색상, 프록시 그룹/트레이 아이콘, `CSS Injection` 등 세련된 UI 커스터마이징 +- 프로필 관리(병합 및 스크립트 보조), 구성 문법 힌트 제공 +- 시스템 프록시 제어, 가드 모드, `TUN`(가상 네트워크 어댑터) 지원 +- 노드/규칙 시각 편집기 +- WebDAV 기반 설정 백업 및 동기화 + +### FAQ + +플랫폼별 가이드는 [FAQ 페이지](https://clash-verge-rev.github.io/faq/windows.html)에서 확인하세요. + +### 후원 + +[Clash Verge Rev 개발 후원](https://github.com/sponsors/clash-verge-rev) + +## 개발 + +자세한 기여 가이드는 [CONTRIBUTING.md](../CONTRIBUTING.md)를 참고하세요. + +**Tauri** 필수 구성 요소를 설치한 뒤 아래 명령으로 개발 서버를 실행합니다: + +```shell +pnpm i +pnpm run prebuild +pnpm dev +``` + +## 기여 + +Issue와 Pull Request를 환영합니다! + +## 감사의 말 + +Clash Verge Rev는 다음 프로젝트에 기반하거나 영향을 받았습니다: + +- [zzzgydi/clash-verge](https://github.com/zzzgydi/clash-verge): Windows / macOS / Linux용 Tauri 기반 Clash GUI +- [tauri-apps/tauri](https://github.com/tauri-apps/tauri): 웹 프론트엔드로 더 작고 빠르고 안전한 데스크톱 앱을 빌드 +- [Dreamacro/clash](https://github.com/Dreamacro/clash): Go로 작성된 규칙 기반 터널 +- [MetaCubeX/mihomo](https://github.com/MetaCubeX/mihomo): Go로 작성된 규칙 기반 터널 +- [Fndroid/clash_for_windows_pkg](https://github.com/Fndroid/clash_for_windows_pkg): Windows / macOS용 Clash GUI +- [vitejs/vite](https://github.com/vitejs/vite): 차세대 프론트엔드 툴링, 매우 빠른 DX + +## 라이선스 + +GPL-3.0 라이선스. 자세한 내용은 [LICENSE](../LICENSE)를 참고하세요. diff --git a/clash-verge-rev/docs/README_ru.md b/clash-verge-rev/docs/README_ru.md new file mode 100644 index 0000000000..6bbc3cbb76 --- /dev/null +++ b/clash-verge-rev/docs/README_ru.md @@ -0,0 +1,121 @@ +

+ Clash +
+ Continuation of Clash Verge +
+

+ +

+Clash Meta GUI базируется на Tauri. +

+ +

+ Языки: + 简体中文 · + English · + Español · + Русский · + 日本語 · + 한국어 +

+## Предпросмотр + +| Тёмная тема | Светлая тема | +| ---------------------------------- | ------------------------------------ | +| ![Тёмная тема](./preview_dark.png) | ![Светлая тема](./preview_light.png) | + +## Установка + +Пожалуйста, перейдите на страницу релизов, чтобы скачать соответствующий установочный пакет: [Страница релизов](https://github.com/clash-verge-rev/clash-verge-rev/releases)
+Перейти на [Страницу релизов](https://github.com/clash-verge-rev/clash-verge-rev/releases) to download the corresponding installation package
+Поддержка Windows (x64/x86), Linux (x64/arm64) и macOS 10.15+ (intel/apple). + +#### Как выбрать дистрибутив? + +| Версия | Характеристики | Ссылка | +| :-------------------- | :------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------- | +| Stable | Официальный релиз, высокая надежность, подходит для повседневного использования. | [Release](https://github.com/clash-verge-rev/clash-verge-rev/releases) | +| Alpha(неиспользуемый) | Тестирование процесса публикации. | [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) | +| AutoBuild | Версия с постоянным обновлением, подходящая для тестирования и обратной связи. Может содержать дефекты. | [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) | + +#### Инструкции по установке и ответы на часто задаваемые вопросы можно найти на [странице документации](https://clash-verge-rev.github.io/) + +--- + +### TG канал: [@clash_verge_rev](https://t.me/clash_verge_re) + +## Продвижение + +#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://verge.dginv.click/#/register?code=oaxsAGo6) + +- Высокопроизводительный иностранный VPN-сервис (айрпорт) с бесплатным пробным периодом, выгодными тарифами, возможностью разблокировки потокового ТВ и первым в мире поддержкой протокола Hysteria. +- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://verge.dginv.click/#/register?code=oaxsAGo6) +- Эксклюзивный промо-код на скидку 20% для Clash Verge: verge20 (только 500 штук) +- Специальный тарифный план всего за 15,8 юаней в месяц, 160 Гб трафика, скидка 20% при оплате за год +- Команда за рубежом, без риска побега, до 50% кэшбэка +- Архитектура с балансировкойнагрузки, высокоскоростная выделенная линия (совместима со старыми клиентами), чрезвычайно низкая задержка, без проблем в часы пик, 4K видео загружается мгновенно +- Первый в мире VPN-сервис (айрпорт), поддерживающий протокол Hysteria, теперь доступен более быстрый протокол `Hysteria2` (лучшее сочетание с клиентом Clash Verge) +- Разблокировка потоковые сервисы и ChatGPT +- Официальный сайт: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6) + +#### Среда сборки и публикации этого проекта полностью поддерживается выделенным сервером [YXVM](https://yxvm.com/aff.php?aff=827) + +Благодарим вас за предоставление надежной бэкэнд-среды с эксклюзивными ресурсами, высокой производительностью и высокоскоростной сетью. Если вы считаете, что загрузка файлов происходит достаточно быстро, а использование — достаточно плавно, то это потому, что мы используем серверы высшего уровня! + +🧩 Преимущества выделенного сервера YXVM: + +- 🌎 Премиум-сеть с оптимизацией обратного пути для молниеносной скорости загрузки +- 🔧 Выделенные физические серверные ресурсы, не имеющие аналогов среди VPS, обеспечивающие максимальную производительность +- 🧠 Идеально подходит для прокси, хостинга веб-сайтов/CDN-сайтов, рабочих процессов CI/CD или любых приложений с высокой нагрузкой +- 💡 Поддержка использования сразу после включения, выбор нескольких дата-центров, CN2 / IEPL на выбор +- 📦 Эта конфигурация в настоящее время доступна для покупки — не стесняйтесь заказывать ту же модель! +- 🎯 Хотите попробовать такую же сборку? [Закажите выделенный сервер YXVM прямо сейчас!](https://yxvm.com/aff.php?aff=827) + +## Фичи + +- Основан на произвоительном Rust и фреймворке Tauri 2 +- Имеет встроенное ядро [Clash.Meta(mihomo)](https://github.com/MetaCubeX/mihomo) и поддерживает переключение на ядро версии `Alpha`. +- Чистый и эстетичный пользовательский интерфейс, поддержка настраиваемых цветов темы, значков прокси-группы/системного трея и `CSS Injection`。 +- Управление и расширение конфигурационными файлами (Merge и Script), подсказки по синтаксису конфигурационных файлов. +- Режим системного прокси и защита, `TUN (Tunneled Network Interface)` режим. +- Визуальное редактирование узлов и правил +- Резервное копирование и синхронизация конфигурации WebDAV + +### FAQ + +Смотрите [Страница часто задаваемых вопросов](https://clash-verge-rev.github.io/faq/windows.html) + +### Донат + +[Поддержите развитие Clash Verge Rev](https://github.com/sponsors/clash-verge-rev) + +## Разработка + +Дополнительные сведения смотреть в файле [CONTRIBUTING.md](../CONTRIBUTING.md). + +Для запуска сервера разработки выполните следующие команды после установки всех необходимых компонентов для **Tauri**: + +```shell +pnpm i +pnpm run prebuild +pnpm dev +``` + +## Вклад + +Обращения и запросы на PR приветствуются! + +## Благодарность + +Clash Verge rev был основан на этих проектах или вдохновлен ими, и так далее: + +- [zzzgydi/clash-verge](https://github.com/zzzgydi/clash-verge): Графический интерфейс Clash на основе tauri. Поддерживает Windows, macOS и Linux. +- [tauri-apps/tauri](https://github.com/tauri-apps/tauri): Создавайте более компактные, быстрые и безопасные настольные приложения с веб-интерфейсом. +- [Dreamacro/clash](https://github.com/Dreamacro/clash): Правило-ориентированный туннель на Go. +- [MetaCubeX/mihomo](https://github.com/MetaCubeX/mihomo): Правило-ориентированный туннель на Go. +- [Fndroid/clash_for_windows_pkg](https://github.com/Fndroid/clash_for_windows_pkg): Графический интерфейс пользователя для Windows/macOS на основе Clash. +- [vitejs/vite](https://github.com/vitejs/vite): Инструменты нового поколения для фронтенда. Они быстрые! + +## Лицензия + +GPL-3.0 License. Подробности смотрите в [Лицензии](../LICENSE). diff --git a/clash-verge-rev/eslint.config.ts b/clash-verge-rev/eslint.config.ts index b8cf71f123..f8f51a6319 100644 --- a/clash-verge-rev/eslint.config.ts +++ b/clash-verge-rev/eslint.config.ts @@ -1,5 +1,6 @@ -import eslintReact from "@eslint-react/eslint-plugin"; import eslintJS from "@eslint/js"; +import eslintReact from "@eslint-react/eslint-plugin"; +import { defineConfig } from "eslint/config"; import configPrettier from "eslint-config-prettier"; import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript"; import pluginImportX from "eslint-plugin-import-x"; @@ -7,7 +8,6 @@ import pluginPrettier from "eslint-plugin-prettier"; import pluginReactHooks from "eslint-plugin-react-hooks"; import pluginReactRefresh from "eslint-plugin-react-refresh"; import pluginUnusedImports from "eslint-plugin-unused-imports"; -import { defineConfig } from "eslint/config"; import globals from "globals"; import tseslint from "typescript-eslint"; @@ -17,6 +17,7 @@ export default defineConfig([ plugins: { js: eslintJS, + // @ts-expect-error -- https://github.com/typescript-eslint/typescript-eslint/issues/11543 "react-hooks": pluginReactHooks, // @ts-expect-error -- https://github.com/un-ts/eslint-plugin-import-x/issues/421 "import-x": pluginImportX, @@ -94,9 +95,10 @@ export default defineConfig([ "warn", { vars: "all", - varsIgnorePattern: "^_+$", + varsIgnorePattern: "^_", args: "after-used", - argsIgnorePattern: "^_+$", + argsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^ignore", }, ], @@ -131,4 +133,14 @@ export default defineConfig([ "prettier/prettier": "warn", }, }, + { + files: ["scripts/**/*.{js,mjs,cjs}", "scripts-workflow/**/*.{js,mjs,cjs}"], + + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + }, + }, ]); diff --git a/clash-verge-rev/package.json b/clash-verge-rev/package.json index 812bf889c2..019bd4a012 100644 --- a/clash-verge-rev/package.json +++ b/clash-verge-rev/package.json @@ -1,6 +1,6 @@ { "name": "clash-verge", - "version": "2.4.3", + "version": "2.4.4", "license": "GPL-3.0-only", "scripts": { "prepare": "husky || true", @@ -26,11 +26,14 @@ "publish-version": "node scripts/publish-version.mjs", "fmt": "cargo fmt --manifest-path ./src-tauri/Cargo.toml", "clippy": "cargo clippy --all-features --all-targets --manifest-path ./src-tauri/Cargo.toml", - "lint": "eslint -c eslint.config.ts --cache --cache-location .eslintcache src", - "lint:fix": "eslint -c eslint.config.ts --cache --cache-location .eslintcache --fix src", + "lint": "eslint -c eslint.config.ts --max-warnings=0 --cache --cache-location .eslintcache src", + "lint:fix": "eslint -c eslint.config.ts --max-warnings=0 --cache --cache-location .eslintcache --fix src", "format": "prettier --write .", "format:check": "prettier --check .", - "typecheck": "tsc --noEmit" + "format:i18n": "node scripts/cleanup-unused-i18n.mjs --align --apply", + "i18n:types": "node scripts/generate-i18n-keys.mjs", + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -39,25 +42,26 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@juggle/resize-observer": "^3.4.0", - "@mui/icons-material": "^7.3.4", + "@monaco-editor/react": "^4.7.0", + "@mui/icons-material": "^7.3.5", "@mui/lab": "7.0.0-beta.17", - "@mui/material": "^7.3.4", - "@mui/x-data-grid": "^8.14.0", - "@tauri-apps/api": "2.8.0", - "@tauri-apps/plugin-clipboard-manager": "^2.3.0", - "@tauri-apps/plugin-dialog": "^2.4.0", - "@tauri-apps/plugin-fs": "^2.4.2", - "@tauri-apps/plugin-http": "~2.5.2", - "@tauri-apps/plugin-process": "^2.3.0", - "@tauri-apps/plugin-shell": "2.3.1", + "@mui/material": "^7.3.5", + "@mui/x-data-grid": "^8.18.0", + "@tauri-apps/api": "2.9.0", + "@tauri-apps/plugin-clipboard-manager": "^2.3.2", + "@tauri-apps/plugin-dialog": "^2.4.2", + "@tauri-apps/plugin-fs": "^2.4.4", + "@tauri-apps/plugin-http": "~2.5.4", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-shell": "2.3.3", "@tauri-apps/plugin-updater": "2.9.0", "@types/json-schema": "^7.0.15", - "ahooks": "^3.9.5", - "axios": "^1.12.2", - "dayjs": "1.11.18", + "ahooks": "^3.9.6", + "axios": "^1.13.2", + "dayjs": "1.11.19", "foxact": "^0.2.49", - "i18next": "^25.6.0", - "js-yaml": "^4.1.0", + "i18next": "^25.6.2", + "js-yaml": "^4.1.1", "json-schema": "^0.4.0", "lodash-es": "^4.17.21", "monaco-editor": "^0.54.0", @@ -66,61 +70,61 @@ "react": "19.2.0", "react-dom": "19.2.0", "react-error-boundary": "6.0.0", - "react-hook-form": "^7.65.0", - "react-i18next": "16.0.0", + "react-hook-form": "^7.66.0", + "react-i18next": "16.3.3", "react-markdown": "10.1.0", - "react-monaco-editor": "0.59.0", - "react-router-dom": "7.9.4", + "react-router": "^7.9.6", "react-virtuoso": "^4.14.1", "swr": "^2.3.6", - "types-pac": "^1.0.3", - "zustand": "^5.0.8", - "tauri-plugin-mihomo-api": "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo" + "tauri-plugin-mihomo-api": "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo#main", + "types-pac": "^1.0.3" }, "devDependencies": { "@actions/github": "^6.0.1", - "@eslint-react/eslint-plugin": "^2.0.6", - "@eslint/js": "^9.37.0", - "@tauri-apps/cli": "2.8.4", + "@eslint-react/eslint-plugin": "^2.3.5", + "@eslint/js": "^9.39.1", + "@tauri-apps/cli": "2.9.4", "@types/js-yaml": "^4.0.9", "@types/lodash-es": "^4.17.12", - "@types/react": "19.2.2", - "@types/react-dom": "19.2.1", + "@types/node": "^24.10.1", + "@types/react": "19.2.4", + "@types/react-dom": "19.2.3", "@vitejs/plugin-legacy": "^7.2.1", - "@vitejs/plugin-react": "5.0.4", + "@vitejs/plugin-react-swc": "^4.2.2", "adm-zip": "^0.5.16", "cli-color": "^2.0.4", - "commander": "^14.0.1", + "commander": "^14.0.2", "cross-env": "^10.1.0", - "eslint": "^9.37.0", + "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import-x": "^4.16.1", "eslint-plugin-prettier": "^5.5.4", - "eslint-plugin-react-hooks": "^7.0.0", - "eslint-plugin-react-refresh": "^0.4.23", - "eslint-plugin-unused-imports": "^4.2.0", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "eslint-plugin-unused-imports": "^4.3.0", "glob": "^11.0.3", - "globals": "^16.4.0", + "globals": "^16.5.0", "https-proxy-agent": "^7.0.6", "husky": "^9.1.7", "jiti": "^2.6.1", - "lint-staged": "^16.2.4", - "meta-json-schema": "^1.19.14", + "lint-staged": "^16.2.6", + "meta-json-schema": "^1.19.16", "node-fetch": "^3.3.2", "prettier": "^3.6.2", - "sass": "^1.93.2", - "tar": "^7.5.1", - "terser": "^5.44.0", + "sass": "^1.94.0", + "tar": "^7.5.2", + "terser": "^5.44.1", "typescript": "^5.9.3", - "typescript-eslint": "^8.46.0", - "vite": "^7.1.9", - "vite-plugin-monaco-editor": "^1.1.0", - "vite-plugin-svgr": "^4.5.0" + "typescript-eslint": "^8.46.4", + "vite": "^7.2.2", + "vite-plugin-monaco-editor-esm": "^2.0.2", + "vite-plugin-svgr": "^4.5.0", + "vitest": "^4.0.9" }, "lint-staged": { "*.{ts,tsx,js,jsx}": [ - "eslint --fix", + "eslint --fix --max-warnings=0", "prettier --write", "git add" ], diff --git a/clash-verge-rev/pnpm-lock.yaml b/clash-verge-rev/pnpm-lock.yaml index 91737e7822..b2b6aeb295 100644 --- a/clash-verge-rev/pnpm-lock.yaml +++ b/clash-verge-rev/pnpm-lock.yaml @@ -19,46 +19,49 @@ importers: version: 3.2.2(react@19.2.0) '@emotion/react': specifier: ^11.14.0 - version: 11.14.0(@types/react@19.2.2)(react@19.2.0) + version: 11.14.0(@types/react@19.2.4)(react@19.2.0) '@emotion/styled': specifier: ^11.14.1 - version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) + version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0) '@juggle/resize-observer': specifier: ^3.4.0 version: 3.4.0 + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.54.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@mui/icons-material': - specifier: ^7.3.4 - version: 7.3.4(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) + specifier: ^7.3.5 + version: 7.3.5(@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.4)(react@19.2.0) '@mui/lab': specifier: 7.0.0-beta.17 - version: 7.0.0-beta.17(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 7.0.0-beta.17(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@mui/material': - specifier: ^7.3.4 - version: 7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: ^7.3.5 + version: 7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@mui/x-data-grid': - specifier: ^8.14.0 - version: 8.14.0(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mui/system@7.3.3(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: ^8.18.0 + version: 8.18.0(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mui/system@7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tauri-apps/api': - specifier: 2.8.0 - version: 2.8.0 + specifier: 2.9.0 + version: 2.9.0 '@tauri-apps/plugin-clipboard-manager': - specifier: ^2.3.0 - version: 2.3.0 + specifier: ^2.3.2 + version: 2.3.2 '@tauri-apps/plugin-dialog': - specifier: ^2.4.0 - version: 2.4.0 - '@tauri-apps/plugin-fs': specifier: ^2.4.2 version: 2.4.2 + '@tauri-apps/plugin-fs': + specifier: ^2.4.4 + version: 2.4.4 '@tauri-apps/plugin-http': - specifier: ~2.5.2 - version: 2.5.2 + specifier: ~2.5.4 + version: 2.5.4 '@tauri-apps/plugin-process': - specifier: ^2.3.0 - version: 2.3.0 - '@tauri-apps/plugin-shell': - specifier: 2.3.1 + specifier: ^2.3.1 version: 2.3.1 + '@tauri-apps/plugin-shell': + specifier: 2.3.3 + version: 2.3.3 '@tauri-apps/plugin-updater': specifier: 2.9.0 version: 2.9.0 @@ -66,23 +69,23 @@ importers: specifier: ^7.0.15 version: 7.0.15 ahooks: - specifier: ^3.9.5 - version: 3.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: ^3.9.6 + version: 3.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) axios: - specifier: ^1.12.2 - version: 1.12.2 + specifier: ^1.13.2 + version: 1.13.2 dayjs: - specifier: 1.11.18 - version: 1.11.18 + specifier: 1.11.19 + version: 1.11.19 foxact: specifier: ^0.2.49 version: 0.2.49(react@19.2.0) i18next: - specifier: ^25.6.0 - version: 25.6.0(typescript@5.9.3) + specifier: ^25.6.2 + version: 25.6.2(typescript@5.9.3) js-yaml: - specifier: ^4.1.0 - version: 4.1.0 + specifier: ^4.1.1 + version: 4.1.1 json-schema: specifier: ^0.4.0 version: 0.4.0 @@ -108,20 +111,17 @@ importers: specifier: 6.0.0 version: 6.0.0(react@19.2.0) react-hook-form: - specifier: ^7.65.0 - version: 7.65.0(react@19.2.0) + specifier: ^7.66.0 + version: 7.66.0(react@19.2.0) react-i18next: - specifier: 16.0.0 - version: 16.0.0(i18next@25.6.0(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + specifier: 16.3.3 + version: 16.3.3(i18next@25.6.2(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react-markdown: specifier: 10.1.0 - version: 10.1.0(@types/react@19.2.2)(react@19.2.0) - react-monaco-editor: - specifier: 0.59.0 - version: 0.59.0(monaco-editor@0.54.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - react-router-dom: - specifier: 7.9.4 - version: 7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 10.1.0(@types/react@19.2.4)(react@19.2.0) + react-router: + specifier: ^7.9.6 + version: 7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-virtuoso: specifier: ^4.14.1 version: 4.14.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -129,45 +129,45 @@ importers: specifier: ^2.3.6 version: 2.3.6(react@19.2.0) tauri-plugin-mihomo-api: - specifier: git+https://github.com/clash-verge-rev/tauri-plugin-mihomo - version: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/7818a0a8f55c03ee7eba3fd7433faef29adf38ac + specifier: git+https://github.com/clash-verge-rev/tauri-plugin-mihomo#main + version: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/1e2ada19e20c5504a2a8cef367e87c667806dd65 types-pac: specifier: ^1.0.3 version: 1.0.3 - zustand: - specifier: ^5.0.8 - version: 5.0.8(@types/react@19.2.2)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) devDependencies: '@actions/github': specifier: ^6.0.1 version: 6.0.1 '@eslint-react/eslint-plugin': - specifier: ^2.0.6 - version: 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + specifier: ^2.3.5 + version: 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@eslint/js': - specifier: ^9.37.0 - version: 9.37.0 + specifier: ^9.39.1 + version: 9.39.1 '@tauri-apps/cli': - specifier: 2.8.4 - version: 2.8.4 + specifier: 2.9.4 + version: 2.9.4 '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 '@types/react': - specifier: 19.2.2 - version: 19.2.2 + specifier: 19.2.4 + version: 19.2.4 '@types/react-dom': - specifier: 19.2.1 - version: 19.2.1(@types/react@19.2.2) + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.4) '@vitejs/plugin-legacy': specifier: ^7.2.1 - version: 7.2.1(terser@5.44.0)(vite@7.1.9(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)) - '@vitejs/plugin-react': - specifier: 5.0.4 - version: 5.0.4(vite@7.1.9(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)) + version: 7.2.1(terser@5.44.1)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)) + '@vitejs/plugin-react-swc': + specifier: ^4.2.2 + version: 4.2.2(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)) adm-zip: specifier: ^0.5.16 version: 0.5.16 @@ -175,41 +175,41 @@ importers: specifier: ^2.0.4 version: 2.0.4 commander: - specifier: ^14.0.1 - version: 14.0.1 + specifier: ^14.0.2 + version: 14.0.2 cross-env: specifier: ^10.1.0 version: 10.1.0 eslint: - specifier: ^9.37.0 - version: 9.37.0(jiti@2.6.1) + specifier: ^9.39.1 + version: 9.39.1(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.37.0(jiti@2.6.1)) + version: 10.1.8(eslint@9.39.1(jiti@2.6.1)) eslint-import-resolver-typescript: specifier: ^4.4.4 - version: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.37.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.37.0(jiti@2.6.1)) + version: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-import-x: specifier: ^4.16.1 - version: 4.16.1(@typescript-eslint/utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.37.0(jiti@2.6.1)) + version: 4.16.1(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-prettier: specifier: ^5.5.4 - version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1))(prettier@3.6.2) + version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2) eslint-plugin-react-hooks: - specifier: ^7.0.0 - version: 7.0.0(eslint@9.37.0(jiti@2.6.1)) + specifier: ^7.0.1 + version: 7.0.1(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-refresh: - specifier: ^0.4.23 - version: 0.4.23(eslint@9.37.0(jiti@2.6.1)) + specifier: ^0.4.24 + version: 0.4.24(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-unused-imports: - specifier: ^4.2.0 - version: 4.2.0(@typescript-eslint/eslint-plugin@8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1)) + specifier: ^4.3.0 + version: 4.3.0(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) glob: specifier: ^11.0.3 version: 11.0.3 globals: - specifier: ^16.4.0 - version: 16.4.0 + specifier: ^16.5.0 + version: 16.5.0 https-proxy-agent: specifier: ^7.0.6 version: 7.0.6 @@ -220,11 +220,11 @@ importers: specifier: ^2.6.1 version: 2.6.1 lint-staged: - specifier: ^16.2.4 - version: 16.2.4 + specifier: ^16.2.6 + version: 16.2.6 meta-json-schema: - specifier: ^1.19.14 - version: 1.19.14 + specifier: ^1.19.16 + version: 1.19.16 node-fetch: specifier: ^3.3.2 version: 3.3.2 @@ -232,29 +232,32 @@ importers: specifier: ^3.6.2 version: 3.6.2 sass: - specifier: ^1.93.2 - version: 1.93.2 + specifier: ^1.94.0 + version: 1.94.0 tar: - specifier: ^7.5.1 - version: 7.5.1 + specifier: ^7.5.2 + version: 7.5.2 terser: - specifier: ^5.44.0 - version: 5.44.0 + specifier: ^5.44.1 + version: 5.44.1 typescript: specifier: ^5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.46.0 - version: 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + specifier: ^8.46.4 + version: 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) vite: - specifier: ^7.1.9 - version: 7.1.9(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) - vite-plugin-monaco-editor: - specifier: ^1.1.0 - version: 1.1.0(monaco-editor@0.54.0) + specifier: ^7.2.2 + version: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) + vite-plugin-monaco-editor-esm: + specifier: ^2.0.2 + version: 2.0.2(monaco-editor@0.54.0) vite-plugin-svgr: specifier: ^4.5.0 - version: 4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.1.9(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)) + version: 4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)) + vitest: + specifier: ^4.0.9 + version: 4.0.9(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) packages: @@ -660,18 +663,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-regenerator@7.28.1': resolution: {integrity: sha512-P0QiV/taaa3kXpLY+sXla5zec4E+4t4Aqc9ggHlfZ7a2cp8/x/Gv08jfwEtn9gnnYIMvHx6aoOZ8XJL8eU71Dg==} engines: {node: '>=6.9.0'} @@ -1019,63 +1010,59 @@ packages: resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint-react/ast@2.0.6': - resolution: {integrity: sha512-i72EzCOYQo+CKnxKRPuguRM0FPIpA/ojDhK0XZI++nO6fG67ggKMSf2DZTIbScus30GsluhaYLpN8R2rppeVJA==} + '@eslint-react/ast@2.3.5': + resolution: {integrity: sha512-gTnLEdQ82Kcy2Yn8fLe6ks/yQx1kI3OYuWgYNb4D1XSAOYvL1Cj+UIx2/+ew9vMBLMO3NJr90EMPUr0yVOhC7w==} engines: {node: '>=20.19.0'} - '@eslint-react/core@2.0.6': - resolution: {integrity: sha512-VbyrfHT3CK22WrYszjXR38tWudlkwDn1G72i/qArWIDppJCFCxfUfNvpM63TaPEUjY/fWWLfsDn9DWltpAvuYw==} + '@eslint-react/core@2.3.5': + resolution: {integrity: sha512-6+/3bMmkxIk4vlMwfxw4lU6y7/Z1cjGURPsooAULitbBS4+s0M0N1UjWaPpDwT4FR0SVVqjOp1yUcI66uQvQKg==} engines: {node: '>=20.19.0'} - '@eslint-react/eff@2.0.6': - resolution: {integrity: sha512-hBXZg93uB8L8UxjAbgSMQA/27qKeNokXy3Dfkhr6I2gQ4A+IjdXBqbI6hHGlyxyGRmkUpk3RLDELZ/0uqqtysg==} + '@eslint-react/eff@2.3.5': + resolution: {integrity: sha512-F2bj6v7Q1hgLn+N28pkJyYvBiTaUFh0qOEz3IXUupkqqnu9zGxmh3P7c0l//8AlR2CvRTCmSVBBhem4BhoSczw==} engines: {node: '>=20.19.0'} - '@eslint-react/eslint-plugin@2.0.6': - resolution: {integrity: sha512-g+Wz8gr+J0rJVVr4y5DilXroPtZGart1phFWZtcuKPsdPEfINCEyHPzBrscOOzXFa26D5HKQOIxGb/IzYrjVSw==} + '@eslint-react/eslint-plugin@2.3.5': + resolution: {integrity: sha512-5VTcKcbyDNGrpXj3y5wfYKogA8g1aVPcyupSL9/URyxLhnv14tfSNAJ64qTh0NBunETU69n7T81e4ZYJS2ctGw==} engines: {node: '>=20.19.0'} peerDependencies: - eslint: ^9.36.0 + eslint: ^9.39.1 typescript: ^5.9.3 - '@eslint-react/kit@2.0.6': - resolution: {integrity: sha512-7Vz4GcKAsUVwpJMBBxEiHI/kXfUlTTTN71YGmvbgBsHpsXRQRQC1+yl3Q3FNsabd8909ww1eay2uIuigfVUkaQ==} + '@eslint-react/shared@2.3.5': + resolution: {integrity: sha512-k65W/X2MeiDX21HPwtcPaFHciYVRYrzE+EZ2ok2BVQWcl24GQUEckAfdMzKQ6cS19OgjQm9k0juHjpUcyHj29g==} engines: {node: '>=20.19.0'} - '@eslint-react/shared@2.0.6': - resolution: {integrity: sha512-yhRcipMwhzhYuJMWXHZVVnlAPntpMSUIvMtUYvplKeQvnEX+/awWobZSKqxWzB2QouBWSo5W3Sp2kb4vZonsig==} + '@eslint-react/var@2.3.5': + resolution: {integrity: sha512-BDq9o4kUu4h0Lvv29AY+N9LFh69tgICRNDmr5GnRmRFaYZ6/fq+UbO18K47ccb2tj2TI8V6VJFpkPx1fK7lYeQ==} engines: {node: '>=20.19.0'} - '@eslint-react/var@2.0.6': - resolution: {integrity: sha512-fWx7HZ4vpm3woyoclxbtN/Swz7Qgm4LU3mYDs+pHj4ZSOvtqPCTOACMIFR/MwRwEWBAthUyMMZfxFpWz/8RJmA==} - engines: {node: '>=20.19.0'} - - '@eslint/config-array@0.21.0': - resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/config-helpers@0.4.0': - resolution: {integrity: sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==} + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/core@0.16.0': - resolution: {integrity: sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==} + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/eslintrc@3.3.1': resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.37.0': - resolution: {integrity: sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==} + '@eslint/js@9.39.1': + resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/object-schema@2.1.6': - resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.4.0': - resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==} + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@fastify/busboy@2.1.1': @@ -1131,8 +1118,8 @@ packages: '@jridgewell/source-map@0.3.6': resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} - '@jridgewell/sourcemap-codec@1.5.0': - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} '@jridgewell/trace-mapping@0.3.29': resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} @@ -1140,14 +1127,24 @@ packages: '@juggle/resize-observer@3.4.0': resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} - '@mui/core-downloads-tracker@7.3.4': - resolution: {integrity: sha512-BIktMapG3r4iXwIhYNpvk97ZfYWTreBBQTWjQKbNbzI64+ULHfYavQEX2w99aSWHS58DvXESWIgbD9adKcUOBw==} + '@monaco-editor/loader@1.6.1': + resolution: {integrity: sha512-w3tEnj9HYEC73wtjdpR089AqkUPskFRcdkxsiSFt3SoUc3OHpmu+leP94CXBm4mHfefmhsdfI0ZQu6qJ0wgtPg==} - '@mui/icons-material@7.3.4': - resolution: {integrity: sha512-9n6Xcq7molXWYb680N2Qx+FRW8oT6j/LXF5PZFH3ph9X/Rct0B/BlLAsFI7iL9ySI6LVLuQIVtrLiPT82R7OZw==} + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@mui/core-downloads-tracker@7.3.5': + resolution: {integrity: sha512-kOLwlcDPnVz2QMhiBv0OQ8le8hTCqKM9cRXlfVPL91l3RGeOsxrIhNRsUt3Xb8wb+pTVUolW+JXKym93vRKxCw==} + + '@mui/icons-material@7.3.5': + resolution: {integrity: sha512-LciL1GLMZ+VlzyHAALSVAR22t8IST4LCXmljcUSx2NOutgO2XnxdIp8ilFbeNf9wpo0iUFbAuoQcB7h+HHIf3A==} engines: {node: '>=14.0.0'} peerDependencies: - '@mui/material': ^7.3.4 + '@mui/material': ^7.3.5 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: @@ -1175,13 +1172,13 @@ packages: '@types/react': optional: true - '@mui/material@7.3.4': - resolution: {integrity: sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==} + '@mui/material@7.3.5': + resolution: {integrity: sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.5.0 '@emotion/styled': ^11.3.0 - '@mui/material-pigment-css': ^7.3.3 + '@mui/material-pigment-css': ^7.3.5 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1195,8 +1192,8 @@ packages: '@types/react': optional: true - '@mui/private-theming@7.3.3': - resolution: {integrity: sha512-OJM+9nj5JIyPUvsZ5ZjaeC9PfktmK+W5YaVLToLR8L0lB/DGmv1gcKE43ssNLSvpoW71Hct0necfade6+kW3zQ==} + '@mui/private-theming@7.3.5': + resolution: {integrity: sha512-cTx584W2qrLonwhZLbEN7P5pAUu0nZblg8cLBlTrZQ4sIiw8Fbvg7GvuphQaSHxPxrCpa7FDwJKtXdbl2TSmrA==} engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1205,8 +1202,8 @@ packages: '@types/react': optional: true - '@mui/styled-engine@7.3.3': - resolution: {integrity: sha512-CmFxvRJIBCEaWdilhXMw/5wFJ1+FT9f3xt+m2pPXhHPeVIbBg9MnMvNSJjdALvnQJMPw8jLhrUtXmN7QAZV2fw==} + '@mui/styled-engine@7.3.5': + resolution: {integrity: sha512-zbsZ0uYYPndFCCPp2+V3RLcAN6+fv4C8pdwRx6OS3BwDkRCN8WBehqks7hWyF3vj1kdQLIWrpdv/5Y0jHRxYXQ==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.4.1 @@ -1218,8 +1215,8 @@ packages: '@emotion/styled': optional: true - '@mui/system@7.3.3': - resolution: {integrity: sha512-Lqq3emZr5IzRLKaHPuMaLBDVaGvxoh6z7HMWd1RPKawBM5uMRaQ4ImsmmgXWtwJdfZux5eugfDhXJUo2mliS8Q==} + '@mui/system@7.3.5': + resolution: {integrity: sha512-yPaf5+gY3v80HNkJcPi6WT+r9ebeM4eJzrREXPxMt7pNTV/1eahyODO4fbH3Qvd8irNxDFYn5RQ3idHW55rA6g==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.5.0 @@ -1234,16 +1231,16 @@ packages: '@types/react': optional: true - '@mui/types@7.4.7': - resolution: {integrity: sha512-8vVje9rdEr1rY8oIkYgP+Su5Kwl6ik7O3jQ0wl78JGSmiZhRHV+vkjooGdKD8pbtZbutXFVTWQYshu2b3sG9zw==} + '@mui/types@7.4.8': + resolution: {integrity: sha512-ZNXLBjkPV6ftLCmmRCafak3XmSn8YV0tKE/ZOhzKys7TZXUiE0mZxlH8zKDo6j6TTUaDnuij68gIG+0Ucm7Xhw==} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true - '@mui/utils@7.3.3': - resolution: {integrity: sha512-kwNAUh7bLZ7mRz9JZ+6qfRnnxbE4Zuc+RzXnhSpRSxjTlSTj7b4JxRLXpG+MVtPVtqks5k/XC8No1Vs3x4Z2gg==} + '@mui/utils@7.3.5': + resolution: {integrity: sha512-jisvFsEC3sgjUjcPnR4mYfhzjCDIudttSGSbe1o/IXFNu0kZuR+7vqQI0jg8qtcVZBHWrwTfvAZj9MNMumcq1g==} engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1252,8 +1249,8 @@ packages: '@types/react': optional: true - '@mui/x-data-grid@8.14.0': - resolution: {integrity: sha512-bzUpD83Wx4mawkgquDQUUbLLnpF+JP7Pe7YQx1ixS6W/AlUwXAVagPTOijwchHvlx0Ky11dJvOQAfrnWu6an/Q==} + '@mui/x-data-grid@8.18.0': + resolution: {integrity: sha512-g8y5EI3TNqrimHpH/Hv6u6i04cbvsqh39Tg4bZEhGq+SDxWp42iABlUvB7p+gtXfyd+IbmpfzUQ1hOCsHlTMZw==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.9.0 @@ -1268,14 +1265,14 @@ packages: '@emotion/styled': optional: true - '@mui/x-internals@8.14.0': - resolution: {integrity: sha512-esYyl61nuuFXiN631TWuPh2tqdoyTdBI/4UXgwH3rytF8jiWvy6prPBPRHEH1nvW3fgw9FoBI48FlOO+yEI8xg==} + '@mui/x-internals@8.18.0': + resolution: {integrity: sha512-iM2SJALLo4kNqxTel8lfjIymYV9MgTa6021/rAlfdh/vwPMglaKyXQHrxkkWs2Eu/JFKkCKr5Fd34Gsdp63wIg==} engines: {node: '>=14.0.0'} peerDependencies: react: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@mui/x-virtualizer@0.2.3': - resolution: {integrity: sha512-CZ+VxFmeJaTduAOlSyo5cVek0PV5Y8gm4coyaHEpCvms207J9AoMUKqWIcdwsVGlTH1Y71j35xT/MwHKutZiNw==} + '@mui/x-virtualizer@0.2.8': + resolution: {integrity: sha512-hCkhTg3BLLbf0SIw9Cx/NHTCUmbna+P5F2V+Bcv/9XiYhfzzmhYnm68+V6vOOhKVbV3j8JKsUEqcTC9K2Jpu8A==} engines: {node: '>=14.0.0'} peerDependencies: react: ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1433,8 +1430,8 @@ packages: '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - '@rolldown/pluginutils@1.0.0-beta.38': - resolution: {integrity: sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==} + '@rolldown/pluginutils@1.0.0-beta.47': + resolution: {integrity: sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==} '@rollup/pluginutils@5.2.0': resolution: {integrity: sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==} @@ -1548,6 +1545,9 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} engines: {node: '>=14'} @@ -1616,97 +1616,172 @@ packages: peerDependencies: '@svgr/core': '*' - '@tauri-apps/api@2.8.0': - resolution: {integrity: sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw==} + '@swc/core-darwin-arm64@1.14.0': + resolution: {integrity: sha512-uHPC8rlCt04nvYNczWzKVdgnRhxCa3ndKTBBbBpResOZsRmiwRAvByIGh599j+Oo6Z5eyTPrgY+XfJzVmXnN7Q==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] - '@tauri-apps/cli-darwin-arm64@2.8.4': - resolution: {integrity: sha512-BKu8HRkYV01SMTa7r4fLx+wjgtRK8Vep7lmBdHDioP6b8XH3q2KgsAyPWfEZaZIkZ2LY4SqqGARaE9oilNe0oA==} + '@swc/core-darwin-x64@1.14.0': + resolution: {integrity: sha512-2SHrlpl68vtePRknv9shvM9YKKg7B9T13tcTg9aFCwR318QTYo+FzsKGmQSv9ox/Ua0Q2/5y2BNjieffJoo4nA==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.14.0': + resolution: {integrity: sha512-SMH8zn01dxt809svetnxpeg/jWdpi6dqHKO3Eb11u4OzU2PK7I5uKS6gf2hx5LlTbcJMFKULZiVwjlQLe8eqtg==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.14.0': + resolution: {integrity: sha512-q2JRu2D8LVqGeHkmpVCljVNltG0tB4o4eYg+dElFwCS8l2Mnt9qurMCxIeo9mgoqz0ax+k7jWtIRHktnVCbjvQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.14.0': + resolution: {integrity: sha512-uofpVoPCEUjYIv454ZEZ3sLgMD17nIwlz2z7bsn7rl301Kt/01umFA7MscUovFfAK2IRGck6XB+uulMu6aFhKQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-x64-gnu@1.14.0': + resolution: {integrity: sha512-quTTx1Olm05fBfv66DEBuOsOgqdypnZ/1Bh3yGXWY7ANLFeeRpCDZpljD9BSjdsNdPOlwJmEUZXMHtGm3v1TZQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.14.0': + resolution: {integrity: sha512-caaNAu+aIqT8seLtCf08i8C3/UC5ttQujUjejhMcuS1/LoCKtNiUs4VekJd2UGt+pyuuSrQ6dKl8CbCfWvWeXw==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.14.0': + resolution: {integrity: sha512-EeW3jFlT3YNckJ6V/JnTfGcX7UHGyh6/AiCPopZ1HNaGiXVCKHPpVQZicmtyr/UpqxCXLrTgjHOvyMke7YN26A==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.14.0': + resolution: {integrity: sha512-dPai3KUIcihV5hfoO4QNQF5HAaw8+2bT7dvi8E5zLtecW2SfL3mUZipzampXq5FHll0RSCLzlrXnSx+dBRZIIQ==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.14.0': + resolution: {integrity: sha512-nm+JajGrTqUA6sEHdghDlHMNfH1WKSiuvljhdmBACW4ta4LC3gKurX2qZuiBARvPkephW9V/i5S8QPY1PzFEqg==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.14.0': + resolution: {integrity: sha512-oExhY90bes5pDTVrei0xlMVosTxwd/NMafIpqsC4dMbRYZ5KB981l/CX8tMnGsagTplj/RcG9BeRYmV6/J5m3w==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.25': + resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + + '@tauri-apps/api@2.9.0': + resolution: {integrity: sha512-qD5tMjh7utwBk9/5PrTA/aGr3i5QaJ/Mlt7p8NilQ45WgbifUNPyKWsA63iQ8YfQq6R8ajMapU+/Q8nMcPRLNw==} + + '@tauri-apps/cli-darwin-arm64@2.9.4': + resolution: {integrity: sha512-9rHkMVtbMhe0AliVbrGpzMahOBg3rwV46JYRELxR9SN6iu1dvPOaMaiC4cP6M/aD1424ziXnnMdYU06RAH8oIw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tauri-apps/cli-darwin-x64@2.8.4': - resolution: {integrity: sha512-imb9PfSd/7G6VAO7v1bQ2A3ZH4NOCbhGJFLchxzepGcXf9NKkfun157JH9mko29K6sqAwuJ88qtzbKCbWJTH9g==} + '@tauri-apps/cli-darwin-x64@2.9.4': + resolution: {integrity: sha512-VT9ymNuT06f5TLjCZW2hfSxbVtZDhORk7CDUDYiq5TiSYQdxkl8MVBy0CCFFcOk4QAkUmqmVUA9r3YZ/N/vPRQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tauri-apps/cli-linux-arm-gnueabihf@2.8.4': - resolution: {integrity: sha512-Ml215UnDdl7/fpOrF1CNovym/KjtUbCuPgrcZ4IhqUCnhZdXuphud/JT3E8X97Y03TZ40Sjz8raXYI2ET0exzw==} + '@tauri-apps/cli-linux-arm-gnueabihf@2.9.4': + resolution: {integrity: sha512-tTWkEPig+2z3Rk0zqZYfjUYcgD+aSm72wdrIhdYobxbQZOBw0zfn50YtWv+av7bm0SHvv75f0l7JuwgZM1HFow==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@2.8.4': - resolution: {integrity: sha512-pbcgBpMyI90C83CxE5REZ9ODyIlmmAPkkJXtV398X3SgZEIYy5TACYqlyyv2z5yKgD8F8WH4/2fek7+jH+ZXAw==} + '@tauri-apps/cli-linux-arm64-gnu@2.9.4': + resolution: {integrity: sha512-ql6vJ611qoqRYHxkKPnb2vHa27U+YRKRmIpLMMBeZnfFtZ938eao7402AQCH1mO2+/8ioUhbpy9R/ZcLTXVmkg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tauri-apps/cli-linux-arm64-musl@2.8.4': - resolution: {integrity: sha512-zumFeaU1Ws5Ay872FTyIm7z8kfzEHu8NcIn8M6TxbJs0a7GRV21KBdpW1zNj2qy7HynnpQCqjAYXTUUmm9JAOw==} + '@tauri-apps/cli-linux-arm64-musl@2.9.4': + resolution: {integrity: sha512-vg7yNn7ICTi6hRrcA/6ff2UpZQP7un3xe3SEld5QM0prgridbKAiXGaCKr3BnUBx/rGXegQlD/wiLcWdiiraSw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tauri-apps/cli-linux-riscv64-gnu@2.8.4': - resolution: {integrity: sha512-qiqbB3Zz6IyO201f+1ojxLj65WYj8mixL5cOMo63nlg8CIzsP23cPYUrx1YaDPsCLszKZo7tVs14pc7BWf+/aQ==} + '@tauri-apps/cli-linux-riscv64-gnu@2.9.4': + resolution: {integrity: sha512-l8L+3VxNk6yv5T/Z/gv5ysngmIpsai40B9p6NQQyqYqxImqYX37pqREoEBl1YwG7szGnDibpWhidPrWKR59OJA==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - '@tauri-apps/cli-linux-x64-gnu@2.8.4': - resolution: {integrity: sha512-TaqaDd9Oy6k45Hotx3pOf+pkbsxLaApv4rGd9mLuRM1k6YS/aw81YrsMryYPThrxrScEIUcmNIHaHsLiU4GMkw==} + '@tauri-apps/cli-linux-x64-gnu@2.9.4': + resolution: {integrity: sha512-PepPhCXc/xVvE3foykNho46OmCyx47E/aG676vKTVp+mqin5d+IBqDL6wDKiGNT5OTTxKEyNlCQ81Xs2BQhhqA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tauri-apps/cli-linux-x64-musl@2.8.4': - resolution: {integrity: sha512-ot9STAwyezN8w+bBHZ+bqSQIJ0qPZFlz/AyscpGqB/JnJQVDFQcRDmUPFEaAtt2UUHSWzN3GoTJ5ypqLBp2WQA==} + '@tauri-apps/cli-linux-x64-musl@2.9.4': + resolution: {integrity: sha512-zcd1QVffh5tZs1u1SCKUV/V7RRynebgYUNWHuV0FsIF1MjnULUChEXhAhug7usCDq4GZReMJOoXa6rukEozWIw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tauri-apps/cli-win32-arm64-msvc@2.8.4': - resolution: {integrity: sha512-+2aJ/g90dhLiOLFSD1PbElXX3SoMdpO7HFPAZB+xot3CWlAZD1tReUFy7xe0L5GAR16ZmrxpIDM9v9gn5xRy/w==} + '@tauri-apps/cli-win32-arm64-msvc@2.9.4': + resolution: {integrity: sha512-/7ZhnP6PY04bEob23q8MH/EoDISdmR1wuNm0k9d5HV7TDMd2GGCDa8dPXA4vJuglJKXIfXqxFmZ4L+J+MO42+w==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@2.8.4': - resolution: {integrity: sha512-yj7WDxkL1t9Uzr2gufQ1Hl7hrHuFKTNEOyascbc109EoiAqCp0tgZ2IykQqOZmZOHU884UAWI1pVMqBhS/BfhA==} + '@tauri-apps/cli-win32-ia32-msvc@2.9.4': + resolution: {integrity: sha512-1LmAfaC4Cq+3O1Ir1ksdhczhdtFSTIV51tbAGtbV/mr348O+M52A/xwCCXQank0OcdBxy5BctqkMtuZnQvA8uQ==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@tauri-apps/cli-win32-x64-msvc@2.8.4': - resolution: {integrity: sha512-XuvGB4ehBdd7QhMZ9qbj/8icGEatDuBNxyYHbLKsTYh90ggUlPa/AtaqcC1Fo69lGkTmq9BOKrs1aWSi7xDonA==} + '@tauri-apps/cli-win32-x64-msvc@2.9.4': + resolution: {integrity: sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tauri-apps/cli@2.8.4': - resolution: {integrity: sha512-ejUZBzuQRcjFV+v/gdj/DcbyX/6T4unZQjMSBZwLzP/CymEjKcc2+Fc8xTORThebHDUvqoXMdsCZt8r+hyN15g==} + '@tauri-apps/cli@2.9.4': + resolution: {integrity: sha512-pvylWC9QckrOS9ATWXIXcgu7g2hKK5xTL5ZQyZU/U0n9l88SEFGcWgLQNa8WZmd+wWIOWhkxOFcOl3i6ubDNNw==} engines: {node: '>= 10'} hasBin: true - '@tauri-apps/plugin-clipboard-manager@2.3.0': - resolution: {integrity: sha512-81NOBA2P+OTY8RLkBwyl9ZR/0CeggLub4F6zxcxUIfFOAqtky7J61+K/MkH2SC1FMxNBxrX0swDuKvkjkHadlA==} + '@tauri-apps/plugin-clipboard-manager@2.3.2': + resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==} - '@tauri-apps/plugin-dialog@2.4.0': - resolution: {integrity: sha512-OvXkrEBfWwtd8tzVCEXIvRfNEX87qs2jv6SqmVPiHcJjBhSF/GUvjqUNIDmKByb5N8nvDqVUM7+g1sXwdC/S9w==} + '@tauri-apps/plugin-dialog@2.4.2': + resolution: {integrity: sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ==} - '@tauri-apps/plugin-fs@2.4.2': - resolution: {integrity: sha512-YGhmYuTgXGsi6AjoV+5mh2NvicgWBfVJHHheuck6oHD+HC9bVWPaHvCP0/Aw4pHDejwrvT8hE3+zZAaWf+hrig==} + '@tauri-apps/plugin-fs@2.4.4': + resolution: {integrity: sha512-MTorXxIRmOnOPT1jZ3w96vjSuScER38ryXY88vl5F0uiKdnvTKKTtaEjTEo8uPbl4e3gnUtfsDVwC7h77GQLvQ==} - '@tauri-apps/plugin-http@2.5.2': - resolution: {integrity: sha512-x1mQKHSLDk4mS2S938OTeyk8L7QyLpCrKZCZcjkljGsvTvRMojCvI9SeJ1kaxc7t8xSilkC7WdId8xER9TIGLg==} + '@tauri-apps/plugin-http@2.5.4': + resolution: {integrity: sha512-/i4U/9za3mrytTgfRn5RHneKubZE/dwRmshYwyMvNRlkWjvu1m4Ma72kcbVJMZFGXpkbl+qLyWMGrihtWB76Zg==} - '@tauri-apps/plugin-process@2.3.0': - resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==} + '@tauri-apps/plugin-process@2.3.1': + resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} - '@tauri-apps/plugin-shell@2.3.1': - resolution: {integrity: sha512-jjs2WGDO/9z2pjNlydY/F5yYhNsscv99K5lCmU5uKjsVvQ3dRlDhhtVYoa4OLDmktLtQvgvbQjCFibMl6tgGfw==} + '@tauri-apps/plugin-shell@2.3.3': + resolution: {integrity: sha512-Xod+pRcFxmOWFWEnqH5yZcA7qwAMuaaDkMR1Sply+F8VfBj++CGnj2xf5UoialmjZ2Cvd8qrvSCbU+7GgNVsKQ==} '@tauri-apps/plugin-updater@2.9.0': resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==} @@ -1714,21 +1789,15 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.20.7': - resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1762,14 +1831,17 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} - '@types/react-dom@19.2.1': - resolution: {integrity: sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: '@types/react': ^19.2.0 @@ -1778,8 +1850,8 @@ packages: peerDependencies: '@types/react': '*' - '@types/react@19.2.2': - resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==} + '@types/react@19.2.4': + resolution: {integrity: sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A==} '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1787,63 +1859,63 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@typescript-eslint/eslint-plugin@8.46.0': - resolution: {integrity: sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==} + '@typescript-eslint/eslint-plugin@8.46.4': + resolution: {integrity: sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.46.0 + '@typescript-eslint/parser': ^8.46.4 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.46.0': - resolution: {integrity: sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==} + '@typescript-eslint/parser@8.46.4': + resolution: {integrity: sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.46.0': - resolution: {integrity: sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==} + '@typescript-eslint/project-service@8.46.4': + resolution: {integrity: sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.46.0': - resolution: {integrity: sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==} + '@typescript-eslint/scope-manager@8.46.4': + resolution: {integrity: sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.46.0': - resolution: {integrity: sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==} + '@typescript-eslint/tsconfig-utils@8.46.4': + resolution: {integrity: sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.46.0': - resolution: {integrity: sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==} + '@typescript-eslint/type-utils@8.46.4': + resolution: {integrity: sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.46.0': - resolution: {integrity: sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==} + '@typescript-eslint/types@8.46.4': + resolution: {integrity: sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.46.0': - resolution: {integrity: sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==} + '@typescript-eslint/typescript-estree@8.46.4': + resolution: {integrity: sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.46.0': - resolution: {integrity: sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==} + '@typescript-eslint/utils@8.46.4': + resolution: {integrity: sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.46.0': - resolution: {integrity: sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==} + '@typescript-eslint/visitor-keys@8.46.4': + resolution: {integrity: sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -1951,11 +2023,40 @@ packages: terser: ^5.16.0 vite: ^7.0.0 - '@vitejs/plugin-react@5.0.4': - resolution: {integrity: sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==} + '@vitejs/plugin-react-swc@4.2.2': + resolution: {integrity: sha512-x+rE6tsxq/gxrEJN3Nv3dIV60lFflPj94c90b+NNo6n1QV1QQUTLoL0MpaOVasUZ0zqVBn7ead1B5ecx1JAGfA==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vite: ^4 || ^5 || ^6 || ^7 + + '@vitest/expect@4.0.9': + resolution: {integrity: sha512-C2vyXf5/Jfj1vl4DQYxjib3jzyuswMi/KHHVN2z+H4v16hdJ7jMZ0OGe3uOVIt6LyJsAofDdaJNIFEpQcrSTFw==} + + '@vitest/mocker@4.0.9': + resolution: {integrity: sha512-PUyaowQFHW+9FKb4dsvvBM4o025rWMlEDXdWRxIOilGaHREYTi5Q2Rt9VCgXgPy/hHZu1LeuXtrA/GdzOatP2g==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.9': + resolution: {integrity: sha512-Hor0IBTwEi/uZqB7pvGepyElaM8J75pYjrrqbC8ZYMB9/4n5QA63KC15xhT+sqHpdGWfdnPo96E8lQUxs2YzSQ==} + + '@vitest/runner@4.0.9': + resolution: {integrity: sha512-aF77tsXdEvIJRkj9uJZnHtovsVIx22Ambft9HudC+XuG/on1NY/bf5dlDti1N35eJT+QZLb4RF/5dTIG18s98w==} + + '@vitest/snapshot@4.0.9': + resolution: {integrity: sha512-r1qR4oYstPbnOjg0Vgd3E8ADJbi4ditCzqr+Z9foUrRhIy778BleNyZMeAJ2EjV+r4ASAaDsdciC9ryMy8xMMg==} + + '@vitest/spy@4.0.9': + resolution: {integrity: sha512-J9Ttsq0hDXmxmT8CUOWUr1cqqAj2FJRGTdyEjSR+NjoOGKEqkEWj+09yC0HhI8t1W6t4Ctqawl1onHgipJve1A==} + + '@vitest/utils@4.0.9': + resolution: {integrity: sha512-cEol6ygTzY4rUPvNZM19sDf7zGa35IYTm9wfzkHoT/f5jX10IOY7QleWSOh5T0e3I3WVozwK5Asom79qW8DiuQ==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -1975,9 +2076,8 @@ packages: resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} engines: {node: '>= 14'} - ahooks@3.9.5: - resolution: {integrity: sha512-TrjXie49Q8HuHKTa84Fm9A+famMDAG1+7a9S9Gq6RQ0h90Jgqmiq3CkObuRjWT/C4d6nRZCw35Y2k2fmybb5eA==} - engines: {node: '>=18'} + ahooks@3.9.6: + resolution: {integrity: sha512-Mr7f05swd5SmKlR9SZo5U6M0LsL4ErweLzpdgXjA1JPmnZ78Vr6wzx0jUtvoxrcqGKYnX0Yjc02iEASVxHFPjQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -2043,8 +2143,8 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axios@1.12.2: - resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} @@ -2128,6 +2228,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.0: + resolution: {integrity: sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2188,8 +2292,8 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - commander@14.0.1: - resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} + commander@14.0.2: + resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} commander@2.20.3: @@ -2266,8 +2370,8 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} - dayjs@1.11.18: - resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} @@ -2277,8 +2381,8 @@ packages: supports-color: optional: true - debug@4.4.1: - resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -2374,6 +2478,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2506,61 +2613,54 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-react-debug@2.0.6: - resolution: {integrity: sha512-uUqjSApa9nGQh9VEVktp7j4T/wK0WbJVQ5phHwcFQcSVOfEr9G6ISjpjFG/lJ70SIX3v3rAOrF7GvEZWA7MxcQ==} + eslint-plugin-react-dom@2.3.5: + resolution: {integrity: sha512-SsIF5HbsXLJcbEoFbzgabqA7DOnfGd0BhD7QzZd5tqgz4gL2j2mUGCBbQjQIE0BMbKtOihbhuceQfQ/QxoJJIg==} engines: {node: '>=20.19.0'} peerDependencies: - eslint: ^9.36.0 + eslint: ^9.39.1 typescript: ^5.9.3 - eslint-plugin-react-dom@2.0.6: - resolution: {integrity: sha512-PQzi+KksafJsoRN9gyM+a9JMJsvHxy6vCzr2c/eZvSluRDFxyWWwIRPCHijus0I8hlh0fyOOhR1S3DU2c05Zuw==} - engines: {node: '>=20.19.0'} - peerDependencies: - eslint: ^9.36.0 - typescript: ^5.9.3 - - eslint-plugin-react-hooks-extra@2.0.6: - resolution: {integrity: sha512-Dg82EIM+4J+Ideb61Hc+vnwGS5icBVB+D2uqINqEngxmDCDhubM+zTsjYJd9qeuiAQ3e7qM8A1GPHkHyrGW5bQ==} + eslint-plugin-react-hooks-extra@2.3.5: + resolution: {integrity: sha512-IxPs6O/XCpm8FAv38TyJKcHkeS/qNb97PdbH1OqHbf4BAT/QTInWweNEpePiyydQ0YuLvHqTo1dreY8Jj6Re3A==} engines: {node: '>=20.0.0'} peerDependencies: - eslint: ^9.36.0 + eslint: ^9.39.1 typescript: ^5.9.3 - eslint-plugin-react-hooks@7.0.0: - resolution: {integrity: sha512-fNXaOwvKwq2+pXiRpXc825Vd63+KM4DLL40Rtlycb8m7fYpp6efrTp1sa6ZbP/Ap58K2bEKFXRmhURE+CJAQWw==} + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} engines: {node: '>=18'} peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - eslint-plugin-react-naming-convention@2.0.6: - resolution: {integrity: sha512-BWgXnegBQU0GbOIvQRVKjXak//hOQLfiNXGO7vJ7MBN2DVEOEL8UK52YfxWW4dQYa7q4NNTqy7UOkK9sgpVw6g==} + eslint-plugin-react-naming-convention@2.3.5: + resolution: {integrity: sha512-sjKvdJq90HWNYRBtwia7C/N8NXdg+k8O7ikQqf6QsOuTUHGLgFWGtxx1AktfizlSusCdb96w5LJ4MSi+KsuVZg==} engines: {node: '>=20.19.0'} peerDependencies: - eslint: ^9.36.0 + eslint: ^9.39.1 typescript: ^5.9.3 - eslint-plugin-react-refresh@0.4.23: - resolution: {integrity: sha512-G4j+rv0NmbIR45kni5xJOrYvCtyD3/7LjpVH8MPPcudXDcNu8gv+4ATTDXTtbRR8rTCM5HxECvCSsRmxKnWDsA==} + eslint-plugin-react-refresh@0.4.24: + resolution: {integrity: sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==} peerDependencies: eslint: '>=8.40' - eslint-plugin-react-web-api@2.0.6: - resolution: {integrity: sha512-NGH3mUJ/L3OD12g0RaNQP5ofripIFnVuGGbOrENw9ret6Q7XGNDALHjsLxYCmFJAcHXun0W4DKmH1mHm8hyrIQ==} + eslint-plugin-react-web-api@2.3.5: + resolution: {integrity: sha512-wY/hNWQxshTZ2niuu8QcARQuDg5w+cEA2OYtnrnPDjhy0qxikAaYA4NUx7HTAXoMC1Kxl78+NbQBBXnlwoMAZA==} engines: {node: '>=20.19.0'} peerDependencies: - eslint: ^9.36.0 + eslint: ^9.39.1 typescript: ^5.9.3 - eslint-plugin-react-x@2.0.6: - resolution: {integrity: sha512-x8i52RElVgIUxSneM4uC+nkQcBGEFkiXAD8/QgxBbrfm0VKZ1748IBuNrp7125Ubx3JDlqJfJz1iEPJCyhvdAA==} + eslint-plugin-react-x@2.3.5: + resolution: {integrity: sha512-Yj+6e2ds6Gg3KRPgNdifincu3cuxDYPcboCXc5EGHC//6JZXRgtqQ3N5uP9RVHnCHmKF2EiZ76XyPDnp4hMgEg==} engines: {node: '>=20.19.0'} peerDependencies: - eslint: ^9.36.0 + eslint: ^9.39.1 typescript: ^5.9.3 - eslint-plugin-unused-imports@4.2.0: - resolution: {integrity: sha512-hLbJ2/wnjKq4kGA9AUaExVFIbNzyxYdVo49QZmKCnhk5pc9wcYRbfgLHvWJ8tnsdcseGhoUAddm9gn/lt+d74w==} + eslint-plugin-unused-imports@4.3.0: + resolution: {integrity: sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA==} peerDependencies: '@typescript-eslint/eslint-plugin': ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0 eslint: ^9.0.0 || ^8.0.0 @@ -2580,8 +2680,8 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.37.0: - resolution: {integrity: sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==} + eslint@9.39.1: + resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -2616,6 +2716,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2626,6 +2729,10 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + ext@1.7.0: resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} @@ -2774,8 +2881,8 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} - globals@16.4.0: - resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} globalthis@1.0.4: @@ -2846,8 +2953,8 @@ packages: engines: {node: '>=18'} hasBin: true - i18next@25.6.0: - resolution: {integrity: sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw==} + i18next@25.6.2: + resolution: {integrity: sha512-0GawNyVUw0yvJoOEBq1VHMAsqdM23XrHkMtl2gKEjviJQSLVXsrPqsoYAxBEugW5AB96I2pZkwRxyl8WZVoWdw==} peerDependencies: typescript: ^5 peerDependenciesMeta: @@ -2882,6 +2989,7 @@ packages: intersection-observer@0.12.2: resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==} + deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019. is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -3043,8 +3151,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true jsesc@3.0.2: @@ -3094,13 +3202,13 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - lint-staged@16.2.4: - resolution: {integrity: sha512-Pkyr/wd90oAyXk98i/2KwfkIhoYQUMtss769FIT9hFM5ogYZwrk+GRE46yKXSg2ZGhcJ1p38Gf5gmI5Ohjg2yg==} + lint-staged@16.2.6: + resolution: {integrity: sha512-s1gphtDbV4bmW1eylXpVMk2u7is7YsrLl8hzrtvC70h4ByhcMLZFY01Fx05ZUDNuv1H8HO4E+e2zgejV1jVwNw==} engines: {node: '>=20.17'} hasBin: true - listr2@9.0.4: - resolution: {integrity: sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ==} + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} engines: {node: '>=20.0.0'} locate-path@6.0.0: @@ -3143,8 +3251,8 @@ packages: lru-queue@0.1.0: resolution: {integrity: sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==} - magic-string@0.30.17: - resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} marked@14.0.0: resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} @@ -3191,8 +3299,8 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - meta-json-schema@1.19.14: - resolution: {integrity: sha512-A+NSHAfXWn2T225dawVuLXVXrSWhxjRNiG+nS+Cet1Zovslrq2lMqvkIrXhdaK6Gv+VYrEV8rAkYcqAz2pxKMw==} + meta-json-schema@1.19.16: + resolution: {integrity: sha512-Py3XR3VRXs3tAMg3sy7fmex8IU4p4FTxVbF86WTtssWpFcSNbBUjk0QjpdhGrh+9qPMSwCJY1drXnvgDq9XQ7Q==} engines: {node: '>=18', pnpm: '>=9'} micromark-core-commonmark@2.0.3: @@ -3451,6 +3559,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3517,16 +3628,16 @@ packages: react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} - react-hook-form@7.65.0: - resolution: {integrity: sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==} + react-hook-form@7.66.0: + resolution: {integrity: sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==} engines: {node: '>=18.0.0'} peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 - react-i18next@16.0.0: - resolution: {integrity: sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q==} + react-i18next@16.3.3: + resolution: {integrity: sha512-IaY2W+ueVd/fe7H6Wj2S4bTuLNChnajFUlZFfCTrTHWzGcOrUHlVzW55oXRSl+J51U8Onn6EvIhQ+Bar9FUcjw==} peerDependencies: - i18next: '>= 25.5.2' + i18next: '>= 25.6.2' react: '>= 16.8.0' react-dom: '*' react-native: '*' @@ -3542,8 +3653,8 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-is@19.1.1: - resolution: {integrity: sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==} + react-is@19.2.0: + resolution: {integrity: sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==} react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} @@ -3551,26 +3662,8 @@ packages: '@types/react': '>=18' react: '>=18' - react-monaco-editor@0.59.0: - resolution: {integrity: sha512-SggqfZCdUauNk7GI0388bk5n25zYsQ1ai1i+VhxAgwbCH+MTGl7L1fBNTJ6V+oXeUApf+bpzikprHJEZm9J/zA==} - peerDependencies: - monaco-editor: ^0.52.0 - react: '>=16.8.0 <20.0.0' - react-dom: '>=16.8.0 <20.0.0' - - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} - engines: {node: '>=0.10.0'} - - react-router-dom@7.9.4: - resolution: {integrity: sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==} - engines: {node: '>=20.0.0'} - peerDependencies: - react: '>=18' - react-dom: '>=18' - - react-router@7.9.4: - resolution: {integrity: sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==} + react-router@7.9.6: + resolution: {integrity: sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -3683,8 +3776,8 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} - sass@1.93.2: - resolution: {integrity: sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==} + sass@1.94.0: + resolution: {integrity: sha512-Dqh7SiYcaFtdv5Wvku6QgS5IGPm281L+ZtVD1U2FJa7Q0EFRlq8Z3sjYtz6gYObsYThUOz9ArwFqPZx+1azILQ==} engines: {node: '>=14.0.0'} hasBin: true @@ -3746,6 +3839,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3779,6 +3875,15 @@ packages: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -3869,16 +3974,16 @@ packages: systemjs@6.15.1: resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==} - tar@7.5.1: - resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==} + tar@7.5.2: + resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==} engines: {node: '>=18'} - tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/7818a0a8f55c03ee7eba3fd7433faef29adf38ac: - resolution: {tarball: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/7818a0a8f55c03ee7eba3fd7433faef29adf38ac} + tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/1e2ada19e20c5504a2a8cef367e87c667806dd65: + resolution: {tarball: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/1e2ada19e20c5504a2a8cef367e87c667806dd65} version: 0.1.0 - terser@5.44.0: - resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} + terser@5.44.1: + resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==} engines: {node: '>=10'} hasBin: true @@ -3886,10 +3991,20 @@ packages: resolution: {integrity: sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==} engines: {node: '>=0.12'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3911,8 +4026,8 @@ packages: peerDependencies: typescript: '>=4.0.0' - ts-pattern@5.8.0: - resolution: {integrity: sha512-kIjN2qmWiHnhgr5DAkAafF9fwb0T5OhMVSWrm8XEdTFnX6+wfXwYOFjeF86UZ54vduqiR7BfqScFmXSzSaH8oA==} + ts-pattern@5.9.0: + resolution: {integrity: sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==} tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -3950,8 +4065,8 @@ packages: types-pac@1.0.3: resolution: {integrity: sha512-MF2UAZGvGMOM+vHi9Zj/LvQqdNN1m1xSB+PjAW9B/GvFqaB4GwR18YaIbGIGDRTW/J8iqFXQHLZd5eJVtho46w==} - typescript-eslint@8.46.0: - resolution: {integrity: sha512-6+ZrB6y2bT2DX3K+Qd9vn7OFOJR+xSLDj+Aw/N3zBwUt27uTw2sw2TE2+UcY1RiyBZkaGbTkVg9SSdPNUG6aUw==} + typescript-eslint@8.46.4: + resolution: {integrity: sha512-KALyxkpYV5Ix7UhvjTwJXZv76VWsHG+NjNlt/z+a17SOQSiOcBdUXdbJdyXi7RPxrBFECtFOiPwUJQusJuCqrg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -3966,6 +4081,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@5.29.0: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} @@ -4030,8 +4148,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite-plugin-monaco-editor@1.1.0: - resolution: {integrity: sha512-IvtUqZotrRoVqwT0PBBDIZPNraya3BxN/bfcNfnxZ5rkJiGcNtO5eAOWWSgT7zullIAEqQwxMU83yL9J5k7gww==} + vite-plugin-monaco-editor-esm@2.0.2: + resolution: {integrity: sha512-XVkOpL/r0rw1NpbO30vUwG4S0THkC9KB1vjjV8olGd49h4/EQsKl3DrxB6KRDwyZNC9mKiiZgk2L6njUYj3oKQ==} peerDependencies: monaco-editor: '>=0.33.0' @@ -4040,8 +4158,8 @@ packages: peerDependencies: vite: '>=2.6.0' - vite@7.1.9: - resolution: {integrity: sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==} + vite@7.2.2: + resolution: {integrity: sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -4080,6 +4198,40 @@ packages: yaml: optional: true + vitest@4.0.9: + resolution: {integrity: sha512-E0Ja2AX4th+CG33yAFRC+d1wFx2pzU5r6HtG6LiPSE04flaE0qB6YyjSw9ZcpJAtVPfsvZGtJlKWZpuW7EHRxg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.9 + '@vitest/browser-preview': 4.0.9 + '@vitest/browser-webdriverio': 4.0.9 + '@vitest/ui': 4.0.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -4125,6 +4277,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -4170,26 +4327,8 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@4.1.11: - resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} - - zustand@5.0.8: - resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==} - engines: {node: '>=12.20.0'} - peerDependencies: - '@types/react': '>=18.0.0' - immer: '>=9.0.6' - react: '>=18.0.0' - use-sync-external-store: '>=1.2.0' - peerDependenciesMeta: - '@types/react': - optional: true - immer: - optional: true - react: - optional: true - use-sync-external-store: - optional: true + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -4232,7 +4371,7 @@ snapshots: '@babel/types': 7.28.4 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -4284,7 +4423,7 @@ snapshots: '@babel/core': 7.28.4 '@babel/helper-compilation-targets': 7.27.2 '@babel/helper-plugin-utils': 7.27.1 - debug: 4.4.1 + debug: 4.4.3 lodash.debounce: 4.0.8 resolve: 1.22.10 transitivePeerDependencies: @@ -4690,16 +4829,6 @@ snapshots: '@babel/core': 7.28.4 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-regenerator@7.28.1(@babel/core@7.28.4)': dependencies: '@babel/core': 7.28.4 @@ -4866,7 +4995,7 @@ snapshots: '@babel/parser': 7.28.4 '@babel/template': 7.27.2 '@babel/types': 7.28.4 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -4948,7 +5077,7 @@ snapshots: '@emotion/memoize@0.9.0': {} - '@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0)': + '@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 '@emotion/babel-plugin': 11.13.5 @@ -4960,7 +5089,7 @@ snapshots: hoist-non-react-statics: 3.3.2 react: 19.2.0 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.4 transitivePeerDependencies: - supports-color @@ -4974,18 +5103,18 @@ snapshots: '@emotion/sheet@1.4.0': {} - '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)': + '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 '@emotion/babel-plugin': 11.13.5 '@emotion/is-prop-valid': 1.3.1 - '@emotion/react': 11.14.0(@types/react@19.2.2)(react@19.2.0) + '@emotion/react': 11.14.0(@types/react@19.2.4)(react@19.2.0) '@emotion/serialize': 1.3.3 '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.0) '@emotion/utils': 1.4.2 react: 19.2.0 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.4 transitivePeerDependencies: - supports-color @@ -5076,136 +5205,123 @@ snapshots: '@esbuild/win32-x64@0.25.4': optional: true - '@eslint-community/eslint-utils@4.8.0(eslint@9.37.0(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.8.0(eslint@9.39.1(jiti@2.6.1))': dependencies: - eslint: 9.37.0(jiti@2.6.1) + eslint: 9.39.1(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} - '@eslint-react/ast@2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': + '@eslint-react/ast@2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-react/eff': 2.0.6 - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/typescript-estree': 8.46.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/eff': 2.3.5 + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) string-ts: 2.2.1 transitivePeerDependencies: - eslint - supports-color - typescript - '@eslint-react/core@2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': + '@eslint-react/core@2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-react/ast': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/eff': 2.0.6 - '@eslint-react/kit': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/shared': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.0 - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/eff': 2.3.5 + '@eslint-react/shared': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/var': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.4 + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) birecord: 0.1.1 - ts-pattern: 5.8.0 + ts-pattern: 5.9.0 transitivePeerDependencies: - eslint - supports-color - typescript - '@eslint-react/eff@2.0.6': {} + '@eslint-react/eff@2.3.5': {} - '@eslint-react/eslint-plugin@2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': + '@eslint-react/eslint-plugin@2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-react/eff': 2.0.6 - '@eslint-react/kit': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/shared': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.0 - '@typescript-eslint/type-utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.37.0(jiti@2.6.1) - eslint-plugin-react-debug: 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-react-dom: 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-react-hooks-extra: 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-react-naming-convention: 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-react-web-api: 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-react-x: 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/eff': 2.3.5 + '@eslint-react/shared': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.4 + '@typescript-eslint/type-utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) + eslint-plugin-react-dom: 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-react-hooks-extra: 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-react-naming-convention: 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-react-web-api: 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-react-x: 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@eslint-react/kit@2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': + '@eslint-react/shared@2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-react/eff': 2.0.6 - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/eff': 2.3.5 + '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + ts-pattern: 5.9.0 + zod: 4.1.12 transitivePeerDependencies: - eslint - supports-color - typescript - '@eslint-react/shared@2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': + '@eslint-react/var@2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-react/eff': 2.0.6 - '@eslint-react/kit': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - ts-pattern: 5.8.0 - zod: 4.1.11 + '@eslint-react/ast': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/eff': 2.3.5 + '@typescript-eslint/scope-manager': 8.46.4 + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + ts-pattern: 5.9.0 transitivePeerDependencies: - eslint - supports-color - typescript - '@eslint-react/var@2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': + '@eslint/config-array@0.21.1': dependencies: - '@eslint-react/ast': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/eff': 2.0.6 - '@typescript-eslint/scope-manager': 8.46.0 - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - ts-pattern: 5.8.0 - transitivePeerDependencies: - - eslint - - supports-color - - typescript - - '@eslint/config-array@0.21.0': - dependencies: - '@eslint/object-schema': 2.1.6 - debug: 4.4.1 + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 minimatch: 3.1.2 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.4.0': + '@eslint/config-helpers@0.4.2': dependencies: - '@eslint/core': 0.16.0 + '@eslint/core': 0.17.0 - '@eslint/core@0.16.0': + '@eslint/core@0.17.0': dependencies: '@types/json-schema': 7.0.15 '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 - debug: 4.4.1 + debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@9.37.0': {} + '@eslint/js@9.39.1': {} - '@eslint/object-schema@2.1.6': {} + '@eslint/object-schema@2.1.7': {} - '@eslint/plugin-kit@0.4.0': + '@eslint/plugin-kit@0.4.1': dependencies: - '@eslint/core': 0.16.0 + '@eslint/core': 0.17.0 levn: 0.4.1 '@fastify/busboy@2.1.1': {} @@ -5244,7 +5360,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.12': dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.29 '@jridgewell/remapping@2.3.5': @@ -5259,72 +5375,83 @@ snapshots: '@jridgewell/gen-mapping': 0.3.12 '@jridgewell/trace-mapping': 0.3.29 - '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.29': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@juggle/resize-observer@3.4.0': {} - '@mui/core-downloads-tracker@7.3.4': {} + '@monaco-editor/loader@1.6.1': + dependencies: + state-local: 1.0.7 - '@mui/icons-material@7.3.4(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)': + '@monaco-editor/react@4.7.0(monaco-editor@0.54.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@monaco-editor/loader': 1.6.1 + monaco-editor: 0.54.0 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@mui/core-downloads-tracker@7.3.5': {} + + '@mui/icons-material@7.3.5(@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.4)(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 - '@mui/material': 7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@mui/material': 7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.4 - '@mui/lab@7.0.0-beta.17(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@mui/lab@7.0.0-beta.17(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 - '@mui/material': 7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@mui/system': 7.3.3(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) - '@mui/types': 7.4.7(@types/react@19.2.2) - '@mui/utils': 7.3.3(@types/react@19.2.2)(react@19.2.0) + '@mui/material': 7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@mui/system': 7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0) + '@mui/types': 7.4.8(@types/react@19.2.4) + '@mui/utils': 7.3.5(@types/react@19.2.4)(react@19.2.0) clsx: 2.1.1 prop-types: 15.8.1 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) optionalDependencies: - '@emotion/react': 11.14.0(@types/react@19.2.2)(react@19.2.0) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) - '@types/react': 19.2.2 + '@emotion/react': 11.14.0(@types/react@19.2.4)(react@19.2.0) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0) + '@types/react': 19.2.4 - '@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 - '@mui/core-downloads-tracker': 7.3.4 - '@mui/system': 7.3.3(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) - '@mui/types': 7.4.7(@types/react@19.2.2) - '@mui/utils': 7.3.3(@types/react@19.2.2)(react@19.2.0) + '@mui/core-downloads-tracker': 7.3.5 + '@mui/system': 7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0) + '@mui/types': 7.4.8(@types/react@19.2.4) + '@mui/utils': 7.3.5(@types/react@19.2.4)(react@19.2.0) '@popperjs/core': 2.11.8 - '@types/react-transition-group': 4.4.12(@types/react@19.2.2) + '@types/react-transition-group': 4.4.12(@types/react@19.2.4) clsx: 2.1.1 csstype: 3.1.3 prop-types: 15.8.1 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - react-is: 19.1.1 + react-is: 19.2.0 react-transition-group: 4.4.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) optionalDependencies: - '@emotion/react': 11.14.0(@types/react@19.2.2)(react@19.2.0) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) - '@types/react': 19.2.2 + '@emotion/react': 11.14.0(@types/react@19.2.4)(react@19.2.0) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0) + '@types/react': 19.2.4 - '@mui/private-theming@7.3.3(@types/react@19.2.2)(react@19.2.0)': + '@mui/private-theming@7.3.5(@types/react@19.2.4)(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 - '@mui/utils': 7.3.3(@types/react@19.2.2)(react@19.2.0) + '@mui/utils': 7.3.5(@types/react@19.2.4)(react@19.2.0) prop-types: 15.8.1 react: 19.2.0 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.4 - '@mui/styled-engine@7.3.3(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)': + '@mui/styled-engine@7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 '@emotion/cache': 11.14.0 @@ -5334,77 +5461,77 @@ snapshots: prop-types: 15.8.1 react: 19.2.0 optionalDependencies: - '@emotion/react': 11.14.0(@types/react@19.2.2)(react@19.2.0) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) + '@emotion/react': 11.14.0(@types/react@19.2.4)(react@19.2.0) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0) - '@mui/system@7.3.3(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)': + '@mui/system@7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 - '@mui/private-theming': 7.3.3(@types/react@19.2.2)(react@19.2.0) - '@mui/styled-engine': 7.3.3(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(react@19.2.0) - '@mui/types': 7.4.7(@types/react@19.2.2) - '@mui/utils': 7.3.3(@types/react@19.2.2)(react@19.2.0) + '@mui/private-theming': 7.3.5(@types/react@19.2.4)(react@19.2.0) + '@mui/styled-engine': 7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(react@19.2.0) + '@mui/types': 7.4.8(@types/react@19.2.4) + '@mui/utils': 7.3.5(@types/react@19.2.4)(react@19.2.0) clsx: 2.1.1 csstype: 3.1.3 prop-types: 15.8.1 react: 19.2.0 optionalDependencies: - '@emotion/react': 11.14.0(@types/react@19.2.2)(react@19.2.0) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) - '@types/react': 19.2.2 + '@emotion/react': 11.14.0(@types/react@19.2.4)(react@19.2.0) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0) + '@types/react': 19.2.4 - '@mui/types@7.4.7(@types/react@19.2.2)': + '@mui/types@7.4.8(@types/react@19.2.4)': dependencies: '@babel/runtime': 7.28.4 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.4 - '@mui/utils@7.3.3(@types/react@19.2.2)(react@19.2.0)': + '@mui/utils@7.3.5(@types/react@19.2.4)(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 - '@mui/types': 7.4.7(@types/react@19.2.2) + '@mui/types': 7.4.8(@types/react@19.2.4) '@types/prop-types': 15.7.15 clsx: 2.1.1 prop-types: 15.8.1 react: 19.2.0 - react-is: 19.1.1 + react-is: 19.2.0 optionalDependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.4 - '@mui/x-data-grid@8.14.0(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mui/system@7.3.3(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@mui/x-data-grid@8.18.0(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mui/system@7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 - '@mui/material': 7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@mui/system': 7.3.3(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) - '@mui/utils': 7.3.3(@types/react@19.2.2)(react@19.2.0) - '@mui/x-internals': 8.14.0(@types/react@19.2.2)(react@19.2.0) - '@mui/x-virtualizer': 0.2.3(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@mui/material': 7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@mui/system': 7.3.5(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0) + '@mui/utils': 7.3.5(@types/react@19.2.4)(react@19.2.0) + '@mui/x-internals': 8.18.0(@types/react@19.2.4)(react@19.2.0) + '@mui/x-virtualizer': 0.2.8(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) clsx: 2.1.1 prop-types: 15.8.1 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) use-sync-external-store: 1.6.0(react@19.2.0) optionalDependencies: - '@emotion/react': 11.14.0(@types/react@19.2.2)(react@19.2.0) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) + '@emotion/react': 11.14.0(@types/react@19.2.4)(react@19.2.0) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.4)(react@19.2.0))(@types/react@19.2.4)(react@19.2.0) transitivePeerDependencies: - '@types/react' - '@mui/x-internals@8.14.0(@types/react@19.2.2)(react@19.2.0)': + '@mui/x-internals@8.18.0(@types/react@19.2.4)(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 - '@mui/utils': 7.3.3(@types/react@19.2.2)(react@19.2.0) + '@mui/utils': 7.3.5(@types/react@19.2.4)(react@19.2.0) react: 19.2.0 reselect: 5.1.1 use-sync-external-store: 1.6.0(react@19.2.0) transitivePeerDependencies: - '@types/react' - '@mui/x-virtualizer@0.2.3(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@mui/x-virtualizer@0.2.8(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 - '@mui/utils': 7.3.3(@types/react@19.2.2)(react@19.2.0) - '@mui/x-internals': 8.14.0(@types/react@19.2.2)(react@19.2.0) + '@mui/utils': 7.3.5(@types/react@19.2.4)(react@19.2.0) + '@mui/x-internals': 8.18.0(@types/react@19.2.4)(react@19.2.0) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) transitivePeerDependencies: @@ -5552,7 +5679,7 @@ snapshots: '@popperjs/core@2.11.8': {} - '@rolldown/pluginutils@1.0.0-beta.38': {} + '@rolldown/pluginutils@1.0.0-beta.47': {} '@rollup/pluginutils@5.2.0(rollup@4.46.2)': dependencies: @@ -5625,6 +5752,8 @@ snapshots: '@rtsao/scc@1.1.0': optional: true + '@standard-schema/spec@1.0.0': {} + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.28.4)': dependencies: '@babel/core': 7.28.4 @@ -5695,113 +5824,150 @@ snapshots: transitivePeerDependencies: - supports-color - '@tauri-apps/api@2.8.0': {} - - '@tauri-apps/cli-darwin-arm64@2.8.4': + '@swc/core-darwin-arm64@1.14.0': optional: true - '@tauri-apps/cli-darwin-x64@2.8.4': + '@swc/core-darwin-x64@1.14.0': optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@2.8.4': + '@swc/core-linux-arm-gnueabihf@1.14.0': optional: true - '@tauri-apps/cli-linux-arm64-gnu@2.8.4': + '@swc/core-linux-arm64-gnu@1.14.0': optional: true - '@tauri-apps/cli-linux-arm64-musl@2.8.4': + '@swc/core-linux-arm64-musl@1.14.0': optional: true - '@tauri-apps/cli-linux-riscv64-gnu@2.8.4': + '@swc/core-linux-x64-gnu@1.14.0': optional: true - '@tauri-apps/cli-linux-x64-gnu@2.8.4': + '@swc/core-linux-x64-musl@1.14.0': optional: true - '@tauri-apps/cli-linux-x64-musl@2.8.4': + '@swc/core-win32-arm64-msvc@1.14.0': optional: true - '@tauri-apps/cli-win32-arm64-msvc@2.8.4': + '@swc/core-win32-ia32-msvc@1.14.0': optional: true - '@tauri-apps/cli-win32-ia32-msvc@2.8.4': + '@swc/core-win32-x64-msvc@1.14.0': optional: true - '@tauri-apps/cli-win32-x64-msvc@2.8.4': - optional: true - - '@tauri-apps/cli@2.8.4': + '@swc/core@1.14.0': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.25 optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.8.4 - '@tauri-apps/cli-darwin-x64': 2.8.4 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.8.4 - '@tauri-apps/cli-linux-arm64-gnu': 2.8.4 - '@tauri-apps/cli-linux-arm64-musl': 2.8.4 - '@tauri-apps/cli-linux-riscv64-gnu': 2.8.4 - '@tauri-apps/cli-linux-x64-gnu': 2.8.4 - '@tauri-apps/cli-linux-x64-musl': 2.8.4 - '@tauri-apps/cli-win32-arm64-msvc': 2.8.4 - '@tauri-apps/cli-win32-ia32-msvc': 2.8.4 - '@tauri-apps/cli-win32-x64-msvc': 2.8.4 + '@swc/core-darwin-arm64': 1.14.0 + '@swc/core-darwin-x64': 1.14.0 + '@swc/core-linux-arm-gnueabihf': 1.14.0 + '@swc/core-linux-arm64-gnu': 1.14.0 + '@swc/core-linux-arm64-musl': 1.14.0 + '@swc/core-linux-x64-gnu': 1.14.0 + '@swc/core-linux-x64-musl': 1.14.0 + '@swc/core-win32-arm64-msvc': 1.14.0 + '@swc/core-win32-ia32-msvc': 1.14.0 + '@swc/core-win32-x64-msvc': 1.14.0 - '@tauri-apps/plugin-clipboard-manager@2.3.0': - dependencies: - '@tauri-apps/api': 2.8.0 + '@swc/counter@0.1.3': {} - '@tauri-apps/plugin-dialog@2.4.0': + '@swc/types@0.1.25': dependencies: - '@tauri-apps/api': 2.8.0 + '@swc/counter': 0.1.3 - '@tauri-apps/plugin-fs@2.4.2': - dependencies: - '@tauri-apps/api': 2.8.0 + '@tauri-apps/api@2.9.0': {} - '@tauri-apps/plugin-http@2.5.2': - dependencies: - '@tauri-apps/api': 2.8.0 + '@tauri-apps/cli-darwin-arm64@2.9.4': + optional: true - '@tauri-apps/plugin-process@2.3.0': - dependencies: - '@tauri-apps/api': 2.8.0 + '@tauri-apps/cli-darwin-x64@2.9.4': + optional: true - '@tauri-apps/plugin-shell@2.3.1': + '@tauri-apps/cli-linux-arm-gnueabihf@2.9.4': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.9.4': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.9.4': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.9.4': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.9.4': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.9.4': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.9.4': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.9.4': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.9.4': + optional: true + + '@tauri-apps/cli@2.9.4': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.9.4 + '@tauri-apps/cli-darwin-x64': 2.9.4 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.9.4 + '@tauri-apps/cli-linux-arm64-gnu': 2.9.4 + '@tauri-apps/cli-linux-arm64-musl': 2.9.4 + '@tauri-apps/cli-linux-riscv64-gnu': 2.9.4 + '@tauri-apps/cli-linux-x64-gnu': 2.9.4 + '@tauri-apps/cli-linux-x64-musl': 2.9.4 + '@tauri-apps/cli-win32-arm64-msvc': 2.9.4 + '@tauri-apps/cli-win32-ia32-msvc': 2.9.4 + '@tauri-apps/cli-win32-x64-msvc': 2.9.4 + + '@tauri-apps/plugin-clipboard-manager@2.3.2': dependencies: - '@tauri-apps/api': 2.8.0 + '@tauri-apps/api': 2.9.0 + + '@tauri-apps/plugin-dialog@2.4.2': + dependencies: + '@tauri-apps/api': 2.9.0 + + '@tauri-apps/plugin-fs@2.4.4': + dependencies: + '@tauri-apps/api': 2.9.0 + + '@tauri-apps/plugin-http@2.5.4': + dependencies: + '@tauri-apps/api': 2.9.0 + + '@tauri-apps/plugin-process@2.3.1': + dependencies: + '@tauri-apps/api': 2.9.0 + + '@tauri-apps/plugin-shell@2.3.3': + dependencies: + '@tauri-apps/api': 2.9.0 '@tauri-apps/plugin-updater@2.9.0': dependencies: - '@tauri-apps/api': 2.8.0 + '@tauri-apps/api': 2.9.0 '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 optional: true - '@types/babel__core@7.20.5': + '@types/chai@5.2.2': dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.20.7 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.28.4 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 - - '@types/babel__traverse@7.20.7': - dependencies: - '@babel/types': 7.28.4 + '@types/deep-eql': 4.0.2 '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -5833,19 +5999,23 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + '@types/parse-json@4.0.2': {} '@types/prop-types@15.7.15': {} - '@types/react-dom@19.2.1(@types/react@19.2.2)': + '@types/react-dom@19.2.3(@types/react@19.2.4)': dependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.4 - '@types/react-transition-group@4.4.12(@types/react@19.2.2)': + '@types/react-transition-group@4.4.12(@types/react@19.2.4)': dependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.4 - '@types/react@19.2.2': + '@types/react@19.2.4': dependencies: csstype: 3.1.3 @@ -5853,15 +6023,15 @@ snapshots: '@types/unist@3.0.3': {} - '@typescript-eslint/eslint-plugin@8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.0 - '@typescript-eslint/type-utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.46.0 - eslint: 9.37.0(jiti@2.6.1) + '@typescript-eslint/parser': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.4 + '@typescript-eslint/type-utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.46.4 + eslint: 9.39.1(jiti@2.6.1) graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 @@ -5870,57 +6040,57 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.46.0 - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/typescript-estree': 8.46.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.46.0 - debug: 4.4.1 - eslint: 9.37.0(jiti@2.6.1) + '@typescript-eslint/scope-manager': 8.46.4 + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.46.4 + debug: 4.4.3 + eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.46.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.46.4(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.46.0(typescript@5.9.3) - '@typescript-eslint/types': 8.46.0 - debug: 4.4.1 + '@typescript-eslint/tsconfig-utils': 8.46.4(typescript@5.9.3) + '@typescript-eslint/types': 8.46.4 + debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.46.0': + '@typescript-eslint/scope-manager@8.46.4': dependencies: - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/visitor-keys': 8.46.0 + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/visitor-keys': 8.46.4 - '@typescript-eslint/tsconfig-utils@8.46.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.46.4(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/typescript-estree': 8.46.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.1 - eslint: 9.37.0(jiti@2.6.1) + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.46.0': {} + '@typescript-eslint/types@8.46.4': {} - '@typescript-eslint/typescript-estree@8.46.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.46.4(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.46.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.46.0(typescript@5.9.3) - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/visitor-keys': 8.46.0 - debug: 4.4.1 + '@typescript-eslint/project-service': 8.46.4(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.46.4(typescript@5.9.3) + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/visitor-keys': 8.46.4 + debug: 4.4.3 fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 @@ -5930,20 +6100,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.8.0(eslint@9.37.0(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.46.0 - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/typescript-estree': 8.46.0(typescript@5.9.3) - eslint: 9.37.0(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.8.0(eslint@9.39.1(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.46.4 + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.46.0': + '@typescript-eslint/visitor-keys@8.46.4': dependencies: - '@typescript-eslint/types': 8.46.0 + '@typescript-eslint/types': 8.46.4 eslint-visitor-keys: 4.2.1 '@ungap/structured-clone@1.3.0': {} @@ -6007,7 +6177,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitejs/plugin-legacy@7.2.1(terser@5.44.0)(vite@7.1.9(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))': + '@vitejs/plugin-legacy@7.2.1(terser@5.44.1)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))': dependencies: '@babel/core': 7.28.4 '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.28.4) @@ -6018,25 +6188,60 @@ snapshots: browserslist: 4.25.1 browserslist-to-esbuild: 2.1.1(browserslist@4.25.1) core-js: 3.45.0 - magic-string: 0.30.17 + magic-string: 0.30.21 regenerator-runtime: 0.14.1 systemjs: 6.15.1 - terser: 5.44.0 - vite: 7.1.9(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) + terser: 5.44.1 + vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@5.0.4(vite@7.1.9(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))': + '@vitejs/plugin-react-swc@4.2.2(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))': dependencies: - '@babel/core': 7.28.4 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.4) - '@rolldown/pluginutils': 1.0.0-beta.38 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 7.1.9(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) + '@rolldown/pluginutils': 1.0.0-beta.47 + '@swc/core': 1.14.0 + vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) transitivePeerDependencies: - - supports-color + - '@swc/helpers' + + '@vitest/expect@4.0.9': + dependencies: + '@standard-schema/spec': 1.0.0 + '@types/chai': 5.2.2 + '@vitest/spy': 4.0.9 + '@vitest/utils': 4.0.9 + chai: 6.2.0 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.9(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 4.0.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) + + '@vitest/pretty-format@4.0.9': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.9': + dependencies: + '@vitest/utils': 4.0.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.9': + dependencies: + '@vitest/pretty-format': 4.0.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.9': {} + + '@vitest/utils@4.0.9': + dependencies: + '@vitest/pretty-format': 4.0.9 + tinyrainbow: 3.0.3 acorn-jsx@5.3.2(acorn@8.15.0): dependencies: @@ -6048,11 +6253,11 @@ snapshots: agent-base@7.1.3: {} - ahooks@3.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + ahooks@3.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@babel/runtime': 7.28.4 '@types/js-cookie': 3.0.6 - dayjs: 1.11.18 + dayjs: 1.11.19 intersection-observer: 0.12.2 js-cookie: 3.0.5 lodash: 4.17.21 @@ -6152,7 +6357,7 @@ snapshots: possible-typed-array-names: 1.1.0 optional: true - axios@1.12.2: + axios@1.13.2: dependencies: follow-redirects: 1.15.9 form-data: 4.0.4 @@ -6252,6 +6457,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.0: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -6306,7 +6513,7 @@ snapshots: comma-separated-tokens@2.0.3: {} - commander@14.0.1: {} + commander@14.0.2: {} commander@2.20.3: {} @@ -6339,7 +6546,7 @@ snapshots: cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -6386,14 +6593,14 @@ snapshots: is-data-view: 1.0.2 optional: true - dayjs@1.11.18: {} + dayjs@1.11.19: {} debug@3.2.7: dependencies: ms: 2.1.3 optional: true - debug@4.4.1: + debug@4.4.3: dependencies: ms: 2.1.3 @@ -6533,6 +6740,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -6613,9 +6822,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.37.0(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)): dependencies: - eslint: 9.37.0(jiti@2.6.1) + eslint: 9.39.1(jiti@2.6.1) eslint-import-context@0.1.9(unrs-resolver@1.11.1): dependencies: @@ -6633,10 +6842,10 @@ snapshots: - supports-color optional: true - eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.37.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.37.0(jiti@2.6.1)): + eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)): dependencies: - debug: 4.4.1 - eslint: 9.37.0(jiti@2.6.1) + debug: 4.4.3 + eslint: 9.39.1(jiti@2.6.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) get-tsconfig: 4.10.1 is-bun-module: 2.0.0 @@ -6644,29 +6853,29 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.37.0(jiti@2.6.1)) - eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.37.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.37.0(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.37.0(jiti@2.6.1) + '@typescript-eslint/parser': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.37.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.37.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color optional: true - eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.37.0(jiti@2.6.1)): + eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)): dependencies: - '@typescript-eslint/types': 8.46.0 + '@typescript-eslint/types': 8.46.4 comment-parser: 1.4.1 - debug: 4.4.1 - eslint: 9.37.0(jiti@2.6.1) + debug: 4.4.3 + eslint: 9.39.1(jiti@2.6.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 minimatch: 10.0.3 @@ -6674,12 +6883,12 @@ snapshots: stable-hash-x: 0.2.0 unrs-resolver: 1.11.1 optionalDependencies: - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.37.0(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -6688,9 +6897,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.37.0(jiti@2.6.1) + eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.37.0(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -6702,158 +6911,134 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color optional: true - eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1))(prettier@3.6.2): + eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2): dependencies: - eslint: 9.37.0(jiti@2.6.1) + eslint: 9.39.1(jiti@2.6.1) prettier: 3.6.2 prettier-linter-helpers: 1.0.0 synckit: 0.11.11 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@9.37.0(jiti@2.6.1)) + eslint-config-prettier: 10.1.8(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-react-debug@2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-react-dom@2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@eslint-react/ast': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/core': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/eff': 2.0.6 - '@eslint-react/kit': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/shared': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.0 - '@typescript-eslint/type-utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.37.0(jiti@2.6.1) - string-ts: 2.2.1 - ts-pattern: 5.8.0 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - eslint-plugin-react-dom@2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@eslint-react/ast': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/core': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/eff': 2.0.6 - '@eslint-react/kit': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/shared': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.0 - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/core': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/eff': 2.3.5 + '@eslint-react/shared': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/var': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.4 + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) compare-versions: 6.1.1 - eslint: 9.37.0(jiti@2.6.1) + eslint: 9.39.1(jiti@2.6.1) string-ts: 2.2.1 - ts-pattern: 5.8.0 + ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-hooks-extra@2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-react-hooks-extra@2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@eslint-react/ast': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/core': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/eff': 2.0.6 - '@eslint-react/kit': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/shared': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.0 - '@typescript-eslint/type-utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.37.0(jiti@2.6.1) + '@eslint-react/ast': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/core': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/eff': 2.3.5 + '@eslint-react/shared': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/var': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.4 + '@typescript-eslint/type-utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) string-ts: 2.2.1 - ts-pattern: 5.8.0 + ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-hooks@7.0.0(eslint@9.37.0(jiti@2.6.1)): + eslint-plugin-react-hooks@7.0.1(eslint@9.39.1(jiti@2.6.1)): dependencies: '@babel/core': 7.28.4 '@babel/parser': 7.28.4 - eslint: 9.37.0(jiti@2.6.1) + eslint: 9.39.1(jiti@2.6.1) hermes-parser: 0.25.1 - zod: 4.1.11 - zod-validation-error: 4.0.2(zod@4.1.11) + zod: 4.1.12 + zod-validation-error: 4.0.2(zod@4.1.12) transitivePeerDependencies: - supports-color - eslint-plugin-react-naming-convention@2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-react-naming-convention@2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@eslint-react/ast': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/core': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/eff': 2.0.6 - '@eslint-react/kit': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/shared': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.0 - '@typescript-eslint/type-utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.37.0(jiti@2.6.1) + '@eslint-react/ast': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/core': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/eff': 2.3.5 + '@eslint-react/shared': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/var': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.4 + '@typescript-eslint/type-utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) string-ts: 2.2.1 - ts-pattern: 5.8.0 + ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.4.23(eslint@9.37.0(jiti@2.6.1)): + eslint-plugin-react-refresh@0.4.24(eslint@9.39.1(jiti@2.6.1)): dependencies: - eslint: 9.37.0(jiti@2.6.1) + eslint: 9.39.1(jiti@2.6.1) - eslint-plugin-react-web-api@2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-react-web-api@2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@eslint-react/ast': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/core': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/eff': 2.0.6 - '@eslint-react/kit': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/shared': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.0 - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.37.0(jiti@2.6.1) + '@eslint-react/ast': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/core': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/eff': 2.3.5 + '@eslint-react/shared': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/var': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.4 + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) string-ts: 2.2.1 - ts-pattern: 5.8.0 + ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-x@2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-react-x@2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@eslint-react/ast': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/core': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/eff': 2.0.6 - '@eslint-react/kit': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/shared': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@eslint-react/var': 2.0.6(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.0 - '@typescript-eslint/type-utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/types': 8.46.0 - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/ast': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/core': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/eff': 2.3.5 + '@eslint-react/shared': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@eslint-react/var': 2.3.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.4 + '@typescript-eslint/type-utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) compare-versions: 6.1.1 - eslint: 9.37.0(jiti@2.6.1) - is-immutable-type: 5.0.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) + is-immutable-type: 5.0.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) string-ts: 2.2.1 ts-api-utils: 2.1.0(typescript@5.9.3) - ts-pattern: 5.8.0 + ts-pattern: 5.9.0 typescript: 5.9.3 transitivePeerDependencies: - supports-color - eslint-plugin-unused-imports@4.2.0(@typescript-eslint/eslint-plugin@8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1)): + eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)): dependencies: - eslint: 9.37.0(jiti@2.6.1) + eslint: 9.39.1(jiti@2.6.1) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint-scope@8.4.0: dependencies: @@ -6864,25 +7049,24 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.37.0(jiti@2.6.1): + eslint@9.39.1(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.8.0(eslint@9.37.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.8.0(eslint@9.39.1(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.1 - '@eslint/config-array': 0.21.0 - '@eslint/config-helpers': 0.4.0 - '@eslint/core': 0.16.0 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.37.0 - '@eslint/plugin-kit': 0.4.0 + '@eslint/js': 9.39.1 + '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.1 + debug: 4.4.3 escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -6933,6 +7117,10 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} event-emitter@0.3.5: @@ -6942,6 +7130,8 @@ snapshots: eventemitter3@5.0.1: {} + expect-type@1.2.2: {} + ext@1.7.0: dependencies: type: 2.7.3 @@ -7100,7 +7290,7 @@ snapshots: globals@14.0.0: {} - globals@16.4.0: {} + globals@16.5.0: {} globalthis@1.0.4: dependencies: @@ -7180,13 +7370,13 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.3 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color husky@9.1.7: {} - i18next@25.6.0(typescript@5.9.3): + i18next@25.6.2(typescript@5.9.3): dependencies: '@babel/runtime': 7.28.4 optionalDependencies: @@ -7305,10 +7495,10 @@ snapshots: is-hexadecimal@2.0.1: {} - is-immutable-type@5.0.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3): + is-immutable-type@5.0.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/type-utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.37.0(jiti@2.6.1) + '@typescript-eslint/type-utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.9.3) ts-declaration-location: 1.0.7(typescript@5.9.3) typescript: 5.9.3 @@ -7396,7 +7586,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.1.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -7434,17 +7624,17 @@ snapshots: lines-and-columns@1.2.4: {} - lint-staged@16.2.4: + lint-staged@16.2.6: dependencies: - commander: 14.0.1 - listr2: 9.0.4 + commander: 14.0.2 + listr2: 9.0.5 micromatch: 4.0.8 nano-spawn: 2.0.0 pidtree: 0.6.0 string-argv: 0.3.2 yaml: 2.8.1 - listr2@9.0.4: + listr2@9.0.5: dependencies: cli-truncate: 5.1.0 colorette: 2.0.20 @@ -7493,9 +7683,9 @@ snapshots: dependencies: es5-ext: 0.10.64 - magic-string@0.30.17: + magic-string@0.30.21: dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 marked@14.0.0: {} @@ -7605,7 +7795,7 @@ snapshots: merge2@1.4.1: {} - meta-json-schema@1.19.14: {} + meta-json-schema@1.19.16: {} micromark-core-commonmark@2.0.3: dependencies: @@ -7721,7 +7911,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.12 - debug: 4.4.1 + debug: 4.4.3 decode-named-character-reference: 1.1.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -7953,6 +8143,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -8004,29 +8196,30 @@ snapshots: react-fast-compare@3.2.2: {} - react-hook-form@7.65.0(react@19.2.0): + react-hook-form@7.66.0(react@19.2.0): dependencies: react: 19.2.0 - react-i18next@16.0.0(i18next@25.6.0(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3): + react-i18next@16.3.3(i18next@25.6.2(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3): dependencies: '@babel/runtime': 7.28.4 html-parse-stringify: 3.0.1 - i18next: 25.6.0(typescript@5.9.3) + i18next: 25.6.2(typescript@5.9.3) react: 19.2.0 + use-sync-external-store: 1.6.0(react@19.2.0) optionalDependencies: react-dom: 19.2.0(react@19.2.0) typescript: 5.9.3 react-is@16.13.1: {} - react-is@19.1.1: {} + react-is@19.2.0: {} - react-markdown@10.1.0(@types/react@19.2.2)(react@19.2.0): + react-markdown@10.1.0(@types/react@19.2.4)(react@19.2.0): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@types/react': 19.2.2 + '@types/react': 19.2.4 devlop: 1.1.0 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 @@ -8040,21 +8233,7 @@ snapshots: transitivePeerDependencies: - supports-color - react-monaco-editor@0.59.0(monaco-editor@0.54.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): - dependencies: - monaco-editor: 0.54.0 - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) - - react-refresh@0.17.0: {} - - react-router-dom@7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0): - dependencies: - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) - react-router: 7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - - react-router@7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: cookie: 1.0.2 react: 19.2.0 @@ -8217,7 +8396,7 @@ snapshots: is-regex: 1.2.1 optional: true - sass@1.93.2: + sass@1.94.0: dependencies: chokidar: 4.0.3 immutable: 5.1.2 @@ -8300,6 +8479,8 @@ snapshots: side-channel-weakmap: 1.0.2 optional: true + siginfo@2.0.0: {} + signal-exit@4.1.0: {} slice-ansi@7.1.2: @@ -8327,6 +8508,12 @@ snapshots: stable-hash-x@0.2.0: {} + stackback@0.0.2: {} + + state-local@1.0.7: {} + + std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -8434,7 +8621,7 @@ snapshots: systemjs@6.15.1: {} - tar@7.5.1: + tar@7.5.2: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -8442,11 +8629,11 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/7818a0a8f55c03ee7eba3fd7433faef29adf38ac: + tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/1e2ada19e20c5504a2a8cef367e87c667806dd65: dependencies: - '@tauri-apps/api': 2.8.0 + '@tauri-apps/api': 2.9.0 - terser@5.44.0: + terser@5.44.1: dependencies: '@jridgewell/source-map': 0.3.6 acorn: 8.15.0 @@ -8458,11 +8645,17 @@ snapshots: es5-ext: 0.10.64 next-tick: 1.1.0 + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.0.3: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -8480,7 +8673,7 @@ snapshots: picomatch: 4.0.3 typescript: 5.9.3 - ts-pattern@5.8.0: {} + ts-pattern@5.9.0: {} tsconfig-paths@3.15.0: dependencies: @@ -8539,13 +8732,13 @@ snapshots: types-pac@1.0.3: {} - typescript-eslint@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.46.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.37.0(jiti@2.6.1) + '@typescript-eslint/eslint-plugin': 8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -8560,6 +8753,8 @@ snapshots: which-boxed-primitive: 1.1.1 optional: true + undici-types@7.16.0: {} + undici@5.29.0: dependencies: '@fastify/busboy': 2.1.1 @@ -8658,22 +8853,22 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite-plugin-monaco-editor@1.1.0(monaco-editor@0.54.0): + vite-plugin-monaco-editor-esm@2.0.2(monaco-editor@0.54.0): dependencies: monaco-editor: 0.54.0 - vite-plugin-svgr@4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.1.9(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)): + vite-plugin-svgr@4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)): dependencies: '@rollup/pluginutils': 5.2.0(rollup@4.46.2) '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) - vite: 7.1.9(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) transitivePeerDependencies: - rollup - supports-color - typescript - vite@7.1.9(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1): + vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1): dependencies: esbuild: 0.25.4 fdir: 6.5.0(picomatch@4.0.3) @@ -8682,12 +8877,52 @@ snapshots: rollup: 4.46.2 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 24.10.1 fsevents: 2.3.3 jiti: 2.6.1 - sass: 1.93.2 - terser: 5.44.0 + sass: 1.94.0 + terser: 5.44.1 yaml: 2.8.1 + vitest@4.0.9(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1): + dependencies: + '@vitest/expect': 4.0.9 + '@vitest/mocker': 4.0.9(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)) + '@vitest/pretty-format': 4.0.9 + '@vitest/runner': 4.0.9 + '@vitest/snapshot': 4.0.9 + '@vitest/spy': 4.0.9 + '@vitest/utils': 4.0.9 + debug: 4.4.3 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 24.10.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + void-elements@3.1.0: {} vscode-jsonrpc@8.2.0: {} @@ -8754,6 +8989,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@7.0.0: @@ -8786,16 +9026,10 @@ snapshots: yocto-queue@0.1.0: {} - zod-validation-error@4.0.2(zod@4.1.11): + zod-validation-error@4.0.2(zod@4.1.12): dependencies: - zod: 4.1.11 + zod: 4.1.12 - zod@4.1.11: {} - - zustand@5.0.8(@types/react@19.2.2)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)): - optionalDependencies: - '@types/react': 19.2.2 - react: 19.2.0 - use-sync-external-store: 1.6.0(react@19.2.0) + zod@4.1.12: {} zwitch@2.0.4: {} diff --git a/clash-verge-rev/renovate.json b/clash-verge-rev/renovate.json index 7df27b159d..3aa47244e4 100644 --- a/clash-verge-rev/renovate.json +++ b/clash-verge-rev/renovate.json @@ -42,6 +42,6 @@ "groupName": "github actions" } ], - "postUpdateOptions": ["pnpmDedupe"], + "postUpdateOptions": ["pnpmDedupe", "updateCargoLock"], "ignoreDeps": ["criterion"] } diff --git a/clash-verge-rev/scripts/check-unused-i18n.js b/clash-verge-rev/scripts/check-unused-i18n.js deleted file mode 100644 index 95958df4a1..0000000000 --- a/clash-verge-rev/scripts/check-unused-i18n.js +++ /dev/null @@ -1,102 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const LOCALES_DIR = path.resolve(__dirname, "../src/locales"); -const SRC_DIRS = [ - path.resolve(__dirname, "../src"), - path.resolve(__dirname, "../src-tauri"), -]; -const exts = [".js", ".ts", ".tsx", ".jsx", ".vue", ".rs"]; - -// 递归获取所有文件 -function getAllFiles(dir, exts) { - let files = []; - fs.readdirSync(dir).forEach((file) => { - const full = path.join(dir, file); - if (fs.statSync(full).isDirectory()) { - files = files.concat(getAllFiles(full, exts)); - } else if (exts.includes(path.extname(full))) { - files.push(full); - } - }); - return files; -} - -// 读取所有源码内容为一个大字符串 -function getAllSourceContent() { - const files = SRC_DIRS.flatMap((dir) => getAllFiles(dir, exts)); - return files.map((f) => fs.readFileSync(f, "utf8")).join("\n"); -} - -// 白名单 key,不检查这些 key 是否被使用 -const WHITELIST_KEYS = [ - "theme.light", - "theme.dark", - "theme.system", - "Already Using Latest Core Version", -]; - -// 主流程 -function processI18nFile(i18nPath, lang, allSource) { - const i18n = JSON.parse(fs.readFileSync(i18nPath, "utf8")); - const keys = Object.keys(i18n); - - const used = {}; - const unused = []; - - let checked = 0; - const total = keys.length; - keys.forEach((key) => { - if (WHITELIST_KEYS.includes(key)) { - used[key] = i18n[key]; - } else { - // 只查找一次 - const regex = new RegExp(`["'\`]${key}["'\`]`); - if (regex.test(allSource)) { - used[key] = i18n[key]; - } else { - unused.push(key); - } - } - checked++; - if (checked % 20 === 0 || checked === total) { - const percent = ((checked / total) * 100).toFixed(1); - process.stdout.write( - `\r[${lang}] Progress: ${checked}/${total} (${percent}%)`, - ); - if (checked === total) process.stdout.write("\n"); - } - }); - - // 输出未使用的 key - console.log(`\n[${lang}] Unused keys:`, unused); - - // 备份原文件 - const oldPath = i18nPath + ".old"; - fs.renameSync(i18nPath, oldPath); - - // 写入精简后的 i18n 文件(保留原文件名) - fs.writeFileSync(i18nPath, JSON.stringify(used, null, 2), "utf8"); - console.log( - `[${lang}] Cleaned i18n file written to src/locales/${path.basename(i18nPath)}`, - ); - console.log(`[${lang}] Original file backed up as ${path.basename(oldPath)}`); -} - -function main() { - // 支持 zhtw.json、zh-tw.json、zh_CN.json 等 - const files = fs - .readdirSync(LOCALES_DIR) - .filter((f) => /^[a-z0-9\-_]+\.json$/i.test(f) && !f.endsWith(".old")); - const allSource = getAllSourceContent(); - files.forEach((file) => { - const lang = path.basename(file, ".json"); - processI18nFile(path.join(LOCALES_DIR, file), lang, allSource); - }); -} - -main(); diff --git a/clash-verge-rev/scripts/cleanup-unused-i18n.mjs b/clash-verge-rev/scripts/cleanup-unused-i18n.mjs new file mode 100644 index 0000000000..ce8af9c3d7 --- /dev/null +++ b/clash-verge-rev/scripts/cleanup-unused-i18n.mjs @@ -0,0 +1,1321 @@ +#!/usr/bin/env node + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +import ts from "typescript"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const LOCALES_DIR = path.resolve(__dirname, "../src/locales"); +const TAURI_LOCALES_DIR = path.resolve(__dirname, "../src-tauri/locales"); +const DEFAULT_SOURCE_DIRS = [ + path.resolve(__dirname, "../src"), + path.resolve(__dirname, "../src-tauri"), +]; +const EXCLUDE_USAGE_DIRS = [LOCALES_DIR, TAURI_LOCALES_DIR]; +const DEFAULT_BASELINE_LANG = "en"; +const IGNORE_DIR_NAMES = new Set([ + ".git", + ".idea", + ".turbo", + ".next", + ".cache", + ".pnpm", + "node_modules", + "dist", + "build", + "coverage", + "out", + "target", + "gen", + "packages", + "release", + "logs", + "__pycache__", +]); +const SUPPORTED_EXTENSIONS = new Set([ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".vue", + ".json", +]); + +const TS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]); + +const KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)+$/; +const TEMPLATE_PREFIX_PATTERN = + /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*\.$/; + +const IGNORED_KEY_PREFIXES = new Set([ + "text", + "primary", + "secondary", + "error", + "warning", + "success", + "info", + "background", + "grey", + "option", + "action", + "example", + "chrome", + "localhost", + "www", + "pac", + "V2", + "v2", + "v1", +]); + +const NOTICE_METHOD_NAMES = new Set(["success", "error", "info", "warning"]); +const NOTICE_SERVICE_IDENTIFIERS = new Set([ + "@/services/noticeService", + "./noticeService", + "../services/noticeService", +]); + +const WHITELIST_KEYS = new Set([ + "theme.light", + "theme.dark", + "theme.system", + "Already Using Latest Core Version", +]); + +const MAX_PREVIEW_ENTRIES = 40; +const dynamicKeyCache = new Map(); +const fileUsageCache = new Map(); + +function printUsage() { + console.log(`Usage: pnpm node scripts/cleanup-unused-i18n.mjs [options] + +Options: + --apply Write locale files with unused keys removed (default: report only) + --align Align locale structure/order using the baseline locale + --baseline Baseline locale file name (default: ${DEFAULT_BASELINE_LANG}) + --keep-extra Preserve keys that exist only in non-baseline locales when aligning + --no-backup Skip creating \`.bak\` backups when applying changes + --report Write a JSON report to the given path + --src Include an additional source directory (repeatable) + --help Show this message +`); +} + +function parseArgs(argv) { + const options = { + apply: false, + backup: true, + reportPath: null, + extraSources: [], + align: false, + baseline: DEFAULT_BASELINE_LANG, + keepExtra: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + switch (arg) { + case "--apply": + options.apply = true; + break; + case "--align": + options.align = true; + break; + case "--keep-extra": + options.keepExtra = true; + break; + case "--no-backup": + options.backup = false; + break; + case "--report": { + const next = argv[i + 1]; + if (!next) { + throw new Error("--report requires a file path"); + } + options.reportPath = path.resolve(process.cwd(), next); + i += 1; + break; + } + case "--baseline": { + const next = argv[i + 1]; + if (!next) { + throw new Error("--baseline requires a locale name (e.g. en)"); + } + options.baseline = next.replace(/\.json$/, ""); + i += 1; + break; + } + case "--src": + case "--source": { + const next = argv[i + 1]; + if (!next) { + throw new Error(`${arg} requires a directory path`); + } + options.extraSources.push(path.resolve(process.cwd(), next)); + i += 1; + break; + } + case "--help": + printUsage(); + process.exit(0); + break; + default: + if (arg.startsWith("--")) { + throw new Error(`Unknown option: ${arg}`); + } + throw new Error(`Unexpected positional argument: ${arg}`); + } + } + + return options; +} + +function getAllFiles(start, predicate) { + if (!fs.existsSync(start)) return []; + + const stack = [start]; + const files = []; + + while (stack.length) { + const current = stack.pop(); + if (!current) continue; + const stat = fs.statSync(current); + if (stat.isDirectory()) { + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isSymbolicLink()) { + continue; + } + if (IGNORE_DIR_NAMES.has(entry.name)) { + continue; + } + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(entryPath); + } else if (!predicate || predicate(entryPath)) { + files.push(entryPath); + } + } + } else if (!predicate || predicate(current)) { + files.push(current); + } + } + + return files; +} + +function collectSourceFiles(sourceDirs) { + const seen = new Set(); + const files = []; + + for (const dir of sourceDirs) { + const resolved = getAllFiles(dir, (filePath) => { + if (seen.has(filePath)) return false; + if (!SUPPORTED_EXTENSIONS.has(path.extname(filePath))) return false; + if ( + EXCLUDE_USAGE_DIRS.some((excluded) => + filePath.startsWith(`${excluded}${path.sep}`), + ) || + EXCLUDE_USAGE_DIRS.includes(filePath) + ) { + return false; + } + return true; + }); + + for (const filePath of resolved) { + seen.add(filePath); + files.push({ + path: filePath, + extension: path.extname(filePath).toLowerCase(), + content: fs.readFileSync(filePath, "utf8"), + }); + } + } + + files.sort((a, b) => a.path.localeCompare(b.path)); + return files; +} + +function flattenLocale(obj, parent = "") { + const entries = new Map(); + + if (!obj || typeof obj !== "object" || Array.isArray(obj)) { + return entries; + } + + for (const [key, value] of Object.entries(obj)) { + const currentPath = parent ? `${parent}.${key}` : key; + if (value && typeof value === "object" && !Array.isArray(value)) { + const childEntries = flattenLocale(value, currentPath); + for (const [childKey, childValue] of childEntries) { + entries.set(childKey, childValue); + } + } else { + entries.set(currentPath, value); + } + } + + return entries; +} + +function diffLocaleKeys(baselineEntries, localeEntries) { + const missing = []; + const extra = []; + + for (const key of baselineEntries.keys()) { + if (!localeEntries.has(key)) { + missing.push(key); + } + } + + for (const key of localeEntries.keys()) { + if (!baselineEntries.has(key)) { + extra.push(key); + } + } + + missing.sort(); + extra.sort(); + + return { missing, extra }; +} + +function determineScriptKind(extension) { + switch (extension) { + case ".ts": + return ts.ScriptKind.TS; + case ".tsx": + return ts.ScriptKind.TSX; + case ".jsx": + return ts.ScriptKind.JSX; + case ".js": + case ".mjs": + case ".cjs": + return ts.ScriptKind.JS; + default: + return ts.ScriptKind.TS; + } +} + +function getNamespaceFromKey(key) { + if (!key || typeof key !== "string") return null; + const [namespace] = key.split("."); + return namespace ?? null; +} + +function addTemplatePrefixCandidate( + prefix, + dynamicPrefixes, + baselineNamespaces, +) { + if (!prefix || typeof prefix !== "string") return; + const normalized = prefix.trim(); + if (!normalized) return; + let candidate = normalized; + if (!candidate.endsWith(".")) { + const lastDotIndex = candidate.lastIndexOf("."); + if (lastDotIndex === -1) { + return; + } + candidate = candidate.slice(0, lastDotIndex + 1); + } + if (!TEMPLATE_PREFIX_PATTERN.test(candidate)) return; + const namespace = getNamespaceFromKey(candidate); + if (!namespace || IGNORED_KEY_PREFIXES.has(namespace)) return; + if (!baselineNamespaces.has(namespace)) return; + dynamicPrefixes.add(candidate); +} + +function addKeyIfValid(key, usedKeys, baselineNamespaces, options = {}) { + if (!key || typeof key !== "string") return false; + if (!KEY_PATTERN.test(key)) return false; + const namespace = getNamespaceFromKey(key); + if (!namespace || IGNORED_KEY_PREFIXES.has(namespace)) return false; + if (!options.forceNamespace && !baselineNamespaces.has(namespace)) { + return false; + } + usedKeys.add(key); + return true; +} + +function collectImportSpecifiers(sourceFile) { + const specifiers = new Map(); + + sourceFile.forEachChild((node) => { + if (!ts.isImportDeclaration(node)) return; + const moduleText = + ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text; + if (!moduleText || !node.importClause) return; + + if (node.importClause.name) { + specifiers.set(node.importClause.name.text, moduleText); + } + + const { namedBindings } = node.importClause; + if (!namedBindings) return; + + if (ts.isNamespaceImport(namedBindings)) { + specifiers.set(namedBindings.name.text, `${moduleText}.*`); + return; + } + + if (ts.isNamedImports(namedBindings)) { + for (const element of namedBindings.elements) { + specifiers.set(element.name.text, moduleText); + } + } + }); + + return specifiers; +} + +function getCallExpressionChain(expression) { + const chain = []; + let current = expression; + while (current) { + if (ts.isIdentifier(current)) { + chain.unshift(current.text); + break; + } + if (ts.isPropertyAccessExpression(current)) { + chain.unshift(current.name.text); + current = current.expression; + continue; + } + if (ts.isElementAccessExpression(current)) { + const argument = current.argumentExpression; + if (ts.isStringLiteralLike(argument)) { + chain.unshift(argument.text); + current = current.expression; + continue; + } + return []; + } + break; + } + return chain; +} + +function classifyCallExpression(expression, importSpecifiers) { + if (!expression) return null; + const chain = getCallExpressionChain(expression); + if (chain.length === 0) return null; + + const last = chain[chain.length - 1]; + const root = chain[0]; + + if (last === "t") { + return { type: "translation", forceNamespace: true }; + } + + if ( + NOTICE_METHOD_NAMES.has(last) && + root === "showNotice" && + (!importSpecifiers || + NOTICE_SERVICE_IDENTIFIERS.has(importSpecifiers.get("showNotice") ?? "")) + ) { + return { type: "notice", forceNamespace: true }; + } + + return null; +} + +function resolveBindingValue(name, scopeStack) { + if (!name) return null; + for (let index = scopeStack.length - 1; index >= 0; index -= 1) { + const scope = scopeStack[index]; + if (scope && scope.has(name)) { + return scope.get(name); + } + } + return null; +} + +function resolveKeyFromExpression( + node, + scopeStack, + dynamicPrefixes, + baselineNamespaces, + importSpecifiers, +) { + if (!node) return null; + + if ( + ts.isStringLiteralLike(node) || + ts.isNoSubstitutionTemplateLiteral(node) + ) { + return node.text; + } + + if (ts.isTemplateExpression(node)) { + addTemplatePrefixCandidate( + node.head?.text ?? "", + dynamicPrefixes, + baselineNamespaces, + ); + for (const span of node.templateSpans) { + const literalText = span.literal?.text ?? ""; + const combined = (node.head?.text ?? "") + literalText; + addTemplatePrefixCandidate(combined, dynamicPrefixes, baselineNamespaces); + } + return null; + } + + if (ts.isBinaryExpression(node)) { + if (node.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const left = resolveKeyFromExpression( + node.left, + scopeStack, + dynamicPrefixes, + baselineNamespaces, + importSpecifiers, + ); + const right = resolveKeyFromExpression( + node.right, + scopeStack, + dynamicPrefixes, + baselineNamespaces, + importSpecifiers, + ); + if (left && right) { + return `${left}${right}`; + } + if (left) { + addTemplatePrefixCandidate(left, dynamicPrefixes, baselineNamespaces); + } + return null; + } + return null; + } + + if (ts.isParenthesizedExpression(node)) { + return resolveKeyFromExpression( + node.expression, + scopeStack, + dynamicPrefixes, + baselineNamespaces, + importSpecifiers, + ); + } + + if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) { + return resolveKeyFromExpression( + node.expression, + scopeStack, + dynamicPrefixes, + baselineNamespaces, + importSpecifiers, + ); + } + + if (ts.isIdentifier(node)) { + return resolveBindingValue(node.text, scopeStack); + } + + if (ts.isCallExpression(node)) { + const classification = classifyCallExpression( + node.expression, + importSpecifiers, + ); + if (!classification) return null; + const firstArg = node.arguments[0]; + if (!firstArg) return null; + return resolveKeyFromExpression( + firstArg, + scopeStack, + dynamicPrefixes, + baselineNamespaces, + importSpecifiers, + ); + } + + return null; +} + +function collectUsedKeysFromTsFile( + file, + baselineNamespaces, + usedKeys, + dynamicPrefixes, +) { + let sourceFile; + + try { + sourceFile = ts.createSourceFile( + file.path, + file.content, + ts.ScriptTarget.Latest, + true, + determineScriptKind(file.extension), + ); + } catch (error) { + console.warn(`Warning: failed to parse ${file.path}: ${error.message}`); + return; + } + + const importSpecifiers = collectImportSpecifiers(sourceFile); + const scopeStack = [new Map()]; + + const visit = (node) => { + let scopePushed = false; + if ( + ts.isBlock(node) || + ts.isModuleBlock(node) || + node.kind === ts.SyntaxKind.CaseBlock || + ts.isCatchClause(node) + ) { + scopeStack.push(new Map()); + scopePushed = true; + } + + if (ts.isTemplateExpression(node)) { + resolveKeyFromExpression( + node, + scopeStack, + dynamicPrefixes, + baselineNamespaces, + importSpecifiers, + ); + } + + if (ts.isVariableDeclaration(node) && node.initializer) { + const key = resolveKeyFromExpression( + node.initializer, + scopeStack, + dynamicPrefixes, + baselineNamespaces, + importSpecifiers, + ); + if (key && KEY_PATTERN.test(key)) { + if (ts.isIdentifier(node.name)) { + scopeStack[scopeStack.length - 1].set(node.name.text, key); + } + } + } + + if (ts.isCallExpression(node)) { + const classification = classifyCallExpression( + node.expression, + importSpecifiers, + ); + if (classification) { + const [firstArg] = node.arguments; + if (firstArg) { + const key = resolveKeyFromExpression( + firstArg, + scopeStack, + dynamicPrefixes, + baselineNamespaces, + importSpecifiers, + ); + if ( + !addKeyIfValid(key, usedKeys, baselineNamespaces, classification) + ) { + addTemplatePrefixCandidate( + key ?? "", + dynamicPrefixes, + baselineNamespaces, + ); + } + } + } + } + + if (ts.isJsxAttribute(node) && node.name?.text === "i18nKey") { + const initializer = node.initializer; + if (initializer && ts.isStringLiteralLike(initializer)) { + addKeyIfValid(initializer.text, usedKeys, baselineNamespaces, { + forceNamespace: false, + }); + } else if ( + initializer && + ts.isJsxExpression(initializer) && + initializer.expression + ) { + const key = resolveKeyFromExpression( + initializer.expression, + scopeStack, + dynamicPrefixes, + baselineNamespaces, + importSpecifiers, + ); + addKeyIfValid(key, usedKeys, baselineNamespaces, { + forceNamespace: true, + }); + } + } + + node.forEachChild(visit); + + if (scopePushed) { + scopeStack.pop(); + } + }; + + visit(sourceFile); +} + +function collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys) { + const regex = /['"`]([A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)+)['"`]/g; + let match; + while ((match = regex.exec(file.content))) { + addKeyIfValid(match[1], usedKeys, baselineNamespaces, { + forceNamespace: false, + }); + } +} + +function collectUsedI18nKeys(sourceFiles, baselineNamespaces) { + const usedKeys = new Set(); + const dynamicPrefixes = new Set(); + + for (const file of sourceFiles) { + if (TS_EXTENSIONS.has(file.extension)) { + collectUsedKeysFromTsFile( + file, + baselineNamespaces, + usedKeys, + dynamicPrefixes, + ); + } else { + collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys); + } + } + + return { usedKeys, dynamicPrefixes }; +} + +function alignToBaseline(baselineNode, localeNode, options) { + const shouldCopyLocale = + localeNode && typeof localeNode === "object" && !Array.isArray(localeNode); + + if ( + baselineNode && + typeof baselineNode === "object" && + !Array.isArray(baselineNode) + ) { + const result = {}; + const baselineKeys = Object.keys(baselineNode); + for (const key of baselineKeys) { + const baselineValue = baselineNode[key]; + const localeValue = shouldCopyLocale ? localeNode[key] : undefined; + + if ( + baselineValue && + typeof baselineValue === "object" && + !Array.isArray(baselineValue) + ) { + result[key] = alignToBaseline( + baselineValue, + localeValue && typeof localeValue === "object" ? localeValue : {}, + options, + ); + } else if (localeValue === undefined) { + result[key] = baselineValue; + } else { + result[key] = localeValue; + } + } + + if (options.keepExtra && shouldCopyLocale) { + const extraKeys = Object.keys(localeNode) + .filter((key) => !baselineKeys.includes(key)) + .sort(); + for (const key of extraKeys) { + result[key] = localeNode[key]; + } + } + + return result; + } + + return shouldCopyLocale ? localeNode : baselineNode; +} + +function logPreviewEntries(label, items) { + if (!items || items.length === 0) return; + + const preview = items.slice(0, MAX_PREVIEW_ENTRIES); + for (const item of preview) { + console.log(` · ${label}: ${item}`); + } + if (items.length > preview.length) { + console.log( + ` · ${label}: ... and ${items.length - preview.length} more`, + ); + } +} + +function removeKey(target, dottedKey) { + if ( + !target || + typeof target !== "object" || + Array.isArray(target) || + dottedKey === "" + ) { + return; + } + + if (dottedKey in target) { + delete target[dottedKey]; + return; + } + + const parts = dottedKey.split(".").filter((part) => part !== ""); + if (parts.length === 0) return; + + let current = target; + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]; + if ( + !current || + typeof current !== "object" || + Array.isArray(current) || + !(part in current) + ) { + return; + } + if (index === parts.length - 1) { + delete current[part]; + return; + } + current = current[part]; + } +} + +function cleanupEmptyBranches(target) { + if (!target || typeof target !== "object" || Array.isArray(target)) { + return false; + } + + for (const key of Object.keys(target)) { + if (cleanupEmptyBranches(target[key])) { + delete target[key]; + } + } + + return Object.keys(target).length === 0; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function findKeyInSources(key, sourceFiles) { + if (fileUsageCache.has(key)) { + return fileUsageCache.get(key); + } + + const pattern = new RegExp(`(['"\`])${escapeRegExp(key)}\\1`); + let found = false; + + for (const file of sourceFiles) { + if (pattern.test(file.content)) { + found = true; + break; + } + } + + fileUsageCache.set(key, found); + return found; +} + +function isKeyUsed(key, usage, sourceFiles) { + if (WHITELIST_KEYS.has(key)) return true; + if (!key) return false; + if (usage.usedKeys.has(key)) return true; + + if (dynamicKeyCache.has(key)) { + return dynamicKeyCache.get(key); + } + + const prefixes = getCandidatePrefixes(key); + let used = prefixes.some((prefix) => usage.dynamicPrefixes.has(prefix)); + + if (!used) { + used = findKeyInSources(key, sourceFiles); + } + + dynamicKeyCache.set(key, used); + return used; +} + +function getCandidatePrefixes(key) { + if (!key.includes(".")) return []; + const parts = key.split("."); + const prefixes = []; + for (let index = 0; index < parts.length - 1; index += 1) { + const prefix = `${parts.slice(0, index + 1).join(".")}.`; + prefixes.push(prefix); + } + return prefixes; +} + +function writeReport(reportPath, data) { + const payload = JSON.stringify(data, null, 2); + fs.writeFileSync(reportPath, `${payload}\n`, "utf8"); +} + +function loadLocales() { + if (!fs.existsSync(LOCALES_DIR)) { + throw new Error(`Locales directory not found: ${LOCALES_DIR}`); + } + + const entries = fs.readdirSync(LOCALES_DIR, { withFileTypes: true }); + const locales = []; + + for (const entry of entries) { + if (entry.isFile()) { + if ( + /^[a-z0-9\-_]+\.json$/i.test(entry.name) && + !entry.name.endsWith(".bak") && + !entry.name.endsWith(".old") + ) { + const localePath = path.join(LOCALES_DIR, entry.name); + const name = path.basename(entry.name, ".json"); + const raw = fs.readFileSync(localePath, "utf8"); + locales.push({ + name, + dir: LOCALES_DIR, + format: "single-file", + files: [ + { + namespace: "translation", + path: localePath, + }, + ], + data: JSON.parse(raw), + }); + } + continue; + } + + if (!entry.isDirectory()) continue; + if (entry.name.startsWith(".")) continue; + + const localeDir = path.join(LOCALES_DIR, entry.name); + const namespaceEntries = fs + .readdirSync(localeDir, { withFileTypes: true }) + .filter( + (item) => + item.isFile() && + item.name.endsWith(".json") && + !item.name.endsWith(".bak") && + !item.name.endsWith(".old"), + ) + .map((item) => ({ + namespace: path.basename(item.name, ".json"), + path: path.join(localeDir, item.name), + })); + + namespaceEntries.sort((a, b) => a.path.localeCompare(b.path)); + + const data = {}; + for (const file of namespaceEntries) { + const raw = fs.readFileSync(file.path, "utf8"); + try { + data[file.namespace] = JSON.parse(raw); + } catch (error) { + console.warn(`Warning: failed to parse ${file.path}: ${error.message}`); + data[file.namespace] = {}; + } + } + + locales.push({ + name: entry.name, + dir: localeDir, + format: "multi-file", + files: namespaceEntries, + data, + }); + } + + locales.sort((a, b) => a.name.localeCompare(b.name)); + return locales; +} + +function ensureBackup(localePath) { + const backupPath = `${localePath}.bak`; + if (fs.existsSync(backupPath)) { + try { + fs.rmSync(backupPath); + } catch (error) { + throw new Error( + `Failed to recycle existing backup for ${path.basename( + localePath, + )}: ${error.message}`, + ); + } + } + fs.copyFileSync(localePath, backupPath); + return backupPath; +} + +function backupIfNeeded(filePath, backups, options) { + if (!options.backup) return; + if (!fs.existsSync(filePath)) return; + if (backups.has(filePath)) return; + const backupPath = ensureBackup(filePath); + backups.set(filePath, backupPath); + return backupPath; +} + +function cleanupBackups(backups) { + for (const backupPath of backups.values()) { + try { + if (fs.existsSync(backupPath)) { + fs.rmSync(backupPath); + } + } catch (error) { + console.warn( + `Warning: failed to remove backup ${path.basename( + backupPath, + )}: ${error.message}`, + ); + } + } + backups.clear(); +} + +function toModuleIdentifier(namespace, seen) { + const RESERVED = new Set([ + "default", + "function", + "var", + "let", + "const", + "import", + "export", + "class", + "enum", + ]); + + const base = + namespace.replace(/[^a-zA-Z0-9_$]/g, "_").replace(/^[^a-zA-Z_$]+/, "") || + "ns"; + + let candidate = base; + let counter = 1; + while (RESERVED.has(candidate) || seen.has(candidate)) { + candidate = `${base}_${counter}`; + counter += 1; + } + seen.add(candidate); + return candidate; +} + +function regenerateLocaleIndex(localeDir, namespaces) { + const seen = new Set(); + const imports = []; + const mappings = []; + + for (const namespace of namespaces) { + const filePath = path.join(localeDir, `${namespace}.json`); + if (!fs.existsSync(filePath)) continue; + const identifier = toModuleIdentifier(namespace, seen); + imports.push(`import ${identifier} from "./${namespace}.json";`); + mappings.push(` "${namespace}": ${identifier},`); + } + + const content = `${imports.join("\n")} + +const resources = { +${mappings.join("\n")} +}; + +export default resources; +`; + + fs.writeFileSync(path.join(localeDir, "index.ts"), content, "utf8"); +} + +function writeLocale(locale, data, options) { + const backups = new Map(); + let success = false; + + try { + if (locale.format === "single-file") { + const target = locale.files[0].path; + backupIfNeeded(target, backups, options); + const serialized = JSON.stringify(data, null, 2); + fs.writeFileSync(target, `${serialized}\n`, "utf8"); + success = true; + return; + } + + const entries = Object.entries(data); + const orderedNamespaces = entries.map(([namespace]) => namespace); + const existingFiles = new Map( + locale.files.map((file) => [file.namespace, file.path]), + ); + const visited = new Set(); + + for (const [namespace, value] of entries) { + const target = + existingFiles.get(namespace) ?? + path.join(locale.dir, `${namespace}.json`); + backupIfNeeded(target, backups, options); + const serialized = JSON.stringify(value ?? {}, null, 2); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, `${serialized}\n`, "utf8"); + visited.add(namespace); + } + + for (const [namespace, filePath] of existingFiles.entries()) { + if (!visited.has(namespace) && fs.existsSync(filePath)) { + backupIfNeeded(filePath, backups, options); + fs.rmSync(filePath); + } + } + + regenerateLocaleIndex(locale.dir, orderedNamespaces); + locale.files = orderedNamespaces.map((namespace) => ({ + namespace, + path: path.join(locale.dir, `${namespace}.json`), + })); + success = true; + } finally { + if (success) { + cleanupBackups(backups); + } + } +} + +function processLocale( + locale, + baselineData, + baselineEntries, + usage, + sourceFiles, + missingFromSource, + options, +) { + const data = JSON.parse(JSON.stringify(locale.data)); + const flattened = flattenLocale(data); + const expectedTotal = baselineEntries.size; + + const { missing, extra } = diffLocaleKeys(baselineEntries, flattened); + + const unused = []; + for (const key of flattened.keys()) { + if (!isKeyUsed(key, usage, sourceFiles)) { + unused.push(key); + } + } + + const sourceMissing = + locale.name === options.baseline + ? missingFromSource.filter((key) => !flattened.has(key)) + : []; + + if ( + unused.length === 0 && + missing.length === 0 && + extra.length === 0 && + sourceMissing.length === 0 && + !options.align + ) { + console.log(`[${locale.name}] No issues detected 🎉`); + } else { + console.log(`[${locale.name}] Check results:`); + console.log( + ` unused: ${unused.length}, missing vs baseline: ${missing.length}, extra: ${extra.length}`, + ); + if (sourceMissing.length > 0) { + console.log(` missing in source: ${sourceMissing.length}`); + logPreviewEntries("missing-source", sourceMissing); + } + logPreviewEntries("unused", unused); + logPreviewEntries("missing", missing); + logPreviewEntries("extra", extra); + } + + const removed = []; + let aligned = false; + if (options.apply) { + let updated; + if (options.align) { + aligned = true; + updated = alignToBaseline(baselineData, data, options); + } else { + updated = JSON.parse(JSON.stringify(data)); + } + + for (const key of unused) { + removeKey(updated, key); + removed.push(key); + } + cleanupEmptyBranches(updated); + + writeLocale(locale, updated, options); + locale.data = JSON.parse(JSON.stringify(updated)); + console.log( + `[${locale.name}] Locale resources updated (${removed.length} unused removed${ + aligned ? ", structure aligned" : "" + })`, + ); + } + + return { + locale: locale.name, + file: locale.format === "single-file" ? locale.files[0].path : locale.dir, + totalKeys: flattened.size, + expectedKeys: expectedTotal, + unusedKeys: unused, + removed, + missingKeys: missing, + extraKeys: extra, + missingSourceKeys: sourceMissing, + aligned: aligned && options.apply, + }; +} + +function main() { + const argv = process.argv.slice(2); + + let options; + try { + options = parseArgs(argv); + } catch (error) { + console.error(`Error: ${error.message}`); + console.log(); + printUsage(); + process.exit(1); + } + + const sourceDirs = [ + ...new Set([...DEFAULT_SOURCE_DIRS, ...options.extraSources]), + ]; + + console.log("Scanning source directories:"); + for (const dir of sourceDirs) { + console.log(` - ${dir}`); + } + + const sourceFiles = collectSourceFiles(sourceDirs); + const locales = loadLocales(); + + if (locales.length === 0) { + console.log("No locale files found."); + return; + } + + const baselineLocale = locales.find( + (item) => item.name.toLowerCase() === options.baseline.toLowerCase(), + ); + + if (!baselineLocale) { + const available = locales.map((item) => item.name).join(", "); + throw new Error( + `Baseline locale "${options.baseline}" not found. Available locales: ${available}`, + ); + } + + const baselineData = JSON.parse(JSON.stringify(baselineLocale.data)); + const baselineEntries = flattenLocale(baselineData); + const baselineNamespaces = new Set(Object.keys(baselineData)); + const usage = collectUsedI18nKeys(sourceFiles, baselineNamespaces); + const baselineKeys = new Set(baselineEntries.keys()); + const missingFromSource = Array.from(usage.usedKeys).filter( + (key) => !baselineKeys.has(key), + ); + missingFromSource.sort(); + + locales.sort((a, b) => { + if (a.name === baselineLocale.name) return -1; + if (b.name === baselineLocale.name) return 1; + return a.name.localeCompare(b.name); + }); + + console.log(`\nChecking ${locales.length} locale files...\n`); + + const results = locales.map((locale) => + processLocale( + locale, + baselineData, + baselineEntries, + usage, + sourceFiles, + missingFromSource, + options, + ), + ); + + const totalUnused = results.reduce( + (count, result) => count + result.unusedKeys.length, + 0, + ); + const totalMissing = results.reduce( + (count, result) => count + result.missingKeys.length, + 0, + ); + const totalExtra = results.reduce( + (count, result) => count + result.extraKeys.length, + 0, + ); + const totalSourceMissing = results.reduce( + (count, result) => count + result.missingSourceKeys.length, + 0, + ); + + console.log("\nSummary:"); + for (const result of results) { + console.log( + ` • ${result.locale}: unused=${result.unusedKeys.length}, missing=${result.missingKeys.length}, extra=${result.extraKeys.length}, missingSource=${result.missingSourceKeys.length}, total=${result.totalKeys}, expected=${result.expectedKeys}`, + ); + } + console.log( + `\nTotals → unused: ${totalUnused}, missing: ${totalMissing}, extra: ${totalExtra}, missingSource: ${totalSourceMissing}`, + ); + if (options.apply) { + console.log( + "Files were updated in-place; review diffs before committing changes.", + ); + } else { + console.log( + "Run with --apply to write cleaned locale files. Backups will be created unless --no-backup is passed.", + ); + if (options.align) { + console.log( + "Alignment was evaluated in dry-run mode; rerun with --apply to rewrite locale files.", + ); + } else { + console.log( + "Pass --align to normalize locale structure/order based on the baseline locale.", + ); + } + } + + if (options.reportPath) { + const payload = { + generatedAt: new Date().toISOString(), + options: { + apply: options.apply, + backup: options.backup, + align: options.align, + baseline: baselineLocale.name, + keepExtra: options.keepExtra, + sourceDirs, + }, + results, + }; + writeReport(options.reportPath, payload); + console.log(`Report written to ${options.reportPath}`); + } +} + +try { + main(); +} catch (error) { + console.error("Failed to complete i18n cleanup draft."); + console.error(error); + process.exit(1); +} diff --git a/clash-verge-rev/scripts/extract_update_logs.sh b/clash-verge-rev/scripts/extract_update_logs.sh new file mode 100755 index 0000000000..1283cf3b09 --- /dev/null +++ b/clash-verge-rev/scripts/extract_update_logs.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# +# extract_update_logs.sh +# 从 Changelog.md 提取最新版本 (## v...) 的更新内容 +# 并输出到屏幕或写入环境变量文件(如 GitHub Actions) + +set -euo pipefail + +CHANGELOG_FILE="Changelog.md" + +if [[ ! -f "$CHANGELOG_FILE" ]]; then + echo "❌ 文件不存在: $CHANGELOG_FILE" >&2 + exit 1 +fi + +# 提取从第一个 '## v' 开始到下一个 '## v' 前的内容 +UPDATE_LOGS=$(awk ' + /^## v/ { + if (found) exit; + found=1 + } + found +' "$CHANGELOG_FILE") + +if [[ -z "$UPDATE_LOGS" ]]; then + echo "⚠️ 未找到更新日志内容" + exit 0 +fi + +echo "✅ 提取到的最新版本日志内容如下:" +echo "----------------------------------------" +echo "$UPDATE_LOGS" +echo "----------------------------------------" + +# 如果在 GitHub Actions 环境中(GITHUB_ENV 已定义) +if [[ -n "${GITHUB_ENV:-}" ]]; then + { + echo "UPDATE_LOGS<> "$GITHUB_ENV" + echo "✅ 已写入 GitHub 环境变量 UPDATE_LOGS" +fi diff --git a/clash-verge-rev/scripts/fix-alpha_version.mjs b/clash-verge-rev/scripts/fix-alpha_version.mjs index 22863ba6e9..7817eb4a1d 100644 --- a/clash-verge-rev/scripts/fix-alpha_version.mjs +++ b/clash-verge-rev/scripts/fix-alpha_version.mjs @@ -1,7 +1,7 @@ import { exec } from "child_process"; -import { promisify } from "util"; import fs from "fs/promises"; import path from "path"; +import { promisify } from "util"; /** * 为Alpha版本重命名版本号 diff --git a/clash-verge-rev/scripts/generate-i18n-keys.mjs b/clash-verge-rev/scripts/generate-i18n-keys.mjs new file mode 100644 index 0000000000..133ab5ef3a --- /dev/null +++ b/clash-verge-rev/scripts/generate-i18n-keys.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT_DIR = path.resolve(__dirname, ".."); +const LOCALE_DIR = path.resolve(ROOT_DIR, "src/locales/en"); +const KEY_OUTPUT = path.resolve(ROOT_DIR, "src/types/generated/i18n-keys.ts"); +const RESOURCE_OUTPUT = path.resolve( + ROOT_DIR, + "src/types/generated/i18n-resources.ts", +); + +const isPlainObject = (value) => + typeof value === "object" && value !== null && !Array.isArray(value); + +const flattenKeys = (data, prefix = "") => { + const keys = []; + for (const [key, value] of Object.entries(data)) { + const nextPrefix = prefix ? `${prefix}.${key}` : key; + if (isPlainObject(value)) { + keys.push(...flattenKeys(value, nextPrefix)); + } else { + keys.push(nextPrefix); + } + } + return keys; +}; + +const buildType = (data, indent = 0) => { + if (!isPlainObject(data)) { + return "string"; + } + + const entries = Object.entries(data).sort(([a], [b]) => a.localeCompare(b)); + const pad = " ".repeat(indent); + const inner = entries + .map(([key, value]) => { + const typeStr = buildType(value, indent + 2); + return `${" ".repeat(indent + 2)}${JSON.stringify(key)}: ${typeStr};`; + }) + .join("\n"); + + return entries.length + ? `{ +${inner} +${pad}}` + : "{}"; +}; + +const loadNamespaceJson = async () => { + const dirents = await fs.readdir(LOCALE_DIR, { withFileTypes: true }); + const namespaces = []; + for (const dirent of dirents) { + if (!dirent.isFile() || !dirent.name.endsWith(".json")) continue; + const name = dirent.name.replace(/\.json$/, ""); + const filePath = path.join(LOCALE_DIR, dirent.name); + const raw = await fs.readFile(filePath, "utf8"); + const json = JSON.parse(raw); + namespaces.push({ name, json }); + } + namespaces.sort((a, b) => a.name.localeCompare(b.name)); + return namespaces; +}; + +const buildKeysFile = (keys) => { + const arrayLiteral = keys.map((key) => ` "${key}"`).join(",\n"); + return `// This file is auto-generated by scripts/generate-i18n-keys.mjs\n// Do not edit this file manually.\n\nexport const translationKeys = [\n${arrayLiteral}\n] as const;\n\nexport type TranslationKey = typeof translationKeys[number];\n`; +}; + +const buildResourcesFile = (namespaces) => { + const namespaceEntries = namespaces + .map(({ name, json }) => { + const typeStr = buildType(json, 4); + return ` ${JSON.stringify(name)}: ${typeStr};`; + }) + .join("\n"); + + return `// This file is auto-generated by scripts/generate-i18n-keys.mjs\n// Do not edit this file manually.\n\nexport interface TranslationResources {\n translation: {\n${namespaceEntries}\n };\n}\n`; +}; + +const main = async () => { + const namespaces = await loadNamespaceJson(); + const keys = namespaces.flatMap(({ name, json }) => flattenKeys(json, name)); + const keysContent = buildKeysFile(keys); + const resourcesContent = buildResourcesFile(namespaces); + await fs.mkdir(path.dirname(KEY_OUTPUT), { recursive: true }); + await fs.writeFile(KEY_OUTPUT, keysContent, "utf8"); + await fs.writeFile(RESOURCE_OUTPUT, resourcesContent, "utf8"); + console.log(`Generated ${keys.length} translation keys.`); +}; + +main().catch((error) => { + console.error("Failed to generate i18n metadata:", error); + process.exitCode = 1; +}); diff --git a/clash-verge-rev/scripts/portable-fixed-webview2.mjs b/clash-verge-rev/scripts/portable-fixed-webview2.mjs index b3424fb51b..99bbbc62df 100644 --- a/clash-verge-rev/scripts/portable-fixed-webview2.mjs +++ b/clash-verge-rev/scripts/portable-fixed-webview2.mjs @@ -1,9 +1,10 @@ import fs from "fs"; import fsp from "fs/promises"; -import path from "path"; -import AdmZip from "adm-zip"; import { createRequire } from "module"; +import path from "path"; + import { getOctokit, context } from "@actions/github"; +import AdmZip from "adm-zip"; const target = process.argv.slice(2)[0]; const alpha = process.argv.slice(2)[1]; @@ -79,11 +80,11 @@ async function resolvePortable() { tag, }); - let assets = release.assets.filter((x) => { + const assets = release.assets.filter((x) => { return x.name === zipFile; }); if (assets.length > 0) { - let id = assets[0].id; + const id = assets[0].id; await github.rest.repos.deleteReleaseAsset({ ...options, asset_id: id, diff --git a/clash-verge-rev/scripts/portable.mjs b/clash-verge-rev/scripts/portable.mjs index 49aafdb9cf..e1918971b4 100644 --- a/clash-verge-rev/scripts/portable.mjs +++ b/clash-verge-rev/scripts/portable.mjs @@ -1,8 +1,9 @@ import fs from "fs"; -import path from "path"; -import AdmZip from "adm-zip"; -import { createRequire } from "module"; import fsp from "fs/promises"; +import { createRequire } from "module"; +import path from "path"; + +import AdmZip from "adm-zip"; const target = process.argv.slice(2)[0]; const ARCH_MAP = { diff --git a/clash-verge-rev/scripts/prebuild.mjs b/clash-verge-rev/scripts/prebuild.mjs index 2e3431369f..21f0da7cc4 100644 --- a/clash-verge-rev/scripts/prebuild.mjs +++ b/clash-verge-rev/scripts/prebuild.mjs @@ -1,18 +1,32 @@ -import AdmZip from "adm-zip"; import { execSync } from "child_process"; +import { createHash } from "crypto"; import fs from "fs"; import fsp from "fs/promises"; +import path from "path"; +import zlib from "zlib"; + +import AdmZip from "adm-zip"; import { glob } from "glob"; import { HttpsProxyAgent } from "https-proxy-agent"; import fetch from "node-fetch"; -import path from "path"; import { extract } from "tar"; -import zlib from "zlib"; + 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 + * + */ + const cwd = process.cwd(); const TEMP_DIR = path.join(cwd, "node_modules/.verge"); const FORCE = process.argv.includes("--force") || process.argv.includes("-f"); +const VERSION_CACHE_FILE = path.join(TEMP_DIR, ".version_cache.json"); +const HASH_CACHE_FILE = path.join(TEMP_DIR, ".hash_cache.json"); const PLATFORM_MAP = { "x86_64-pc-windows-msvc": "win32", @@ -43,8 +57,7 @@ const ARCH_MAP = { const arg1 = process.argv.slice(2)[0]; const arg2 = process.argv.slice(2)[1]; -let target; -target = arg1 === "--force" || arg1 === "-f" ? arg2 : arg1; +const target = arg1 === "--force" || arg1 === "-f" ? arg2 : arg1; const { platform, arch } = target ? { platform: PLATFORM_MAP[target], arch: ARCH_MAP[target] } : process; @@ -55,65 +68,119 @@ const SIDECAR_HOST = target .toString() .match(/(?<=host: ).+(?=\s*)/g)[0]; -/* ======= clash meta alpha======= */ +// ======================= +// Version Cache +// ======================= +async function loadVersionCache() { + try { + if (fs.existsSync(VERSION_CACHE_FILE)) { + const data = await fsp.readFile(VERSION_CACHE_FILE, "utf-8"); + return JSON.parse(data); + } + } catch (err) { + log_debug("Failed to load version cache:", err.message); + } + return {}; +} +async function saveVersionCache(cache) { + try { + await fsp.mkdir(TEMP_DIR, { recursive: true }); + await fsp.writeFile(VERSION_CACHE_FILE, JSON.stringify(cache, null, 2)); + log_debug("Version cache saved"); + } catch (err) { + log_debug("Failed to save version cache:", err.message); + } +} +async function getCachedVersion(key) { + const cache = await loadVersionCache(); + const cached = cache[key]; + if (cached && Date.now() - cached.timestamp < 3600000) { + log_info(`Using cached version for ${key}: ${cached.version}`); + return cached.version; + } + return null; +} +async function setCachedVersion(key, version) { + const cache = await loadVersionCache(); + cache[key] = { version, timestamp: Date.now() }; + await saveVersionCache(cache); +} + +// ======================= +// Hash Cache & File Hash +// ======================= +async function calculateFileHash(filePath) { + try { + const fileBuffer = await fsp.readFile(filePath); + const hashSum = createHash("sha256"); + hashSum.update(fileBuffer); + return hashSum.digest("hex"); + } catch (ignoreErr) { + return null; + } +} +async function loadHashCache() { + try { + if (fs.existsSync(HASH_CACHE_FILE)) { + const data = await fsp.readFile(HASH_CACHE_FILE, "utf-8"); + return JSON.parse(data); + } + } catch (err) { + log_debug("Failed to load hash cache:", err.message); + } + return {}; +} +async function saveHashCache(cache) { + try { + await fsp.mkdir(TEMP_DIR, { recursive: true }); + await fsp.writeFile(HASH_CACHE_FILE, JSON.stringify(cache, null, 2)); + log_debug("Hash cache saved"); + } catch (err) { + log_debug("Failed to save hash cache:", err.message); + } +} +async function hasFileChanged(filePath, targetPath) { + if (FORCE) return true; + if (!fs.existsSync(targetPath)) return true; + const hashCache = await loadHashCache(); + const sourceHash = await calculateFileHash(filePath); + const targetHash = await calculateFileHash(targetPath); + if (!sourceHash || !targetHash) return true; + const cacheKey = targetPath; + const cachedHash = hashCache[cacheKey]; + if (cachedHash === sourceHash && sourceHash === targetHash) { + return false; + } + return true; +} +async function updateHashCache(targetPath) { + const hashCache = await loadHashCache(); + const hash = await calculateFileHash(targetPath); + if (hash) { + hashCache[targetPath] = hash; + await saveHashCache(hashCache); + } +} + +// ======================= +// Meta maps (stable & alpha) +// ======================= 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`; let META_ALPHA_VERSION; -const META_ALPHA_MAP = { - "win32-x64": "mihomo-windows-amd64-v2", - "win32-ia32": "mihomo-windows-386", - "win32-arm64": "mihomo-windows-arm64", - "darwin-x64": "mihomo-darwin-amd64-v1", - "darwin-arm64": "mihomo-darwin-arm64", - "linux-x64": "mihomo-linux-amd64-v2", - "linux-ia32": "mihomo-linux-386", - "linux-arm64": "mihomo-linux-arm64", - "linux-arm": "mihomo-linux-armv7", - "linux-riscv64": "mihomo-linux-riscv64", - "linux-loong64": "mihomo-linux-loong64", -}; - -// Fetch the latest alpha release version from the version.txt file -async function getLatestAlphaVersion() { - const options = {}; - - const httpProxy = - process.env.HTTP_PROXY || - process.env.http_proxy || - process.env.HTTPS_PROXY || - process.env.https_proxy; - - if (httpProxy) { - options.agent = new HttpsProxyAgent(httpProxy); - } - try { - const response = await fetch(META_ALPHA_VERSION_URL, { - ...options, - method: "GET", - }); - let v = await response.text(); - META_ALPHA_VERSION = v.trim(); // Trim to remove extra whitespaces - log_info(`Latest alpha version: ${META_ALPHA_VERSION}`); - } catch (error) { - log_error("Error fetching latest alpha version:", error.message); - process.exit(1); - } -} - -/* ======= clash meta stable ======= */ const META_VERSION_URL = "https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt"; const META_URL_PREFIX = `https://github.com/MetaCubeX/mihomo/releases/download`; let META_VERSION; -const META_MAP = { +const META_ALPHA_MAP = { "win32-x64": "mihomo-windows-amd64-v2", "win32-ia32": "mihomo-windows-386", "win32-arm64": "mihomo-windows-arm64", - "darwin-x64": "mihomo-darwin-amd64-v2", - "darwin-arm64": "mihomo-darwin-arm64", + "darwin-x64": "mihomo-darwin-amd64-v1-go122", + "darwin-arm64": "mihomo-darwin-arm64-go122", "linux-x64": "mihomo-linux-amd64-v2", "linux-ia32": "mihomo-linux-386", "linux-arm64": "mihomo-linux-arm64", @@ -122,65 +189,116 @@ const META_MAP = { "linux-loong64": "mihomo-linux-loong64", }; -// Fetch the latest release version from the version.txt file -async function getLatestReleaseVersion() { - const options = {}; +const META_MAP = { + "win32-x64": "mihomo-windows-amd64-v2", + "win32-ia32": "mihomo-windows-386", + "win32-arm64": "mihomo-windows-arm64", + "darwin-x64": "mihomo-darwin-amd64-v2-go122", + "darwin-arm64": "mihomo-darwin-arm64-go122", + "linux-x64": "mihomo-linux-amd64-v2", + "linux-ia32": "mihomo-linux-386", + "linux-arm64": "mihomo-linux-arm64", + "linux-arm": "mihomo-linux-armv7", + "linux-riscv64": "mihomo-linux-riscv64", + "linux-loong64": "mihomo-linux-loong64", +}; +// ======================= +// Fetch latest versions +// ======================= +async function getLatestAlphaVersion() { + if (!FORCE) { + const cached = await getCachedVersion("META_ALPHA_VERSION"); + if (cached) { + META_ALPHA_VERSION = cached; + return; + } + } + const options = {}; const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy; + if (httpProxy) options.agent = new HttpsProxyAgent(httpProxy); - if (httpProxy) { - options.agent = new HttpsProxyAgent(httpProxy); + try { + const response = await fetch(META_ALPHA_VERSION_URL, { + ...options, + method: "GET", + }); + if (!response.ok) + throw new Error( + `Failed to fetch ${META_ALPHA_VERSION_URL}: ${response.status}`, + ); + META_ALPHA_VERSION = (await response.text()).trim(); + log_info(`Latest alpha version: ${META_ALPHA_VERSION}`); + await setCachedVersion("META_ALPHA_VERSION", META_ALPHA_VERSION); + } catch (err) { + log_error("Error fetching latest alpha version:", err.message); + process.exit(1); } +} + +async function getLatestReleaseVersion() { + if (!FORCE) { + const cached = await getCachedVersion("META_VERSION"); + if (cached) { + META_VERSION = cached; + return; + } + } + const options = {}; + const httpProxy = + process.env.HTTP_PROXY || + process.env.http_proxy || + process.env.HTTPS_PROXY || + process.env.https_proxy; + if (httpProxy) options.agent = new HttpsProxyAgent(httpProxy); + try { const response = await fetch(META_VERSION_URL, { ...options, method: "GET", }); - let v = await response.text(); - META_VERSION = v.trim(); // Trim to remove extra whitespaces + if (!response.ok) + throw new Error( + `Failed to fetch ${META_VERSION_URL}: ${response.status}`, + ); + META_VERSION = (await response.text()).trim(); log_info(`Latest release version: ${META_VERSION}`); - } catch (error) { - log_error("Error fetching latest release version:", error.message); + await setCachedVersion("META_VERSION", META_VERSION); + } catch (err) { + log_error("Error fetching latest release version:", err.message); process.exit(1); } } -/* - * check available - */ +// ======================= +// Validate availability +// ======================= if (!META_MAP[`${platform}-${arch}`]) { - throw new Error( - `clash meta alpha unsupported platform "${platform}-${arch}"`, - ); + throw new Error(`clash meta unsupported platform "${platform}-${arch}"`); } - if (!META_ALPHA_MAP[`${platform}-${arch}`]) { throw new Error( `clash meta alpha unsupported platform "${platform}-${arch}"`, ); } -/** - * core info - */ +// ======================= +// Build meta objects +// ======================= function clashMetaAlpha() { const name = META_ALPHA_MAP[`${platform}-${arch}`]; const isWin = platform === "win32"; const urlExt = isWin ? "zip" : "gz"; - const downloadURL = `${META_ALPHA_URL_PREFIX}/${name}-${META_ALPHA_VERSION}.${urlExt}`; - const exeFile = `${name}${isWin ? ".exe" : ""}`; - const zipFile = `${name}-${META_ALPHA_VERSION}.${urlExt}`; - return { name: "verge-mihomo-alpha", targetFile: `verge-mihomo-alpha-${SIDECAR_HOST}${isWin ? ".exe" : ""}`, - exeFile, - zipFile, - downloadURL, + exeFile: `${name}${isWin ? ".exe" : ""}`, + zipFile: `${name}-${META_ALPHA_VERSION}.${urlExt}`, + downloadURL: `${META_ALPHA_URL_PREFIX}/${name}-${META_ALPHA_VERSION}.${urlExt}`, }; } @@ -188,35 +306,83 @@ function clashMeta() { const name = META_MAP[`${platform}-${arch}`]; const isWin = platform === "win32"; const urlExt = isWin ? "zip" : "gz"; - const downloadURL = `${META_URL_PREFIX}/${META_VERSION}/${name}-${META_VERSION}.${urlExt}`; - const exeFile = `${name}${isWin ? ".exe" : ""}`; - const zipFile = `${name}-${META_VERSION}.${urlExt}`; - return { name: "verge-mihomo", targetFile: `verge-mihomo-${SIDECAR_HOST}${isWin ? ".exe" : ""}`, - exeFile, - zipFile, - downloadURL, + exeFile: `${name}${isWin ? ".exe" : ""}`, + zipFile: `${name}-${META_VERSION}.${urlExt}`, + downloadURL: `${META_URL_PREFIX}/${META_VERSION}/${name}-${META_VERSION}.${urlExt}`, }; } -/** - * download sidecar and rename - */ + +// ======================= +// download helper (增强:status + magic bytes) +// ======================= +async function downloadFile(url, outPath) { + const options = {}; + const httpProxy = + process.env.HTTP_PROXY || + process.env.http_proxy || + process.env.HTTPS_PROXY || + process.env.https_proxy; + if (httpProxy) options.agent = new HttpsProxyAgent(httpProxy); + + const response = await fetch(url, { + ...options, + method: "GET", + headers: { "Content-Type": "application/octet-stream" }, + }); + 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}`); + } + + 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); + throw new Error( + `Downloaded file for ${url} is not a valid gzip (magic mismatch).`, + ); + } + } else if (url.endsWith(".zip")) { + if (!(buf[0] === 0x50 && buf[1] === 0x4b)) { + await fsp.writeFile(outPath, buf); + throw new Error( + `Downloaded file for ${url} is not a valid zip (magic mismatch).`, + ); + } + } + + await fsp.writeFile(outPath, buf); + log_success(`download finished: ${url}`); +} + +// ======================= +// resolveSidecar (支持 zip / tgz / gz) +// ======================= async function resolveSidecar(binInfo) { const { name, targetFile, zipFile, exeFile, downloadURL } = binInfo; - const sidecarDir = path.join(cwd, "src-tauri", "sidecar"); const sidecarPath = path.join(sidecarDir, targetFile); - await fsp.mkdir(sidecarDir, { recursive: true }); - if (!FORCE && fs.existsSync(sidecarPath)) return; + + if (!FORCE && fs.existsSync(sidecarPath)) { + log_success(`"${name}" already exists, skipping download`); + return; + } const tempDir = path.join(TEMP_DIR, name); const tempZip = path.join(tempDir, zipFile); const tempExe = path.join(tempDir, exeFile); - await fsp.mkdir(tempDir, { recursive: true }); + try { if (!fs.existsSync(tempZip)) { await downloadFile(downloadURL, tempZip); @@ -225,140 +391,118 @@ async function resolveSidecar(binInfo) { if (zipFile.endsWith(".zip")) { const zip = new AdmZip(tempZip); zip.getEntries().forEach((entry) => { - log_debug(`"${name}" entry name`, entry.entryName); + log_debug(`"${name}" entry: ${entry.entryName}`); }); zip.extractAllTo(tempDir, true); - await fsp.rename(tempExe, sidecarPath); + // 尝试按 exeFile 重命名,否则找第一个可执行文件 + if (fs.existsSync(tempExe)) { + await fsp.rename(tempExe, sidecarPath); + } else { + // 搜索候选 + const files = await fsp.readdir(tempDir); + const candidate = files.find( + (f) => + f === path.basename(exeFile) || + f.endsWith(".exe") || + !f.includes("."), + ); + if (!candidate) + throw new Error(`Expected binary not found in ${tempDir}`); + await fsp.rename(path.join(tempDir, candidate), sidecarPath); + } + if (platform !== "win32") execSync(`chmod 755 ${sidecarPath}`); log_success(`unzip finished: "${name}"`); } else if (zipFile.endsWith(".tgz")) { - // tgz - await fsp.mkdir(tempDir, { recursive: true }); - await extract({ - cwd: tempDir, - file: tempZip, - //strip: 1, // 可能需要根据实际的 .tgz 文件结构调整 - }); + await extract({ cwd: tempDir, file: tempZip }); const files = await fsp.readdir(tempDir); - log_debug(`"${name}" files in tempDir:`, files); - const extractedFile = files.find((file) => file.startsWith("虚空终端-")); - if (extractedFile) { - const extractedFilePath = path.join(tempDir, extractedFile); - await fsp.rename(extractedFilePath, sidecarPath); - log_success(`"${name}" file renamed to "${sidecarPath}"`); - execSync(`chmod 755 ${sidecarPath}`); - log_success(`chmod binary finished: "${name}"`); - } else { - throw new Error(`Expected file not found in ${tempDir}`); - } + log_debug(`"${name}" extracted files:`, files); + // 优先寻找给定 exeFile 或已知前缀 + let extracted = files.find( + (f) => + f === path.basename(exeFile) || + f.startsWith("虚空终端-") || + !f.includes("."), + ); + if (!extracted) extracted = files[0]; + if (!extracted) throw new Error(`Expected file not found in ${tempDir}`); + await fsp.rename(path.join(tempDir, extracted), sidecarPath); + execSync(`chmod 755 ${sidecarPath}`); + log_success(`tgz processed: "${name}"`); } else { - // gz + // .gz const readStream = fs.createReadStream(tempZip); const writeStream = fs.createWriteStream(sidecarPath); await new Promise((resolve, reject) => { - const onError = (error) => { - log_error(`"${name}" gz failed:`, error.message); - reject(error); - }; readStream - .pipe(zlib.createGunzip().on("error", onError)) + .pipe(zlib.createGunzip()) + .on("error", (e) => { + log_error(`gunzip error for ${name}:`, e.message); + reject(e); + }) .pipe(writeStream) .on("finish", () => { - execSync(`chmod 755 ${sidecarPath}`); - log_success(`chmod binary finished: "${name}"`); + if (platform !== "win32") execSync(`chmod 755 ${sidecarPath}`); resolve(); }) - .on("error", onError); + .on("error", (e) => { + log_error(`write stream error for ${name}:`, e.message); + reject(e); + }); }); + log_success(`gz binary processed: "${name}"`); } } catch (err) { - // 需要删除文件 await fsp.rm(sidecarPath, { recursive: true, force: true }); throw err; } finally { - // delete temp dir await fsp.rm(tempDir, { recursive: true, force: true }); } } -const resolveSetDnsScript = () => - resolveResource({ - file: "set_dns.sh", - localPath: path.join(cwd, "scripts/set_dns.sh"), - }); -const resolveUnSetDnsScript = () => - resolveResource({ - file: "unset_dns.sh", - localPath: path.join(cwd, "scripts/unset_dns.sh"), - }); - -/** - * download the file to the resources dir - */ async function resolveResource(binInfo) { const { file, downloadURL, localPath } = binInfo; - const resDir = path.join(cwd, "src-tauri/resources"); const targetPath = path.join(resDir, file); - if (!FORCE && fs.existsSync(targetPath)) return; + if (!FORCE && fs.existsSync(targetPath) && !downloadURL && !localPath) { + log_success(`"${file}" already exists, skipping`); + return; + } if (downloadURL) { + if (!FORCE && fs.existsSync(targetPath)) { + log_success(`"${file}" already exists, skipping download`); + return; + } await fsp.mkdir(resDir, { recursive: true }); await downloadFile(downloadURL, targetPath); + await updateHashCache(targetPath); } if (localPath) { - await fs.copyFile(localPath, targetPath, (err) => { - if (err) { - console.error("Error copying file:", err); - } else { - console.log("File was copied successfully"); - } - }); - log_debug(`copy file finished: "${localPath}"`); + if (!(await hasFileChanged(localPath, targetPath))) { + return; + } + await fsp.mkdir(resDir, { recursive: true }); + await fsp.copyFile(localPath, targetPath); + await updateHashCache(targetPath); + log_success(`Copied file: ${file}`); } log_success(`${file} finished`); } -/** - * download file and save to `path` - */ async function downloadFile(url, path) { - const options = {}; - - const httpProxy = - process.env.HTTP_PROXY || - process.env.http_proxy || - process.env.HTTPS_PROXY || - process.env.https_proxy; - - if (httpProxy) { - options.agent = new HttpsProxyAgent(httpProxy); - } - - const response = await fetch(url, { - ...options, - method: "GET", - headers: { "Content-Type": "application/octet-stream" }, - }); - const buffer = await response.arrayBuffer(); - await fsp.writeFile(path, new Uint8Array(buffer)); - - log_success(`download finished: ${url}`); -} - -// SimpleSC.dll +// SimpleSC.dll (win plugin) const resolvePlugin = async () => { const url = "https://nsis.sourceforge.io/mediawiki/images/e/ef/NSIS_Simple_Service_Plugin_Unicode_1.30.zip"; - const tempDir = path.join(TEMP_DIR, "SimpleSC"); const tempZip = path.join( tempDir, "NSIS_Simple_Service_Plugin_Unicode_1.30.zip", ); const tempDll = path.join(tempDir, "SimpleSC.dll"); - const pluginDir = path.join(process.env.APPDATA, "Local/NSIS"); + const pluginDir = path.join(process.env.APPDATA || "", "Local/NSIS"); const pluginPath = path.join(pluginDir, "SimpleSC.dll"); await fsp.mkdir(pluginDir, { recursive: true }); await fsp.mkdir(tempDir, { recursive: true }); @@ -368,18 +512,33 @@ const resolvePlugin = async () => { await downloadFile(url, tempZip); } const zip = new AdmZip(tempZip); - zip.getEntries().forEach((entry) => { - log_debug(`"SimpleSC" entry name`, entry.entryName); - }); + zip + .getEntries() + .forEach((entry) => log_debug(`"SimpleSC" entry`, entry.entryName)); zip.extractAllTo(tempDir, true); - await fsp.cp(tempDll, pluginPath, { recursive: true, force: true }); - log_success(`unzip finished: "SimpleSC"`); + if (fs.existsSync(tempDll)) { + 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) { + await fsp.cp(path.join(tempDir, dll), pluginPath, { + recursive: true, + force: true, + }); + log_success(`unzip finished: "SimpleSC" (found ${dll})`); + } else { + throw new Error("SimpleSC.dll not found in zip"); + } + } } finally { await fsp.rm(tempDir, { recursive: true, force: true }); } }; -// service chmod +// service chmod (保留并使用 glob) const resolveServicePermission = async () => { const serviceExecutables = [ "clash-verge-service*", @@ -387,74 +546,59 @@ const resolveServicePermission = async () => { "clash-verge-service-uninstall*", ]; const resDir = path.join(cwd, "src-tauri/resources"); - for (let f of serviceExecutables) { - // 使用glob模块来处理通配符 + const hashCache = await loadHashCache(); + let hasChanges = false; + + for (const f of serviceExecutables) { const files = glob.sync(path.join(resDir, f)); - for (let filePath of files) { + for (const filePath of files) { if (fs.existsSync(filePath)) { - execSync(`chmod 755 ${filePath}`); - log_success(`chmod finished: "${filePath}"`); + const currentHash = await calculateFileHash(filePath); + const cacheKey = `${filePath}_chmod`; + if (!FORCE && hashCache[cacheKey] === currentHash) { + continue; + } + try { + execSync(`chmod 755 ${filePath}`); + log_success(`chmod finished: "${filePath}"`); + } catch (e) { + log_error(`chmod failed for ${filePath}:`, e.message); + } + hashCache[cacheKey] = currentHash; + hasChanges = true; } } } + + if (hasChanges) { + await saveHashCache(hashCache); + } }; -// 在 resolveResource 函数后添加新函数 -async function resolveLocales() { - const srcLocalesDir = path.join(cwd, "src/locales"); - const targetLocalesDir = path.join(cwd, "src-tauri/resources/locales"); - - try { - // 确保目标目录存在 - await fsp.mkdir(targetLocalesDir, { recursive: true }); - - // 读取所有语言文件 - const files = await fsp.readdir(srcLocalesDir); - - // 复制每个文件 - for (const file of files) { - const srcPath = path.join(srcLocalesDir, file); - const targetPath = path.join(targetLocalesDir, file); - - await fsp.copyFile(srcPath, targetPath); - log_success(`Copied locale file: ${file}`); - } - - log_success("All locale files copied successfully"); - } catch (err) { - log_error("Error copying locale files:", err.message); - throw err; - } -} - -/** - * main - */ +// ======================= +// Other resource resolvers (service, mmdb, geosite, geoip, enableLoopback, sysproxy) +// ======================= const SERVICE_URL = `https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download/${SIDECAR_HOST}`; - const resolveService = () => { - let ext = platform === "win32" ? ".exe" : ""; - let suffix = platform === "linux" ? "-" + SIDECAR_HOST : ""; - resolveResource({ + const ext = platform === "win32" ? ".exe" : ""; + const suffix = platform === "linux" ? "-" + SIDECAR_HOST : ""; + return resolveResource({ file: "clash-verge-service" + suffix + ext, downloadURL: `${SERVICE_URL}/clash-verge-service${ext}`, }); }; - const resolveInstall = () => { - let ext = platform === "win32" ? ".exe" : ""; - let suffix = platform === "linux" ? "-" + SIDECAR_HOST : ""; - resolveResource({ + const ext = platform === "win32" ? ".exe" : ""; + const suffix = platform === "linux" ? "-" + SIDECAR_HOST : ""; + return resolveResource({ file: "clash-verge-service-install" + suffix + ext, downloadURL: `${SERVICE_URL}/clash-verge-service-install${ext}`, }); }; - const resolveUninstall = () => { - let ext = platform === "win32" ? ".exe" : ""; - let suffix = platform === "linux" ? "-" + SIDECAR_HOST : ""; - - resolveResource({ + const ext = platform === "win32" ? ".exe" : ""; + const suffix = platform === "linux" ? "-" + SIDECAR_HOST : ""; + return resolveResource({ file: "clash-verge-service-uninstall" + suffix + ext, downloadURL: `${SERVICE_URL}/clash-verge-service-uninstall${ext}`, }); @@ -480,15 +624,27 @@ const resolveEnableLoopback = () => file: "enableLoopback.exe", downloadURL: `https://github.com/Kuingsmile/uwp-tool/releases/download/latest/enableLoopback.exe`, }); - const resolveWinSysproxy = () => resolveResource({ file: "sysproxy.exe", downloadURL: `https://github.com/clash-verge-rev/sysproxy/releases/download/${arch}/sysproxy.exe`, }); +const resolveSetDnsScript = () => + resolveResource({ + file: "set_dns.sh", + localPath: path.join(cwd, "scripts/set_dns.sh"), + }); +const resolveUnSetDnsScript = () => + resolveResource({ + file: "unset_dns.sh", + localPath: path.join(cwd, "scripts/unset_dns.sh"), + }); + +// ======================= +// Tasks +// ======================= const tasks = [ - // { name: "clash", func: resolveClash, retry: 5 }, { name: "verge-mihomo-alpha", func: () => @@ -538,11 +694,6 @@ const tasks = [ retry: 5, macosOnly: true, }, - { - name: "locales", - func: resolveLocales, - retry: 2, - }, ]; async function runTask() { diff --git a/clash-verge-rev/scripts/release-version.mjs b/clash-verge-rev/scripts/release-version.mjs index bc177a29c6..0a6fdbed87 100644 --- a/clash-verge-rev/scripts/release-version.mjs +++ b/clash-verge-rev/scripts/release-version.mjs @@ -30,10 +30,11 @@ */ import { execSync } from "child_process"; -import { program } from "commander"; import fs from "fs/promises"; import path from "path"; +import { program } from "commander"; + /** * 获取当前 git 短 commit hash * @returns {string} diff --git a/clash-verge-rev/scripts/telegram.mjs b/clash-verge-rev/scripts/telegram.mjs index d7c741fc83..914fdf9283 100644 --- a/clash-verge-rev/scripts/telegram.mjs +++ b/clash-verge-rev/scripts/telegram.mjs @@ -1,6 +1,8 @@ -import axios from "axios"; import { readFileSync } from "fs"; -import { log_success, log_error, log_info } from "./utils.mjs"; + +import axios from "axios"; + +import { log_error, log_info, log_success } from "./utils.mjs"; const CHAT_ID_RELEASE = "@clash_verge_re"; // 正式发布频道 const CHAT_ID_TEST = "@vergetest"; // 测试频道 @@ -71,6 +73,19 @@ async function sendTelegramNotification() { .join("\n"); } + function normalizeDetailsTags(content) { + return content + .replace( + /\s*\s*(.*?)\s*<\/strong>\s*<\/summary>/g, + "\n$1\n", + ) + .replace(/\s*(.*?)\s*<\/summary>/g, "\n$1\n") + .replace(/<\/?details>/g, "") + .replace(/<\/?strong>/g, (m) => (m === "" ? "" : "")) + .replace(//g, "\n"); + } + + releaseContent = normalizeDetailsTags(releaseContent); const formattedContent = convertMarkdownToTelegramHTML(releaseContent); const releaseTitle = isAutobuild ? "滚动更新版发布" : "正式发布"; diff --git a/clash-verge-rev/scripts/updatelog.mjs b/clash-verge-rev/scripts/updatelog.mjs index d3c90b581a..89faeabd7c 100644 --- a/clash-verge-rev/scripts/updatelog.mjs +++ b/clash-verge-rev/scripts/updatelog.mjs @@ -2,9 +2,9 @@ import fs from "fs"; import fsp from "fs/promises"; import path from "path"; -const UPDATE_LOG = "UPDATELOG.md"; +const UPDATE_LOG = "Changelog.md"; -// parse the UPDATELOG.md +// parse the Changelog.md export async function resolveUpdateLog(tag) { const cwd = process.cwd(); @@ -14,7 +14,7 @@ export async function resolveUpdateLog(tag) { const file = path.join(cwd, UPDATE_LOG); if (!fs.existsSync(file)) { - throw new Error("could not found UPDATELOG.md"); + throw new Error("could not found Changelog.md"); } const data = await fsp.readFile(file, "utf-8"); @@ -38,7 +38,7 @@ export async function resolveUpdateLog(tag) { }); if (!map[tag]) { - throw new Error(`could not found "${tag}" in UPDATELOG.md`); + throw new Error(`could not found "${tag}" in Changelog.md`); } return map[tag].join("\n").trim(); @@ -49,7 +49,7 @@ export async function resolveUpdateLogDefault() { const file = path.join(cwd, UPDATE_LOG); if (!fs.existsSync(file)) { - throw new Error("could not found UPDATELOG.md"); + throw new Error("could not found Changelog.md"); } const data = await fsp.readFile(file, "utf-8"); @@ -58,7 +58,7 @@ export async function resolveUpdateLogDefault() { const reEnd = /^---/; let isCapturing = false; - let content = []; + const content = []; let firstTag = ""; for (const line of data.split("\n")) { @@ -77,7 +77,7 @@ export async function resolveUpdateLogDefault() { } if (!firstTag) { - throw new Error("could not found any version tag in UPDATELOG.md"); + throw new Error("could not found any version tag in Changelog.md"); } return content.join("\n").trim(); diff --git a/clash-verge-rev/scripts/updater-fixed-webview2.mjs b/clash-verge-rev/scripts/updater-fixed-webview2.mjs index 64426d94a8..33389c6340 100644 --- a/clash-verge-rev/scripts/updater-fixed-webview2.mjs +++ b/clash-verge-rev/scripts/updater-fixed-webview2.mjs @@ -1,5 +1,6 @@ +import { context, getOctokit } from "@actions/github"; import fetch from "node-fetch"; -import { getOctokit, context } from "@actions/github"; + import { resolveUpdateLog } from "./updatelog.mjs"; const UPDATE_TAG_NAME = "updater"; @@ -35,7 +36,7 @@ async function resolveUpdater() { const updateData = { name: tag.name, - notes: await resolveUpdateLog(tag.name), // use updatelog.md + notes: await resolveUpdateLog(tag.name), // use Changelog.md pub_date: new Date().toISOString(), platforms: { "windows-x86_64": { signature: "", url: "" }, @@ -113,7 +114,7 @@ async function resolveUpdater() { }); // delete the old assets - for (let asset of updateRelease.assets) { + for (const asset of updateRelease.assets) { if (asset.name === UPDATE_JSON_FILE) { await github.rest.repos.deleteReleaseAsset({ ...options, diff --git a/clash-verge-rev/scripts/updater.mjs b/clash-verge-rev/scripts/updater.mjs index 9291d6ab98..51f6af3eb2 100644 --- a/clash-verge-rev/scripts/updater.mjs +++ b/clash-verge-rev/scripts/updater.mjs @@ -1,5 +1,6 @@ -import fetch from "node-fetch"; import { getOctokit, context } from "@actions/github"; +import fetch from "node-fetch"; + import { resolveUpdateLog, resolveUpdateLogDefault } from "./updatelog.mjs"; // Add stable update JSON filenames @@ -259,7 +260,7 @@ async function processRelease(github, options, tag, isAlpha) { const proxyFile = isAlpha ? ALPHA_UPDATE_JSON_PROXY : UPDATE_JSON_PROXY; // Delete existing assets with these names - for (let asset of updateRelease.assets) { + for (const asset of updateRelease.assets) { if (asset.name === jsonFile) { await github.rest.repos.deleteReleaseAsset({ ...options, diff --git a/clash-verge-rev/src-tauri/.clippy.toml b/clash-verge-rev/src-tauri/.clippy.toml index 0e7ffd070a..a1db40ca1e 100644 --- a/clash-verge-rev/src-tauri/.clippy.toml +++ b/clash-verge-rev/src-tauri/.clippy.toml @@ -1 +1,2 @@ -avoid-breaking-exported-api = true \ No newline at end of file +avoid-breaking-exported-api = true +cognitive-complexity-threshold = 25 \ No newline at end of file diff --git a/clash-verge-rev/src-tauri/Cargo.lock b/clash-verge-rev/src-tauri/Cargo.lock index ff2ba03f4d..f763b73022 100644 --- a/clash-verge-rev/src-tauri/Cargo.lock +++ b/clash-verge-rev/src-tauri/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -59,7 +50,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -67,13 +58,22 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -145,13 +145,25 @@ dependencies = [ "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", - "parking_lot 0.12.5", + "parking_lot", "percent-encoding", "windows-sys 0.60.2", "wl-clipboard-rs", "x11rb", ] +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayvec" version = "0.7.6" @@ -352,7 +364,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -392,7 +404,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -409,7 +421,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -473,7 +485,7 @@ dependencies = [ "http-body 0.4.6", "hyper 0.14.32", "itoa", - "matchit", + "matchit 0.7.3", "memchr", "mime", "percent-encoding", @@ -488,25 +500,23 @@ dependencies = [ [[package]] name = "axum" -version = "0.7.9" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" dependencies = [ - "async-trait", - "axum-core 0.4.5", + "axum-core 0.5.5", "bytes", "futures-util", "http 1.3.1", "http-body 1.0.1", "http-body-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "sync_wrapper 1.0.2", "tower 0.5.2", "tower-layer", @@ -532,19 +542,17 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.5" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" dependencies = [ - "async-trait", "bytes", - "futures-util", + "futures-core", "http 1.3.1", "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper 1.0.2", "tower-layer", "tower-service", @@ -565,19 +573,10 @@ dependencies = [ ] [[package]] -name = "backtrace" -version = "0.3.76" +name = "base62" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link 0.2.1", -] +checksum = "1adf9755786e27479693dedd3271691a92b5e242ab139cacb9fb8e7fb5381111" [[package]] name = "base64" @@ -605,11 +604,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -669,140 +668,142 @@ dependencies = [ [[package]] name = "boa_ast" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c340fe0f0b267787095cbe35240c6786ff19da63ec7b69367ba338eace8169b" +checksum = "bc119a5ad34c3f459062a96907f53358989b173d104258891bb74f95d93747e8" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "boa_interner", "boa_macros", "boa_string", - "indexmap 2.11.4", + "indexmap 2.12.0", "num-bigint", "rustc-hash", ] [[package]] name = "boa_engine" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f620c3f06f51e65c0504ddf04978be1b814ac6586f0b45f6019801ab5efd37f9" +checksum = "e637ec52ea66d76b0ca86180c259d6c7bb6e6a6e14b2f36b85099306d8b00cc3" dependencies = [ + "aligned-vec", "arrayvec", - "bitflags 2.9.4", + "bitflags 2.10.0", "boa_ast", "boa_gc", "boa_interner", "boa_macros", "boa_parser", - "boa_profiler", "boa_string", "bytemuck", "cfg-if", + "cow-utils", "dashmap 6.1.0", + "dynify", "fast-float2", - "hashbrown 0.15.5", - "icu_normalizer 1.5.0", - "indexmap 2.11.4", + "float16", + "futures-channel", + "futures-concurrency", + "futures-lite 2.6.1", + "hashbrown 0.16.0", + "icu_normalizer", + "indexmap 2.12.0", "intrusive-collections", - "itertools 0.13.0", + "itertools 0.14.0", "num-bigint", "num-integer", "num-traits", "num_enum", - "once_cell", - "pollster", + "paste", "portable-atomic", - "rand 0.8.5", + "rand 0.9.2", "regress", "rustc-hash", "ryu-js", "serde", "serde_json", - "sptr", + "small_btree", "static_assertions", + "tag_ptr", "tap", "thin-vec", "thiserror 2.0.17", "time", + "xsum", ] [[package]] name = "boa_gc" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2425c0b7720d42d73eaa6a883fbb77a5c920da8694964a3d79a67597ac55cce2" +checksum = "f1179f690cbfcbe5364cceee5f1cb577265bb6f07b0be6f210aabe270adcf9da" dependencies = [ "boa_macros", - "boa_profiler", "boa_string", - "hashbrown 0.15.5", + "hashbrown 0.16.0", "thin-vec", ] [[package]] name = "boa_interner" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42407a3b724cfaecde8f7d4af566df4b56af32a2f11f0956f5570bb974e7f749" +checksum = "9626505d33dc63d349662437297df1d3afd9d5fc4a2b3ad34e5e1ce879a78848" dependencies = [ "boa_gc", "boa_macros", - "hashbrown 0.15.5", - "indexmap 2.11.4", + "hashbrown 0.16.0", + "indexmap 2.12.0", "once_cell", - "phf 0.11.3", + "phf 0.13.1", "rustc-hash", "static_assertions", ] [[package]] name = "boa_macros" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fd3f870829131332587f607a7ff909f1af5fc523fd1b192db55fbbdf52e8d3c" +checksum = "7f36418a46544b152632c141b0a0b7a453cd69ca150caeef83aee9e2f4b48b7d" dependencies = [ + "cfg-if", + "cow-utils", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", "synstructure", ] [[package]] name = "boa_parser" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cc142dac798cdc6e2dbccfddeb50f36d2523bb977a976e19bdb3ae19b740804" +checksum = "02f99bf5b684f0de946378fcfe5f38c3a0fbd51cbf83a0f39ff773a0e218541f" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "boa_ast", "boa_interner", "boa_macros", - "boa_profiler", "fast-float2", - "icu_properties 1.5.1", + "icu_properties", "num-bigint", "num-traits", "regress", "rustc-hash", ] -[[package]] -name = "boa_profiler" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4064908e7cdf9b6317179e9b04dcb27f1510c1c144aeab4d0394014f37a0f922" - [[package]] name = "boa_string" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7debc13fbf7997bf38bf8e9b20f1ad5e2a7d27a900e1f6039fe244ce30f589b5" +checksum = "45ce9d7aa5563a2e14eab111e2ae1a06a69a812f6c0c3d843196c9d03fbef440" dependencies = [ "fast-float2", + "itoa", "paste", "rustc-hash", - "sptr", + "ryu-js", "static_assertions", ] @@ -827,6 +828,16 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -850,7 +861,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -876,9 +887,9 @@ dependencies = [ [[package]] name = "bzip2" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea8dcd42434048e4f7a304411d9273a411f647446c1234a65ce0554923f4cff" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" dependencies = [ "libbz2-rs-sys", ] @@ -889,7 +900,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "cairo-sys-rs", "glib", "libc", @@ -947,7 +958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" dependencies = [ "serde", - "toml 0.9.7", + "toml 0.9.8", ] [[package]] @@ -958,15 +969,18 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "castaway" -version = "0.1.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] [[package]] name = "cc" -version = "1.2.40" +version = "1.2.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb" +checksum = "739eb0f94557554b3ca9a86d2d37bebd49c5e6d0c1d2bda35ba5bdac830befc2" dependencies = [ "find-msvc-tools", "jobserver", @@ -1003,9 +1017,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -1066,18 +1080,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.48" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.48" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" dependencies = [ "anstyle", "clap_lex", @@ -1085,54 +1099,54 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "clash-verge" -version = "2.4.3" +version = "2.4.4" dependencies = [ "aes-gcm", "anyhow", + "arc-swap", + "async-trait", "backoff", "base64 0.22.1", "boa_engine", "chrono", "clash_verge_logger", "clash_verge_service_ipc", + "compact_str", "console-subscriber", "criterion", - "dashmap 6.1.0", "deelevate", "delay_timer", - "dirs 6.0.0", "dunce", "flexi_logger", "futures", "gethostname", - "getrandom 0.3.3", - "hex", - "hmac", - "isahc", + "getrandom 0.3.4", "libc", "log", "nanoid", "network-interface", "once_cell", "open", - "parking_lot 0.12.5", + "parking_lot", "percent-encoding", "port_scanner", "regex", "reqwest", "reqwest_dav", "runas", + "rust-i18n", "scopeguard", "serde", "serde_json", "serde_yaml_ng", - "sha2 0.10.9", + "signal-hook 0.3.18", + "smartstring", "sys-locale", "sysinfo", "sysproxy", @@ -1154,29 +1168,33 @@ dependencies = [ "tauri-plugin-window-state", "tokio", "tokio-stream", - "users", "warp", "winapi", + "windows-sys 0.61.2", "winreg 0.55.0", "zip 6.0.0", ] [[package]] name = "clash_verge_logger" -version = "0.1.0" -source = "git+https://github.com/clash-verge-rev/clash-verge-logger#d3033b152cbf45fd04d9dee48d7aa9371dbfe99c" +version = "0.2.0" +source = "git+https://github.com/clash-verge-rev/clash-verge-logger#9bb189b5b5c4c2eee35168ff4997e8fb10901c81" dependencies = [ + "arraydeque", + "compact_str", "flexi_logger", "log", "nu-ansi-term", + "tokio", ] [[package]] name = "clash_verge_service_ipc" -version = "2.0.14" -source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc#0b78603344302de33712d2f7554329bdb0091e1e" +version = "2.0.21" +source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc#1e34c648e48f8580208ff777686092e0a94b8025" dependencies = [ "anyhow", + "compact_str", "kode-bridge", "log", "once_cell", @@ -1202,7 +1220,7 @@ version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "block", "cocoa-foundation", "core-foundation 0.10.1", @@ -1218,7 +1236,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "block", "core-foundation 0.10.1", "core-graphics-types", @@ -1232,7 +1250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -1245,6 +1263,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rkyv", + "rustversion", + "ryu", + "serde", + "smallvec", + "static_assertions", +] + [[package]] name = "concat-idents" version = "1.1.5" @@ -1252,7 +1287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f76990911f2267d837d9d0ad060aa63aaad170af40904b29461734c339030d4d" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -1266,22 +1301,23 @@ dependencies = [ [[package]] name = "console-api" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8030735ecb0d128428b64cd379809817e620a40e5001c54465b99ec5feec2857" +checksum = "e8599749b6667e2f0c910c1d0dff6901163ff698a52d5a39720f61b5be4b20d3" dependencies = [ "futures-core", - "prost 0.13.5", - "prost-types 0.13.5", - "tonic 0.12.3", + "prost 0.14.1", + "prost-types 0.14.1", + "tonic 0.14.2", + "tonic-prost", "tracing-core", ] [[package]] name = "console-subscriber" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6539aa9c6a4cd31f4b1c040f860a1eac9aa80e7df6b05d506a6e7179936d6a01" +checksum = "fb4915b7d8dd960457a1b6c380114c2944f728e7c65294ab247ae6b6f1f37592" dependencies = [ "console-api", "crossbeam-channel", @@ -1290,14 +1326,14 @@ dependencies = [ "hdrhistogram", "humantime", "hyper-util", - "prost 0.13.5", - "prost-types 0.13.5", + "prost 0.14.1", + "prost-types 0.14.1", "serde", "serde_json", "thread_local", "tokio", "tokio-stream", - "tonic 0.12.3", + "tonic 0.14.2", "tracing", "tracing-core", "tracing-subscriber", @@ -1364,6 +1400,16 @@ dependencies = [ "url", ] +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1396,7 +1442,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", @@ -1409,11 +1455,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "core-foundation 0.10.1", "libc", ] +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1467,6 +1519,7 @@ dependencies = [ "serde", "serde_json", "tinytemplate", + "tokio", "walkdir", ] @@ -1566,7 +1619,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -1576,7 +1629,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -1588,36 +1641,6 @@ dependencies = [ "cipher", ] -[[package]] -name = "curl" -version = "0.4.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79fc3b6dd0b87ba36e565715bf9a2ced221311db47bd18011676f24a6066edbc" -dependencies = [ - "curl-sys", - "libc", - "openssl-probe", - "openssl-sys", - "schannel", - "socket2 0.6.0", - "windows-sys 0.59.0", -] - -[[package]] -name = "curl-sys" -version = "0.4.83+curl-8.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5830daf304027db10c82632a464879d46a3f7c4ba17a31592657ad16c719b483" -dependencies = [ - "cc", - "libc", - "libz-sys", - "openssl-sys", - "pkg-config", - "vcpkg", - "windows-sys 0.59.0", -] - [[package]] name = "darling" version = "0.21.3" @@ -1639,7 +1662,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -1650,7 +1673,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -1663,7 +1686,7 @@ dependencies = [ "hashbrown 0.14.5", "lock_api", "once_cell", - "parking_lot_core 0.9.12", + "parking_lot_core", ] [[package]] @@ -1677,7 +1700,7 @@ dependencies = [ "hashbrown 0.14.5", "lock_api", "once_cell", - "parking_lot_core 0.9.12", + "parking_lot_core", ] [[package]] @@ -1739,9 +1762,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", "serde_core", @@ -1755,7 +1778,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -1768,7 +1791,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -1806,13 +1829,19 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c06ffa9aeb3fb248b41d4e71ab3c0aa89177afc6669459da4320b97a4c77948" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "prost 0.12.6", "prost-types 0.12.6", "tonic 0.10.2", "tracing-core", ] +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + [[package]] name = "digest" version = "0.9.0" @@ -1899,7 +1928,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "block2 0.6.2", "libc", "objc2 0.6.3", @@ -1913,7 +1942,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -1945,7 +1974,7 @@ checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -1965,9 +1994,9 @@ checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" [[package]] name = "document-features" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ "litrs", ] @@ -2014,6 +2043,26 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "dynify" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81acb15628a3e22358bf73de5e7e62360b8a777dbcb5fc9ac7dfa9ae73723747" +dependencies = [ + "dynify-macros", +] + +[[package]] +name = "dynify-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec431cd708430d5029356535259c5d645d60edd3d39c54e5eea9782d46caa7d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "either" version = "1.15.0" @@ -2029,7 +2078,7 @@ dependencies = [ "cc", "memchr", "rustc_version 0.4.1", - "toml 0.9.7", + "toml 0.9.8", "vswhom", "winreg 0.55.0", ] @@ -2073,7 +2122,27 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", ] [[package]] @@ -2185,7 +2254,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -2232,9 +2301,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" [[package]] name = "fixedbitset" @@ -2243,10 +2312,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] -name = "flate2" -version = "1.1.4" +name = "fixedbitset" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", "libz-rs-sys", @@ -2266,6 +2341,16 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "float16" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bffafbd079d520191c7c2779ae9cf757601266cf4167d3f659ff09617ff8483" +dependencies = [ + "cfg-if", + "rustc_version 0.2.3", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2278,6 +2363,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -2305,7 +2396,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -2354,6 +2445,19 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-buffered" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e0e1f38ec07ba4abbde21eed377082f17ccb988be9d988a5adbf4bafc118fd" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -2364,6 +2468,21 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb68017df91f2e477ed4bea586c59eaecaa47ed885a770d0444e21e62572cd2" +dependencies = [ + "fixedbitset 0.5.7", + "futures-buffered", + "futures-core", + "futures-lite 2.6.1", + "pin-project", + "slab", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.31" @@ -2423,7 +2542,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -2565,10 +2684,24 @@ dependencies = [ ] [[package]] -name = "generic-array" -version = "0.14.7" +name = "generator" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows 0.61.3", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -2576,12 +2709,12 @@ dependencies = [ [[package]] name = "gethostname" -version = "1.0.2" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.2", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] @@ -2610,15 +2743,15 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", - "wasi 0.14.7+wasi-0.2.4", + "wasip2", "wasm-bindgen", ] @@ -2632,12 +2765,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - [[package]] name = "gio" version = "0.18.4" @@ -2676,7 +2803,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "futures-channel", "futures-core", "futures-executor", @@ -2700,11 +2827,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" dependencies = [ "heck 0.4.1", - "proc-macro-crate 2.0.2", + "proc-macro-crate 2.0.0", "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -2741,6 +2868,30 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2801,7 +2952,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -2816,7 +2967,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.11.4", + "indexmap 2.12.0", "slab", "tokio", "tokio-util", @@ -2835,7 +2986,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.3.1", - "indexmap 2.11.4", + "indexmap 2.12.0", "slab", "tokio", "tokio-util", @@ -2844,9 +2995,9 @@ dependencies = [ [[package]] name = "half" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e54c115d4f30f52c67202f079c5f9d8b49db4691f460fdb0b4c2e838261b2ba5" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", @@ -2887,7 +3038,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -2968,11 +3130,11 @@ dependencies = [ [[package]] name = "home" -version = "0.5.11" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3090,7 +3252,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.4.10", "tokio", "tower-service", "tracing", @@ -3196,7 +3358,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.1", "system-configuration", "tokio", "tower-service", @@ -3238,18 +3400,6 @@ dependencies = [ "png 0.17.16", ] -[[package]] -name = "icu_collections" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" -dependencies = [ - "displaydoc", - "yoke 0.7.5", - "zerofrom", - "zerovec 0.10.4", -] - [[package]] name = "icu_collections" version = "2.0.0" @@ -3258,96 +3408,42 @@ checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" dependencies = [ "displaydoc", "potential_utf", - "yoke 0.8.0", + "yoke", "zerofrom", - "zerovec 0.11.4", + "zerovec", ] [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", - "litemap 0.8.0", - "tinystr 0.8.1", - "writeable 0.6.1", - "zerovec 0.11.4", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", ] -[[package]] -name = "icu_locid" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" -dependencies = [ - "displaydoc", - "litemap 0.7.5", - "tinystr 0.7.6", - "writeable 0.5.5", - "zerovec 0.10.4", -] - -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider 1.5.0", - "tinystr 0.7.6", - "zerovec 0.10.4", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "8b24a59706036ba941c9476a55cd57b82b77f38a3c667d637ee7cabbc85eaedc" dependencies = [ "displaydoc", - "icu_collections 1.5.0", - "icu_normalizer_data 1.5.1", - "icu_properties 1.5.1", - "icu_provider 1.5.0", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", "smallvec", "utf16_iter", - "utf8_iter", "write16", - "zerovec 0.10.4", + "zerovec", ] -[[package]] -name = "icu_normalizer" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" -dependencies = [ - "displaydoc", - "icu_collections 2.0.0", - "icu_normalizer_data 2.0.0", - "icu_properties 2.0.1", - "icu_provider 2.0.0", - "smallvec", - "zerovec 0.11.4", -] - -[[package]] -name = "icu_normalizer_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" - [[package]] name = "icu_normalizer_data" version = "2.0.0" @@ -3356,41 +3452,20 @@ checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "f5a97b8ac6235e69506e8dacfb2adf38461d2ce6d3e9bd9c94c4cbc3cd4400a4" dependencies = [ "displaydoc", - "icu_collections 1.5.0", - "icu_locid_transform", - "icu_properties_data 1.5.1", - "icu_provider 1.5.0", - "tinystr 0.7.6", - "zerovec 0.10.4", -] - -[[package]] -name = "icu_properties" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" -dependencies = [ - "displaydoc", - "icu_collections 2.0.0", + "icu_collections", "icu_locale_core", - "icu_properties_data 2.0.1", - "icu_provider 2.0.0", + "icu_properties_data", + "icu_provider", "potential_utf", "zerotrie", - "zerovec 0.11.4", + "zerovec", ] -[[package]] -name = "icu_properties_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" - [[package]] name = "icu_properties_data" version = "2.0.1" @@ -3399,47 +3474,19 @@ checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr 0.7.6", - "writeable 0.5.5", - "yoke 0.7.5", - "zerofrom", - "zerovec 0.10.4", -] - -[[package]] -name = "icu_provider" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", + "serde", "stable_deref_trait", - "tinystr 0.8.1", - "writeable 0.6.1", - "yoke 0.8.0", + "writeable", + "yoke", "zerofrom", "zerotrie", - "zerovec 0.11.4", -] - -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", + "zerovec", ] [[package]] @@ -3465,8 +3512,24 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ - "icu_normalizer 2.0.0", - "icu_properties 2.0.1", + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", ] [[package]] @@ -3496,12 +3559,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.11.4" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", - "hashbrown 0.15.5", + "hashbrown 0.16.0", "serde", "serde_core", ] @@ -3539,7 +3602,7 @@ version = "0.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb6250a98af259a26fd5a4a6081fccea9ac116e4c3178acf4aeb86d32d2b7715" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "cc", "handlebars", "lazy_static", @@ -3584,17 +3647,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "io-uring" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" -dependencies = [ - "bitflags 2.9.4", - "cfg-if", - "libc", -] - [[package]] name = "ipnet" version = "2.11.0" @@ -3642,31 +3694,12 @@ dependencies = [ ] [[package]] -name = "isahc" -version = "1.7.2" +name = "itertools" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" dependencies = [ - "async-channel 1.9.0", - "castaway", - "crossbeam-utils", - "curl", - "curl-sys", - "encoding_rs", - "event-listener 2.5.3", - "futures-lite 1.13.0", - "http 0.2.12", - "log", - "mime", - "once_cell", - "parking_lot 0.11.2", - "polling 2.8.0", - "slab", - "sluice", - "tracing", - "tracing-futures", - "url", - "waker-fn", + "either", ] [[package]] @@ -3753,15 +3786,15 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.81" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" dependencies = [ "once_cell", "wasm-bindgen", @@ -3795,7 +3828,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "serde", "unicode-segmentation", ] @@ -3812,7 +3845,7 @@ dependencies = [ "httparse", "interprocess", "libc", - "parking_lot 0.12.5", + "parking_lot", "path-tree", "pin-project-lite", "rand 0.9.2", @@ -3822,7 +3855,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", - "toml 0.9.7", + "toml 0.9.8", "tracing", "url", "widestring", @@ -3836,7 +3869,7 @@ checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" dependencies = [ "cssparser", "html5ever", - "indexmap 2.11.4", + "indexmap 2.12.0", "selectors", ] @@ -3908,9 +3941,9 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "libc", - "redox_syscall 0.5.18", + "redox_syscall", ] [[package]] @@ -3922,18 +3955,6 @@ dependencies = [ "zlib-rs", ] -[[package]] -name = "libz-sys" -version = "1.1.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -3954,21 +3975,15 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" - -[[package]] -name = "litemap" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "litrs" -version = "0.4.2" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "local-ip-address" @@ -3997,6 +4012,19 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lru" version = "0.12.5" @@ -4030,9 +4058,9 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mac-notification-sys" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "119c8490084af61b44c9eda9d626475847a186737c0378c85e32d77c33a01cd4" +checksum = "4ee70bb2bba058d58e252d2944582d634fc884fc9c489a966d428dedcf653e97" dependencies = [ "cc", "objc2 0.6.3", @@ -4071,7 +4099,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -4095,6 +4123,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md-5" version = "0.10.6" @@ -4175,20 +4209,20 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "moxcms" -version = "0.7.6" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cc7d85f3d741164e8972ad355e26ac6e51b20fcae5f911c7da8f2d8bbbb3f33" +checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6" dependencies = [ "num-traits", "pxfm", @@ -4215,6 +4249,26 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "nanoid" version = "0.4.0" @@ -4247,7 +4301,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "jni-sys", "log", "ndk-sys", @@ -4334,7 +4388,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "cfg-if", "cfg_aliases", "libc", @@ -4367,6 +4421,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "normpath" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf23ab2b905654b4cb177e30b629937b3868311d4e1cba859f899c041046e69b" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "notify-rust" version = "4.11.7" @@ -4392,11 +4455,11 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.50.1" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4447,9 +4510,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" dependencies = [ "num_enum_derive", "rustversion", @@ -4457,14 +4520,14 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -4517,7 +4580,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "block2 0.6.2", "libc", "objc2 0.6.3", @@ -4538,7 +4601,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "objc2 0.6.3", "objc2-foundation 0.3.2", ] @@ -4549,7 +4612,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "objc2 0.6.3", "objc2-foundation 0.3.2", ] @@ -4560,7 +4623,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "dispatch2", "objc2 0.6.3", ] @@ -4571,7 +4634,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "dispatch2", "objc2 0.6.3", "objc2-core-foundation", @@ -4594,7 +4657,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "objc2 0.6.3", "objc2-core-foundation", "objc2-core-graphics", @@ -4606,7 +4669,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "objc2 0.6.3", "objc2-core-foundation", "objc2-core-graphics", @@ -4634,7 +4697,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -4646,7 +4709,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "block2 0.6.2", "libc", "objc2 0.6.3", @@ -4669,7 +4732,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "objc2 0.6.3", "objc2-core-foundation", ] @@ -4690,7 +4753,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4702,7 +4765,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "objc2 0.6.3", "objc2-app-kit", "objc2-foundation 0.3.2", @@ -4714,7 +4777,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4727,7 +4790,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "objc2 0.6.3", "objc2-foundation 0.3.2", ] @@ -4738,7 +4801,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "objc2 0.6.3", "objc2-core-foundation", ] @@ -4749,7 +4812,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "objc2 0.6.3", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -4761,7 +4824,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "block2 0.6.2", "objc2 0.6.3", "objc2-app-kit", @@ -4771,22 +4834,13 @@ dependencies = [ "objc2-security", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" dependencies = [ - "parking_lot_core 0.9.12", + "parking_lot_core", ] [[package]] @@ -4815,11 +4869,11 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.73" +version = "0.10.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "cfg-if", "foreign-types 0.3.2", "libc", @@ -4836,7 +4890,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -4847,9 +4901,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.109" +version = "0.9.110" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" dependencies = [ "cc", "libc", @@ -4894,12 +4948,12 @@ dependencies = [ [[package]] name = "os_pipe" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db335f4760b14ead6290116f2427bf33a14d4f0617d49f78a246de10c1831224" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4947,17 +5001,6 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" -[[package]] -name = "parking_lot" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" -dependencies = [ - "instant", - "lock_api", - "parking_lot_core 0.8.6", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -4965,21 +5008,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", - "parking_lot_core 0.9.12", -] - -[[package]] -name = "parking_lot_core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" -dependencies = [ - "cfg-if", - "instant", - "libc", - "redox_syscall 0.2.16", - "smallvec", - "winapi", + "parking_lot_core", ] [[package]] @@ -4990,7 +5019,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link 0.2.1", ] @@ -5072,7 +5101,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -5091,8 +5120,8 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ - "fixedbitset", - "indexmap 2.11.4", + "fixedbitset 0.4.2", + "indexmap 2.12.0", ] [[package]] @@ -5125,6 +5154,16 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_codegen" version = "0.8.0" @@ -5175,6 +5214,16 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand 2.3.0", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.10.0" @@ -5199,7 +5248,20 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.108", ] [[package]] @@ -5229,6 +5291,15 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.1", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -5246,7 +5317,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -5285,7 +5356,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", - "indexmap 2.11.4", + "indexmap 2.12.0", "quick-xml 0.38.3", "serde", "time", @@ -5338,7 +5409,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "crc32fast", "fdeflate", "flate2", @@ -5375,12 +5446,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "pollster" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" - [[package]] name = "polyval" version = "0.6.2" @@ -5416,11 +5481,11 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ - "zerovec 0.11.4", + "zerovec", ] [[package]] @@ -5462,11 +5527,10 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "2.0.2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" dependencies = [ - "toml_datetime 0.6.3", "toml_edit 0.20.2", ] @@ -5476,7 +5540,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.23.6", + "toml_edit 0.23.7", ] [[package]] @@ -5511,9 +5575,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] @@ -5530,12 +5594,12 @@ dependencies = [ [[package]] name = "prost" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" dependencies = [ "bytes", - "prost-derive 0.13.5", + "prost-derive 0.14.1", ] [[package]] @@ -5548,20 +5612,20 @@ dependencies = [ "itertools 0.12.1", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] name = "prost-derive" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -5575,11 +5639,11 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" dependencies = [ - "prost 0.13.5", + "prost 0.14.1", ] [[package]] @@ -5588,6 +5652,26 @@ version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "publicsuffix" version = "2.3.0" @@ -5600,9 +5684,9 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83f9b339b02259ada5c0f4a389b7fb472f933aa17ce176fd2ad98f28bb401fde" +checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" dependencies = [ "num-traits", ] @@ -5644,7 +5728,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.1", "thiserror 2.0.17", "tokio", "tracing", @@ -5658,7 +5742,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "bytes", - "getrandom 0.3.3", + "getrandom 0.3.4", "lru-slab", "rand 0.9.2", "ring", @@ -5681,7 +5765,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.1", "tracing", "windows-sys 0.60.2", ] @@ -5701,6 +5785,15 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rancor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +dependencies = [ + "ptr_meta", +] + [[package]] name = "rand" version = "0.7.3" @@ -5790,7 +5883,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", ] [[package]] @@ -5843,22 +5936,13 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" -[[package]] -name = "redox_syscall" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags 1.3.2", -] - [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", ] [[package]] @@ -5900,14 +5984,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] name = "regex" -version = "1.12.1" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a52d8d02cacdb176ef4678de6c052efb4b3da14b78e4db683a4252762be5433" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", @@ -5917,9 +6001,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "722166aa0d7438abbaa4d5cc2c649dac844e8c56d82fb3d33e9c34b5cd268fc6" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -5928,25 +6012,31 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "regress" -version = "0.10.4" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145bb27393fe455dd64d6cbc8d059adfa392590a45eadf079c01b11857e7b010" +checksum = "2057b2325e68a893284d1538021ab90279adac1139957ca2a74426c6f118fb48" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.0", "memchr", ] [[package]] -name = "reqwest" -version = "0.12.23" +name = "rend" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64 0.22.1", "bytes", @@ -6061,6 +6151,30 @@ dependencies = [ "portable-atomic-util", ] +[[package]] +name = "rkyv" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a640b26f007713818e9a9b65d34da1cf58538207b052916a83d80e43f3ffa4" +dependencies = [ + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd83f5f173ff41e00337d97f6572e416d022ef8a19f371817259ae960324c482" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "rs-snowflake" version = "0.6.0" @@ -6079,6 +6193,60 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rust-i18n" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332" +dependencies = [ + "globwalk", + "once_cell", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965" +dependencies = [ + "glob", + "once_cell", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "serde_yaml", + "syn 2.0.108", +] + +[[package]] +name = "rust-i18n-support" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "lazy_static", + "normpath", + "once_cell", + "proc-macro2", + "regex", + "serde", + "serde_json", + "serde_yaml", + "siphasher 1.0.1", + "toml 0.8.23", + "triomphe", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -6089,12 +6257,6 @@ dependencies = [ "ordered-multimap", ] -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - [[package]] name = "rustc-hash" version = "2.1.1" @@ -6139,7 +6301,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -6152,7 +6314,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys 0.11.0", @@ -6161,9 +6323,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.32" +version = "0.23.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" +checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" dependencies = [ "once_cell", "ring", @@ -6175,9 +6337,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.12.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" dependencies = [ "web-time", "zeroize", @@ -6185,9 +6347,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.7" +version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ "ring", "rustls-pki-types", @@ -6278,7 +6440,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -6299,7 +6461,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -6428,7 +6590,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -6439,7 +6601,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -6463,7 +6625,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -6477,9 +6639,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5417783452c2be558477e104686f7de5dae53dba813c28435e0e70f82d9b04ee" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" dependencies = [ "serde_core", ] @@ -6498,15 +6660,15 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.15.0" +version = "3.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6093cd8c01b25262b84927e0f7151692158fab02d961e04c979d3903eba7ecc5" +checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.11.4", + "indexmap 2.12.0", "schemars 0.9.0", "schemars 1.0.4", "serde_core", @@ -6517,14 +6679,27 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.15.0" +version = "3.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7e6c180db0816026a61afa1cff5344fb7ebded7e4d3062772179f2501481c27" +checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.12.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", ] [[package]] @@ -6533,7 +6708,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.12.0", "itoa", "ryu", "serde", @@ -6559,7 +6734,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -6708,14 +6883,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] -name = "sluice" -version = "0.5.5" +name = "small_btree" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5" +checksum = "0ba60d2df92ba73864714808ca68c059734853e6ab722b40e1cf543ebb3a057a" dependencies = [ - "async-channel 1.9.0", - "futures-core", - "futures-io", + "arrayvec", ] [[package]] @@ -6724,6 +6897,18 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "serde", + "static_assertions", + "version_check", +] + [[package]] name = "smol" version = "1.3.0" @@ -6753,22 +6938,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.10" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" -dependencies = [ - "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -6787,7 +6962,7 @@ dependencies = [ "objc2-foundation 0.2.2", "objc2-quartz-core 0.2.2", "raw-window-handle", - "redox_syscall 0.5.18", + "redox_syscall", "wasm-bindgen", "web-sys", "windows-sys 0.59.0", @@ -6820,10 +6995,10 @@ dependencies = [ ] [[package]] -name = "sptr" -version = "0.3.2" +name = "spin" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" [[package]] name = "stable_deref_trait" @@ -6844,7 +7019,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", - "parking_lot 0.12.5", + "parking_lot", "phf_shared 0.11.3", "precomputed-hash", "serde", @@ -6883,7 +7058,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -6916,9 +7091,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.106" +version = "2.0.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" dependencies = [ "proc-macro2", "quote", @@ -6948,7 +7123,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -6976,16 +7151,16 @@ dependencies = [ [[package]] name = "sysproxy" -version = "0.3.0" -source = "git+https://github.com/clash-verge-rev/sysproxy-rs#9fe61ca25dc5808cb6d7f13ae73a7a250ab56173" +version = "0.3.1" +source = "git+https://github.com/clash-verge-rev/sysproxy-rs#ea6e5b5bcef32025e1df914d663eea8558afacb2" dependencies = [ "interfaces", "iptools", "log", - "thiserror 1.0.69", + "thiserror 2.0.17", "url", - "windows 0.58.0", - "winreg 0.52.0", + "windows 0.62.2", + "winreg 0.55.0", "xdg", ] @@ -6995,7 +7170,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -7019,17 +7194,23 @@ dependencies = [ "cfg-expr", "heck 0.5.0", "pkg-config", - "toml 0.8.2", + "toml 0.8.23", "version-compare", ] [[package]] -name = "tao" -version = "0.34.3" +name = "tag_ptr" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7" +checksum = "c0e973b34477b7823833469eb0f5a3a60370fef7a453e02d751b59180d0a5a05" + +[[package]] +name = "tao" +version = "0.34.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "block2 0.6.2", "core-foundation 0.10.1", "core-graphics", @@ -7051,7 +7232,7 @@ dependencies = [ "objc2-app-kit", "objc2-foundation 0.3.2", "once_cell", - "parking_lot 0.12.5", + "parking_lot", "raw-window-handle", "scopeguard", "tao-macros", @@ -7071,7 +7252,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -7099,9 +7280,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.8.5" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d1d3b3dc4c101ac989fd7db77e045cc6d91a25349cd410455cb5c57d510c1c" +checksum = "9e492485dd390b35f7497401f67694f46161a2a00ffd800938d5dd3c898fb9d8" dependencies = [ "anyhow", "bytes", @@ -7109,7 +7290,7 @@ dependencies = [ "dirs 6.0.0", "dunce", "embed_plist", - "getrandom 0.3.3", + "getrandom 0.3.4", "glob", "gtk", "heck 0.5.0", @@ -7145,7 +7326,6 @@ dependencies = [ "tracing", "tray-icon", "url", - "urlpattern", "webkit2gtk", "webview2-com", "window-vibrancy", @@ -7154,9 +7334,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.4.1" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c432ccc9ff661803dab74c6cd78de11026a578a9307610bbc39d3c55be7943f" +checksum = "87d6f8cafe6a75514ce5333f115b7b1866e8e68d9672bf4ca89fc0f35697ea9d" dependencies = [ "anyhow", "cargo_toml", @@ -7170,15 +7350,15 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.7", + "toml 0.9.8", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.4.0" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ab3a62cf2e6253936a8b267c2e95839674e7439f104fa96ad0025e149d54d8a" +checksum = "b7ef707148f0755110ca54377560ab891d722de4d53297595380a748026f139f" dependencies = [ "base64 0.22.1", "brotli", @@ -7192,7 +7372,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "syn 2.0.106", + "syn 2.0.108", "tauri-utils", "thiserror 2.0.17", "time", @@ -7203,23 +7383,23 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.4.0" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4368ea8094e7045217edb690f493b55b30caf9f3e61f79b4c24b6db91f07995e" +checksum = "71664fd715ee6e382c05345ad258d6d1d50f90cf1b58c0aa726638b33c2a075d" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", "tauri-codegen", "tauri-utils", ] [[package]] name = "tauri-plugin" -version = "2.4.0" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9946a3cede302eac0c6eb6c6070ac47b1768e326092d32efbb91f21ed58d978f" +checksum = "076c78a474a7247c90cad0b6e87e593c4c620ed4efdb79cbe0214f0021f6c39d" dependencies = [ "anyhow", "glob", @@ -7228,15 +7408,15 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml 0.9.7", + "toml 0.9.8", "walkdir", ] [[package]] name = "tauri-plugin-autostart" -version = "2.5.0" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "062cdcd483d5e3148c9a64dabf8c574e239e2aa1193cf208d95cf89a676f87a5" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" dependencies = [ "auto-launch", "serde", @@ -7248,9 +7428,9 @@ dependencies = [ [[package]] name = "tauri-plugin-clipboard-manager" -version = "2.3.0" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adddd9e9275b20e77af3061d100a25a884cced3c4c9ef680bd94dd0f7e26c1ca" +checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" dependencies = [ "arboard", "log", @@ -7263,9 +7443,9 @@ dependencies = [ [[package]] name = "tauri-plugin-deep-link" -version = "2.4.3" +version = "2.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd67112fb1131834c2a7398ffcba520dbbf62c17de3b10329acd1a3554b1a9bb" +checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73" dependencies = [ "dunce", "plist", @@ -7311,9 +7491,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.4.0" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beee42a4002bc695550599b011728d9dfabf82f767f134754ed6655e434824e" +checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19" dependencies = [ "log", "raw-window-handle", @@ -7329,9 +7509,9 @@ dependencies = [ [[package]] name = "tauri-plugin-fs" -version = "2.4.2" +version = "2.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "315784ec4be45e90a987687bae7235e6be3d6e9e350d2b75c16b8a4bf22c1db7" +checksum = "47df422695255ecbe7bac7012440eddaeefd026656171eac9559f5243d3230d9" dependencies = [ "anyhow", "dunce", @@ -7345,15 +7525,15 @@ dependencies = [ "tauri-plugin", "tauri-utils", "thiserror 2.0.17", - "toml 0.9.7", + "toml 0.9.8", "url", ] [[package]] name = "tauri-plugin-global-shortcut" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df9f0f7bf2fe768b85fee4951c2505a35b72c44df1f6403e74e110bc13c5f58" +checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405" dependencies = [ "global-hotkey", "log", @@ -7366,9 +7546,9 @@ dependencies = [ [[package]] name = "tauri-plugin-http" -version = "2.5.2" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "938a3d7051c9a82b431e3a0f3468f85715b3442b3c3a3913095e9fa509e2652c" +checksum = "c00685aceab12643cf024f712ab0448ba8fcadf86f2391d49d2e5aa732aacc70" dependencies = [ "bytes", "cookie_store", @@ -7391,7 +7571,7 @@ dependencies = [ [[package]] name = "tauri-plugin-mihomo" version = "0.1.1" -source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo#719a31f364e23268c5b8c85169de5168d4843c15" +source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo#d0f00b33cea294cc693e177441fc897426ecbc39" dependencies = [ "base64 0.22.1", "futures-util", @@ -7415,9 +7595,9 @@ dependencies = [ [[package]] name = "tauri-plugin-notification" -version = "2.3.1" +version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2fbc86b929b5376ab84b25c060f966d146b2fbd59b6af8264027b343c82c219" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" dependencies = [ "log", "notify-rust", @@ -7434,9 +7614,9 @@ dependencies = [ [[package]] name = "tauri-plugin-process" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7461c622a5ea00eb9cd9f7a08dbd3bf79484499fd5c21aa2964677f64ca651ab" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" dependencies = [ "tauri", "tauri-plugin", @@ -7444,9 +7624,9 @@ dependencies = [ [[package]] name = "tauri-plugin-shell" -version = "2.3.1" +version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54777d0c0d8add34eea3ced84378619ef5b97996bd967d3038c668feefd21071" +checksum = "c374b6db45f2a8a304f0273a15080d98c70cde86178855fc24653ba657a1144c" dependencies = [ "encoding_rs", "log", @@ -7497,11 +7677,11 @@ dependencies = [ [[package]] name = "tauri-plugin-window-state" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5f6fe3291bfa609c7e0b0ee3bedac294d94c7018934086ce782c1d0f2a468e" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "log", "serde", "serde_json", @@ -7512,9 +7692,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.8.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4cfc9ad45b487d3fded5a4731a567872a4812e9552e3964161b08edabf93846" +checksum = "9368f09358496f2229313fccb37682ad116b7f46fa76981efe116994a0628926" dependencies = [ "cookie", "dpi", @@ -7537,9 +7717,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.8.1" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fe9d48bd122ff002064e88cfcd7027090d789c4302714e68fcccba0f4b7807" +checksum = "929f5df216f5c02a9e894554401bcdab6eec3e39ec6a4a7731c7067fc8688a93" dependencies = [ "gtk", "http 1.3.1", @@ -7565,9 +7745,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.7.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a3852fdf9a4f8fbeaa63dc3e9a85284dd6ef7200751f0bd66ceee30c93f212" +checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673" dependencies = [ "anyhow", "brotli", @@ -7594,7 +7774,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.17", - "toml 0.9.7", + "toml 0.9.8", "url", "urlpattern", "uuid", @@ -7608,7 +7788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd21509dd1fa9bd355dc29894a6ff10635880732396aa38c0066c1e6c1ab8074" dependencies = [ "embed-resource", - "toml 0.9.7", + "toml 0.9.8", ] [[package]] @@ -7630,7 +7810,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand 2.3.0", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.2", "windows-sys 0.61.2", @@ -7742,7 +7922,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -7753,7 +7933,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -7824,22 +8004,13 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", - "zerovec 0.10.4", -] - -[[package]] -name = "tinystr" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" -dependencies = [ - "displaydoc", - "zerovec 0.11.4", + "serde_core", + "zerovec", ] [[package]] @@ -7869,23 +8040,20 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.47.1" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", - "parking_lot 0.12.5", + "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", - "socket2 0.6.0", + "socket2 0.6.1", "tokio-macros", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7900,13 +8068,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -7967,26 +8135,26 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.2" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "toml_edit 0.20.2", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] name = "toml" -version = "0.9.7" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.12.0", "serde_core", - "serde_spanned 1.0.2", - "toml_datetime 0.7.2", + "serde_spanned 1.0.3", + "toml_datetime 0.7.3", "toml_parser", "toml_writer", "winnow 0.7.13", @@ -7994,18 +8162,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.3" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] [[package]] name = "toml_datetime" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" dependencies = [ "serde_core", ] @@ -8016,8 +8184,8 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.11.4", - "toml_datetime 0.6.3", + "indexmap 2.12.0", + "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -8027,39 +8195,57 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.11.4", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", + "indexmap 2.12.0", + "toml_datetime 0.6.11", "winnow 0.5.40", ] [[package]] name = "toml_edit" -version = "0.23.6" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3effe7c0e86fdff4f69cdd2ccc1b96f933e24811c5441d44904e8683e27184b" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.11.4", - "toml_datetime 0.7.2", + "indexmap 2.12.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.13", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap 2.12.0", + "toml_datetime 0.7.3", "toml_parser", "winnow 0.7.13", ] [[package]] name = "toml_parser" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ "winnow 0.7.13", ] [[package]] -name = "toml_writer" -version = "1.0.3" +name = "toml_write" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" [[package]] name = "tonic" @@ -8090,13 +8276,12 @@ dependencies = [ [[package]] name = "tonic" -version = "0.12.3" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" dependencies = [ - "async-stream", "async-trait", - "axum 0.7.9", + "axum 0.8.6", "base64 0.22.1", "bytes", "h2 0.4.12", @@ -8108,11 +8293,11 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "prost 0.13.5", - "socket2 0.5.10", + "socket2 0.6.1", + "sync_wrapper 1.0.2", "tokio", "tokio-stream", - "tower 0.4.13", + "tower 0.5.2", "tower-layer", "tower-service", "tracing", @@ -8131,6 +8316,17 @@ dependencies = [ "tonic 0.10.2", ] +[[package]] +name = "tonic-prost" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" +dependencies = [ + "bytes", + "prost 0.14.1", + "tonic 0.14.2", +] + [[package]] name = "tonic-web" version = "0.10.2" @@ -8179,11 +8375,15 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", + "indexmap 2.12.0", "pin-project-lite", + "slab", "sync_wrapper 1.0.2", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -8192,7 +8392,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "bytes", "futures-core", "futures-util", @@ -8210,7 +8410,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "bytes", "futures-util", "http 1.3.1", @@ -8254,7 +8454,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -8267,16 +8467,6 @@ dependencies = [ "valuable", ] -[[package]] -name = "tracing-futures" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" -dependencies = [ - "pin-project", - "tracing", -] - [[package]] name = "tracing-log" version = "0.2.0" @@ -8308,9 +8498,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2" +checksum = "e3d5572781bee8e3f994d7467084e1b1fd7a93ce66bd480f8156ba89dee55a2b" dependencies = [ "crossbeam-channel", "dirs 6.0.0", @@ -8325,7 +8515,7 @@ dependencies = [ "png 0.17.16", "serde", "thiserror 2.0.17", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -8340,6 +8530,17 @@ dependencies = [ "petgraph", ] +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -8348,9 +8549,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ts-rs" -version = "11.0.1" +version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef1b7a6d914a34127ed8e1fa927eb7088903787bcded4fa3eef8f85ee1568be" +checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424" dependencies = [ "thiserror 2.0.17", "ts-rs-macros", @@ -8358,13 +8559,13 @@ dependencies = [ [[package]] name = "ts-rs-macros" -version = "11.0.1" +version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d4ed7b4c18cc150a6a0a1e9ea1ecfa688791220781af6e119f9599a8502a0a" +checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", "termcolor", ] @@ -8463,9 +8664,9 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-segmentation" @@ -8525,16 +8726,6 @@ dependencies = [ "url", ] -[[package]] -name = "users" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24cc0f6d6f267b73e5a2cadf007ba8f9bc39c6a6f9666f8cf25ea809a153b032" -dependencies = [ - "libc", - "log", -] - [[package]] name = "utf-8" version = "0.7.6" @@ -8565,7 +8756,7 @@ version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "js-sys", "serde", "wasm-bindgen", @@ -8690,15 +8881,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -8710,9 +8892,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" dependencies = [ "cfg-if", "once_cell", @@ -8721,25 +8903,11 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.106", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-futures" -version = "0.4.54" +version = "0.4.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" dependencies = [ "cfg-if", "js-sys", @@ -8750,9 +8918,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8760,22 +8928,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.106", - "wasm-bindgen-backend", + "syn 2.0.108", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" dependencies = [ "unicode-ident", ] @@ -8813,7 +8981,7 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "rustix 1.1.2", "wayland-backend", "wayland-scanner", @@ -8825,7 +8993,7 @@ version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -8837,7 +9005,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -8868,9 +9036,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.81" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" dependencies = [ "js-sys", "wasm-bindgen", @@ -8949,8 +9117,8 @@ dependencies = [ "webview2-com-sys", "windows 0.61.3", "windows-core 0.61.2", - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", ] [[package]] @@ -8961,7 +9129,7 @@ checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -9045,27 +9213,29 @@ dependencies = [ "windows-version", ] -[[package]] -name = "windows" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" -dependencies = [ - "windows-core 0.58.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -9078,16 +9248,12 @@ dependencies = [ ] [[package]] -name = "windows-core" -version = "0.58.0" +name = "windows-collections" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", - "windows-targets 0.52.6", + "windows-core 0.62.2", ] [[package]] @@ -9096,8 +9262,8 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -9109,8 +9275,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -9124,18 +9290,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", ] [[package]] -name = "windows-implement" -version = "0.58.0" +name = "windows-future" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -9146,18 +9312,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", -] - -[[package]] -name = "windows-interface" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -9168,7 +9323,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -9193,6 +9348,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.5.3" @@ -9204,15 +9369,6 @@ dependencies = [ "windows-strings 0.4.2", ] -[[package]] -name = "windows-result" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -9231,16 +9387,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-strings" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" -dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows-strings" version = "0.4.2" @@ -9385,6 +9531,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -9601,16 +9756,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "winreg" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "winreg" version = "0.55.0" @@ -9654,21 +9799,15 @@ checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" - -[[package]] -name = "writeable" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wry" -version = "0.53.4" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d78ec082b80fa088569a970d043bb3050abaabf4454101d44514ee8d9a8c9f6" +checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" dependencies = [ "base64 0.22.1", "block2 0.6.2", @@ -9760,9 +9899,9 @@ dependencies = [ [[package]] name = "xdg" -version = "2.5.2" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" +checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" [[package]] name = "xkeysym" @@ -9772,63 +9911,44 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xml-rs" -version = "0.8.27" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xsum" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0637d3a5566a82fa5214bae89087bc8c9fb94cd8e8a3c07feb691bb8d9c632db" [[package]] name = "yoke" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", - "yoke-derive 0.7.5", - "zerofrom", -] - -[[package]] -name = "yoke" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" -dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive 0.8.0", + "yoke-derive", "zerofrom", ] [[package]] name = "yoke-derive" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", - "synstructure", -] - -[[package]] -name = "yoke-derive" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", + "syn 2.0.108", "synstructure", ] [[package]] name = "zbus" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" dependencies = [ "async-broadcast", "async-executor", @@ -9851,7 +9971,8 @@ dependencies = [ "tokio", "tracing", "uds_windows", - "windows-sys 0.60.2", + "uuid", + "windows-sys 0.61.2", "winnow 0.7.13", "zbus_macros", "zbus_names", @@ -9860,14 +9981,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", "zbus_names", "zvariant", "zvariant_utils", @@ -9902,7 +10023,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -9922,7 +10043,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", "synstructure", ] @@ -9943,62 +10064,41 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", - "yoke 0.8.0", + "yoke", "zerofrom", ] [[package]] name = "zerovec" -version = "0.10.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ - "yoke 0.7.5", + "serde", + "yoke", "zerofrom", - "zerovec-derive 0.10.3", -] - -[[package]] -name = "zerovec" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" -dependencies = [ - "yoke 0.8.0", - "zerofrom", - "zerovec-derive 0.11.1", + "zerovec-derive", ] [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", + "syn 2.0.108", ] [[package]] @@ -10009,7 +10109,7 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", - "indexmap 2.11.4", + "indexmap 2.12.0", "memchr", ] @@ -10026,9 +10126,9 @@ dependencies = [ "crc32fast", "deflate64", "flate2", - "getrandom 0.3.3", + "getrandom 0.3.4", "hmac", - "indexmap 2.11.4", + "indexmap 2.12.0", "lzma-rust2", "memchr", "pbkdf2", @@ -10048,9 +10148,9 @@ checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" [[package]] name = "zopfli" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" dependencies = [ "bumpalo", "crc32fast", @@ -10103,9 +10203,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.7.0" +version = "5.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "999dd3be73c52b1fccd109a4a81e4fcd20fab1d3599c8121b38d04e1419498db" +checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" dependencies = [ "endi", "enumflags2", @@ -10118,14 +10218,14 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.7.0" +version = "5.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6643fd0b26a46d226bd90d3f07c1b5321fe9bb7f04673cb37ac6d6883885b68e" +checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.108", "zvariant_utils", ] @@ -10138,6 +10238,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.106", + "syn 2.0.108", "winnow 0.7.13", ] diff --git a/clash-verge-rev/src-tauri/Cargo.toml b/clash-verge-rev/src-tauri/Cargo.toml index 7f0e7936a0..158efb45ae 100755 --- a/clash-verge-rev/src-tauri/Cargo.toml +++ b/clash-verge-rev/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clash-verge" -version = "2.4.3" +version = "2.4.4" description = "clash verge" authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"] license = "GPL-3.0-only" @@ -8,42 +8,42 @@ repository = "https://github.com/clash-verge-rev/clash-verge-rev.git" default-run = "clash-verge" edition = "2024" build = "build.rs" +rust-version = "1.91" [package.metadata.bundle] identifier = "io.github.clash-verge-rev.clash-verge-rev" [build-dependencies] -tauri-build = { version = "2.4.1", features = [] } +tauri-build = { version = "2.5.2", features = [] } [dependencies] warp = { version = "0.4.2", features = ["server"] } anyhow = "1.0.100" -dirs = "6.0" open = "5.3.2" log = "0.4.28" dunce = "1.0.5" nanoid = "0.4" chrono = "0.4.42" sysinfo = { version = "0.37.2", features = ["network", "system"] } -boa_engine = "0.20.0" +boa_engine = "0.21.0" serde_json = "1.0.145" serde_yaml_ng = "0.10.0" -once_cell = "1.21.3" +once_cell = { version = "1.21.3", features = ["parking_lot"] } port_scanner = "0.1.5" delay_timer = "0.11.6" -parking_lot = "0.12.5" +parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] } percent-encoding = "2.3.2" -tokio = { version = "1.47.1", features = [ +tokio = { version = "1.48.0", features = [ "rt-multi-thread", "macros", "time", "sync", ] } serde = { version = "1.0.228", features = ["derive"] } -reqwest = { version = "0.12.23", features = ["json", "cookies"] } -regex = "1.12.1" +reqwest = { version = "0.12.24", features = ["json", "cookies"] } +regex = "1.12.2" sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs" } -tauri = { version = "2.8.5", features = [ +tauri = { version = "2.9.3", features = [ "protocol-asset", "devtools", "tray-icon", @@ -51,46 +51,40 @@ tauri = { version = "2.8.5", features = [ "image-png", ] } network-interface = { version = "2.0.3", features = ["serde"] } -tauri-plugin-shell = "2.3.1" -tauri-plugin-dialog = "2.4.0" -tauri-plugin-fs = "2.4.2" -tauri-plugin-process = "2.3.0" -tauri-plugin-clipboard-manager = "2.3.0" -tauri-plugin-deep-link = "2.4.3" -tauri-plugin-window-state = "2.4.0" +tauri-plugin-shell = "2.3.3" +tauri-plugin-dialog = "2.4.2" +tauri-plugin-fs = "2.4.4" +tauri-plugin-process = "2.3.1" +tauri-plugin-clipboard-manager = "2.3.2" +tauri-plugin-deep-link = "2.4.5" +tauri-plugin-window-state = "2.4.1" zip = "6.0.0" reqwest_dav = "0.2.2" aes-gcm = { version = "0.10.3", features = ["std"] } base64 = "0.22.1" -getrandom = "0.3.3" +getrandom = "0.3.4" futures = "0.3.31" sys-locale = "0.3.2" libc = "0.2.177" -gethostname = "1.0.2" -hmac = "0.12.1" -sha2 = "0.10.9" -hex = "0.4.3" +gethostname = "1.1.0" scopeguard = "1.2.0" -dashmap = "6.1.0" -tauri-plugin-notification = "2.3.1" +tauri-plugin-notification = "2.3.3" tokio-stream = "0.1.17" -isahc = { version = "1.7.2", default-features = false, features = [ - "text-decoding", - "parking_lot", -] } backoff = { version = "0.4.0", features = ["tokio"] } -tauri-plugin-http = "2.5.2" +compact_str = { version = "0.9.0", features = ["serde"] } +tauri-plugin-http = "2.5.4" flexi_logger = "0.31.7" -console-subscriber = { version = "0.4.1", optional = true } +console-subscriber = { version = "0.5.0", optional = true } tauri-plugin-devtools = { version = "2.0.1" } tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo" } -clash_verge_logger = { version = "0.1.0", git = "https://github.com/clash-verge-rev/clash-verge-logger" } -clash_verge_service_ipc = { version = "2.0.14", features = [ +clash_verge_logger = { git = "https://github.com/clash-verge-rev/clash-verge-logger" } +async-trait = "0.1.89" +smartstring = { version = "1.0.1", features = ["serde"] } +clash_verge_service_ipc = { version = "2.0.21", features = [ "client", ], git = "https://github.com/clash-verge-rev/clash-verge-service-ipc" } -# clash_verge_service_ipc = { version = "2.0.14", features = [ -# "client", -# ], path = "../../clash-verge-service-ipc" } +arc-swap = "1.7.1" +rust-i18n = "3.1.5" [target.'cfg(windows)'.dependencies] runas = "=1.2.0" @@ -109,13 +103,19 @@ winapi = { version = "0.3.9", features = [ "winhttp", "winreg", ] } +windows-sys = { version = "0.61.2", features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_System_SystemServices", + "Win32_UI_WindowsAndMessaging", +] } -[target.'cfg(target_os = "linux")'.dependencies] -users = "0.11.0" +[target.'cfg(unix)'.dependencies] +signal-hook = "0.3.18" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] -tauri-plugin-autostart = "2.5.0" -tauri-plugin-global-shortcut = "2.3.0" +tauri-plugin-autostart = "2.5.1" +tauri-plugin-global-shortcut = "2.3.1" tauri-plugin-updater = "2.9.0" [features] @@ -124,12 +124,19 @@ custom-protocol = ["tauri/custom-protocol"] verge-dev = ["clash_verge_logger/color"] tauri-dev = [] tokio-trace = ["console-subscriber"] +clippy = ["tauri/test"] +tracing = [] + +[[bench]] +name = "draft_benchmark" +path = "benches/draft_benchmark.rs" +harness = false [profile.release] panic = "abort" -codegen-units = 16 +codegen-units = 1 lto = "thin" -opt-level = 2 +opt-level = 3 debug = false strip = true overflow-checks = false @@ -147,8 +154,8 @@ rpath = false [profile.fast-release] inherits = "release" -incremental = true codegen-units = 64 +incremental = true lto = false opt-level = 0 debug = true @@ -159,7 +166,7 @@ name = "app_lib" crate-type = ["staticlib", "cdylib", "rlib"] [dev-dependencies] -criterion = "0.7.0" +criterion = { version = "0.7.0", features = ["async_tokio"] } [lints.clippy] # Core categories - most important for code safety and correctness @@ -175,8 +182,10 @@ unimplemented = "deny" # Development quality lints todo = "warn" dbg_macro = "warn" -#print_stdout = "warn" -#print_stderr = "warn" + +# 我们期望所有输出方式通过 logging 模块进行统一管理 +# print_stdout = "deny" +# print_stderr = "deny" # Performance lints for proxy application clone_on_ref_ptr = "warn" @@ -211,3 +220,37 @@ needless_raw_string_hashes = "deny" # Too many in existing code #pedantic = { level = "allow", priority = -1 } #nursery = { level = "allow", priority = -1 } #restriction = { level = "allow", priority = -1 } + +or_fun_call = "deny" +cognitive_complexity = "deny" +useless_let_if_seq = "deny" +use_self = "deny" +tuple_array_conversions = "deny" +trait_duplication_in_bounds = "deny" +suspicious_operation_groupings = "deny" +string_lit_as_bytes = "deny" +significant_drop_tightening = "deny" +significant_drop_in_scrutinee = "deny" +redundant_clone = "deny" +# option_if_let_else = "deny" // 过于激进,暂时不开启 +needless_pass_by_ref_mut = "deny" +needless_collect = "deny" +missing_const_for_fn = "deny" +iter_with_drain = "deny" +iter_on_single_items = "deny" +iter_on_empty_collections = "deny" +# fallible_impl_from = "deny" // 过于激进,暂时不开启 +equatable_if_let = "deny" +collection_is_never_read = "deny" +branches_sharing_code = "deny" +pathbuf_init_then_push = "deny" +option_as_ref_cloned = "deny" +large_types_passed_by_value = "deny" +# implicit_clone = "deny" // 可能会造成额外开销,暂时不开启 +expl_impl_clone_on_copy = "deny" +copy_iterator = "deny" +cloned_instead_of_copied = "deny" +# self_only_used_in_recursion = "deny" // Since 1.92.0 +unnecessary_self_imports = "deny" +unused_trait_names = "deny" +wildcard_imports = "deny" diff --git a/clash-verge-rev/src-tauri/benches/draft_benchmark.rs b/clash-verge-rev/src-tauri/benches/draft_benchmark.rs index 8f1515ed93..c8a07c536e 100644 --- a/clash-verge-rev/src-tauri/benches/draft_benchmark.rs +++ b/clash-verge-rev/src-tauri/benches/draft_benchmark.rs @@ -1,91 +1,116 @@ use criterion::{Criterion, criterion_group, criterion_main}; use std::hint::black_box; +use std::process; +use tokio::runtime::Runtime; -// 业务模型 & Draft -use app_lib::config::Draft as DraftNew; use app_lib::config::IVerge; +use app_lib::utils::Draft as DraftNew; -// fn bench_apply_old(c: &mut Criterion) { -// c.bench_function("apply_draft_old", |b| { -// b.iter(|| { -// let verge = Box::new(IVerge { -// enable_auto_launch: Some(true), -// enable_tun_mode: Some(false), -// ..Default::default() -// }); +/// 创建测试数据 +fn make_draft() -> DraftNew { + let verge = IVerge { + enable_auto_launch: Some(true), + enable_tun_mode: Some(false), + ..Default::default() + }; + DraftNew::new(verge) +} -// let draft = DraftOld::from(black_box(verge)); +pub fn bench_draft(c: &mut Criterion) { + let rt = Runtime::new().unwrap_or_else(|e| { + eprintln!("Tokio runtime init failed: {e}"); + process::exit(1); + }); -// { -// let mut d = draft.draft_mut(); -// d.enable_auto_launch = Some(false); -// } + let mut group = c.benchmark_group("draft"); + group.sample_size(100); + group.warm_up_time(std::time::Duration::from_millis(300)); + group.measurement_time(std::time::Duration::from_secs(1)); -// let _ = draft.apply(); -// }); -// }); -// } - -// fn bench_discard_old(c: &mut Criterion) { -// c.bench_function("discard_draft_old", |b| { -// b.iter(|| { -// let verge = Box::new(IVerge::default()); -// let draft = DraftOld::from(black_box(verge)); - -// { -// let mut d = draft.draft_mut(); -// d.enable_auto_launch = Some(false); -// } - -// let _ = draft.discard(); -// }); -// }); -// } - -/// 基准:修改草稿并 apply() -fn bench_apply_new(c: &mut Criterion) { - c.bench_function("apply_draft_new", |b| { + group.bench_function("data_mut", |b| { b.iter(|| { - let verge = Box::new(IVerge { - enable_auto_launch: Some(true), - enable_tun_mode: Some(false), - ..Default::default() + let draft = black_box(make_draft()); + draft.edit_draft(|d| d.enable_tun_mode = Some(true)); + black_box(&draft.latest_arc().enable_tun_mode); + }); + }); + + group.bench_function("draft_mut_first", |b| { + b.iter(|| { + let draft = black_box(make_draft()); + draft.edit_draft(|d| d.enable_auto_launch = Some(false)); + let latest = draft.latest_arc(); + black_box(&latest.enable_auto_launch); + }); + }); + + group.bench_function("draft_mut_existing", |b| { + b.iter(|| { + let draft = black_box(make_draft()); + { + draft.edit_draft(|d| { + d.enable_tun_mode = Some(true); + }); + let latest1 = draft.latest_arc(); + black_box(&latest1.enable_tun_mode); + } + draft.edit_draft(|d| { + d.enable_tun_mode = Some(false); }); - - let draft = DraftNew::from(black_box(verge)); - - { - let mut d = draft.draft_mut(); - d.enable_auto_launch = Some(false); - } - - let _ = draft.apply(); + let latest2 = draft.latest_arc(); + black_box(&latest2.enable_tun_mode); }); }); -} -/// 基准:修改草稿并 discard() -fn bench_discard_new(c: &mut Criterion) { - c.bench_function("discard_draft_new", |b| { + group.bench_function("latest_arc", |b| { b.iter(|| { - let verge = Box::new(IVerge::default()); - let draft = DraftNew::from(black_box(verge)); - - { - let mut d = draft.draft_mut(); - d.enable_auto_launch = Some(false); - } - - let _ = draft.discard(); + let draft = black_box(make_draft()); + let latest = draft.latest_arc(); + black_box(&latest.enable_auto_launch); }); }); + + group.bench_function("apply", |b| { + b.iter(|| { + let draft = black_box(make_draft()); + { + draft.edit_draft(|d| { + d.enable_auto_launch = Some(false); + }); + } + draft.apply(); + black_box(&draft); + }); + }); + + group.bench_function("discard", |b| { + b.iter(|| { + let draft = black_box(make_draft()); + { + draft.edit_draft(|d| { + d.enable_auto_launch = Some(false); + }); + } + draft.discard(); + black_box(&draft); + }); + }); + + group.bench_function("with_data_modify_async", |b| { + b.to_async(&rt).iter(|| async { + let draft = black_box(make_draft()); + let _: Result<(), anyhow::Error> = draft + .with_data_modify::<_, _, _>(|mut box_data| async move { + box_data.enable_auto_launch = + Some(!box_data.enable_auto_launch.unwrap_or(false)); + Ok((box_data, ())) + }) + .await; + }); + }); + + group.finish(); } -criterion_group!( - benches, - // bench_apply_old, - // bench_discard_old, - bench_apply_new, - bench_discard_new -); +criterion_group!(benches, bench_draft); criterion_main!(benches); diff --git a/clash-verge-rev/src-tauri/build.rs b/clash-verge-rev/src-tauri/build.rs index d860e1e6a7..b67dd4d05a 100644 --- a/clash-verge-rev/src-tauri/build.rs +++ b/clash-verge-rev/src-tauri/build.rs @@ -1,3 +1,9 @@ fn main() { - tauri_build::build() + #[cfg(feature = "clippy")] + { + println!("cargo:warning=Skipping tauri_build during Clippy"); + } + + #[cfg(not(feature = "clippy"))] + tauri_build::build(); } diff --git a/clash-verge-rev/src-tauri/locales/ar.yml b/clash-verge-rev/src-tauri/locales/ar.yml new file mode 100644 index 0000000000..a054627f71 --- /dev/null +++ b/clash-verge-rev/src-tauri/locales/ar.yml @@ -0,0 +1,56 @@ +_version: 1 +notifications: + dashboardToggled: + title: Dashboard + body: Dashboard visibility has been updated. + clashModeChanged: + title: Mode Switch + body: Switched to {mode}. + systemProxyToggled: + title: System Proxy + body: System proxy status has been updated. + tunModeToggled: + title: TUN Mode + body: TUN mode status has been updated. + lightweightModeEntered: + title: Lightweight Mode + body: Entered lightweight mode. + appQuit: + title: About to Exit + body: Clash Verge is about to exit. + appHidden: + title: Application Hidden + body: Clash Verge is running in the background. +service: + adminPrompt: Installing the service requires administrator privileges. +tray: + dashboard: Dashboard + ruleMode: Rule Mode + globalMode: Global Mode + directMode: Direct Mode + outboundModes: Outbound Modes + rule: Rule + direct: Direct + global: Global + profiles: Profiles + proxies: Proxies + systemProxy: System Proxy + tunMode: TUN Mode + closeAllConnections: Close All Connections + lightweightMode: Lightweight Mode + copyEnv: Copy Environment Variables + confDir: Configuration Directory + coreDir: Core Directory + logsDir: Log Directory + openDir: Open Directory + appLog: Application Log + coreLog: Core Log + restartClash: Restart Clash Core + restartApp: Restart Application + vergeVersion: Verge Version + more: More + exit: Exit + tooltip: + systemProxy: System Proxy + tun: TUN + profile: Profile diff --git a/clash-verge-rev/src-tauri/locales/de.yml b/clash-verge-rev/src-tauri/locales/de.yml new file mode 100644 index 0000000000..2cda8afe80 --- /dev/null +++ b/clash-verge-rev/src-tauri/locales/de.yml @@ -0,0 +1,56 @@ +_version: 1 +notifications: + dashboardToggled: + title: Dashboard + body: Dashboard visibility has been updated. + clashModeChanged: + title: Mode Switch + body: Switched to {mode}. + systemProxyToggled: + title: System Proxy + body: System proxy status has been updated. + tunModeToggled: + title: TUN Mode + body: TUN mode status has been updated. + lightweightModeEntered: + title: Lightweight Mode + body: Entered lightweight mode. + appQuit: + title: About to Exit + body: Clash Verge is about to exit. + appHidden: + title: Application Hidden + body: Clash Verge is running in the background. +service: + adminPrompt: Installing the service requires administrator privileges. +tray: + dashboard: Dashboard + ruleMode: Rule Mode + globalMode: Global Mode + directMode: Direct Mode + outboundModes: Outbound Modes + rule: Regel + direct: Direkt + global: Global + profiles: Profiles + proxies: Proxies + systemProxy: System Proxy + tunMode: TUN Mode + closeAllConnections: Close All Connections + lightweightMode: Lightweight Mode + copyEnv: Copy Environment Variables + confDir: Configuration Directory + coreDir: Core Directory + logsDir: Log Directory + openDir: Open Directory + appLog: Application Log + coreLog: Core Log + restartClash: Restart Clash Core + restartApp: Restart Application + vergeVersion: Verge Version + more: More + exit: Exit + tooltip: + systemProxy: System Proxy + tun: TUN + profile: Profile diff --git a/clash-verge-rev/src-tauri/locales/en.yml b/clash-verge-rev/src-tauri/locales/en.yml new file mode 100644 index 0000000000..a054627f71 --- /dev/null +++ b/clash-verge-rev/src-tauri/locales/en.yml @@ -0,0 +1,56 @@ +_version: 1 +notifications: + dashboardToggled: + title: Dashboard + body: Dashboard visibility has been updated. + clashModeChanged: + title: Mode Switch + body: Switched to {mode}. + systemProxyToggled: + title: System Proxy + body: System proxy status has been updated. + tunModeToggled: + title: TUN Mode + body: TUN mode status has been updated. + lightweightModeEntered: + title: Lightweight Mode + body: Entered lightweight mode. + appQuit: + title: About to Exit + body: Clash Verge is about to exit. + appHidden: + title: Application Hidden + body: Clash Verge is running in the background. +service: + adminPrompt: Installing the service requires administrator privileges. +tray: + dashboard: Dashboard + ruleMode: Rule Mode + globalMode: Global Mode + directMode: Direct Mode + outboundModes: Outbound Modes + rule: Rule + direct: Direct + global: Global + profiles: Profiles + proxies: Proxies + systemProxy: System Proxy + tunMode: TUN Mode + closeAllConnections: Close All Connections + lightweightMode: Lightweight Mode + copyEnv: Copy Environment Variables + confDir: Configuration Directory + coreDir: Core Directory + logsDir: Log Directory + openDir: Open Directory + appLog: Application Log + coreLog: Core Log + restartClash: Restart Clash Core + restartApp: Restart Application + vergeVersion: Verge Version + more: More + exit: Exit + tooltip: + systemProxy: System Proxy + tun: TUN + profile: Profile diff --git a/clash-verge-rev/src-tauri/locales/es.yml b/clash-verge-rev/src-tauri/locales/es.yml new file mode 100644 index 0000000000..974899b15f --- /dev/null +++ b/clash-verge-rev/src-tauri/locales/es.yml @@ -0,0 +1,56 @@ +_version: 1 +notifications: + dashboardToggled: + title: Dashboard + body: Dashboard visibility has been updated. + clashModeChanged: + title: Mode Switch + body: Switched to {mode}. + systemProxyToggled: + title: System Proxy + body: System proxy status has been updated. + tunModeToggled: + title: TUN Mode + body: TUN mode status has been updated. + lightweightModeEntered: + title: Lightweight Mode + body: Entered lightweight mode. + appQuit: + title: About to Exit + body: Clash Verge is about to exit. + appHidden: + title: Application Hidden + body: Clash Verge is running in the background. +service: + adminPrompt: Installing the service requires administrator privileges. +tray: + dashboard: Dashboard + ruleMode: Rule Mode + globalMode: Global Mode + directMode: Direct Mode + outboundModes: Outbound Modes + rule: Regla + direct: Directo + global: Global + profiles: Profiles + proxies: Proxies + systemProxy: System Proxy + tunMode: TUN Mode + closeAllConnections: Close All Connections + lightweightMode: Lightweight Mode + copyEnv: Copy Environment Variables + confDir: Configuration Directory + coreDir: Core Directory + logsDir: Log Directory + openDir: Open Directory + appLog: Application Log + coreLog: Core Log + restartClash: Restart Clash Core + restartApp: Restart Application + vergeVersion: Verge Version + more: More + exit: Exit + tooltip: + systemProxy: System Proxy + tun: TUN + profile: Profile diff --git a/clash-verge-rev/src-tauri/locales/fa.yml b/clash-verge-rev/src-tauri/locales/fa.yml new file mode 100644 index 0000000000..a054627f71 --- /dev/null +++ b/clash-verge-rev/src-tauri/locales/fa.yml @@ -0,0 +1,56 @@ +_version: 1 +notifications: + dashboardToggled: + title: Dashboard + body: Dashboard visibility has been updated. + clashModeChanged: + title: Mode Switch + body: Switched to {mode}. + systemProxyToggled: + title: System Proxy + body: System proxy status has been updated. + tunModeToggled: + title: TUN Mode + body: TUN mode status has been updated. + lightweightModeEntered: + title: Lightweight Mode + body: Entered lightweight mode. + appQuit: + title: About to Exit + body: Clash Verge is about to exit. + appHidden: + title: Application Hidden + body: Clash Verge is running in the background. +service: + adminPrompt: Installing the service requires administrator privileges. +tray: + dashboard: Dashboard + ruleMode: Rule Mode + globalMode: Global Mode + directMode: Direct Mode + outboundModes: Outbound Modes + rule: Rule + direct: Direct + global: Global + profiles: Profiles + proxies: Proxies + systemProxy: System Proxy + tunMode: TUN Mode + closeAllConnections: Close All Connections + lightweightMode: Lightweight Mode + copyEnv: Copy Environment Variables + confDir: Configuration Directory + coreDir: Core Directory + logsDir: Log Directory + openDir: Open Directory + appLog: Application Log + coreLog: Core Log + restartClash: Restart Clash Core + restartApp: Restart Application + vergeVersion: Verge Version + more: More + exit: Exit + tooltip: + systemProxy: System Proxy + tun: TUN + profile: Profile diff --git a/clash-verge-rev/src-tauri/locales/id.yml b/clash-verge-rev/src-tauri/locales/id.yml new file mode 100644 index 0000000000..587534e9c9 --- /dev/null +++ b/clash-verge-rev/src-tauri/locales/id.yml @@ -0,0 +1,56 @@ +_version: 1 +notifications: + dashboardToggled: + title: Dashboard + body: Dashboard visibility has been updated. + clashModeChanged: + title: Mode Switch + body: Switched to {mode}. + systemProxyToggled: + title: System Proxy + body: System proxy status has been updated. + tunModeToggled: + title: TUN Mode + body: TUN mode status has been updated. + lightweightModeEntered: + title: Lightweight Mode + body: Entered lightweight mode. + appQuit: + title: About to Exit + body: Clash Verge is about to exit. + appHidden: + title: Application Hidden + body: Clash Verge is running in the background. +service: + adminPrompt: Installing the service requires administrator privileges. +tray: + dashboard: Dashboard + ruleMode: Rule Mode + globalMode: Global Mode + directMode: Direct Mode + outboundModes: Outbound Modes + rule: Aturan + direct: Langsung + global: Global + profiles: Profiles + proxies: Proxies + systemProxy: System Proxy + tunMode: TUN Mode + closeAllConnections: Close All Connections + lightweightMode: Lightweight Mode + copyEnv: Copy Environment Variables + confDir: Configuration Directory + coreDir: Core Directory + logsDir: Log Directory + openDir: Open Directory + appLog: Application Log + coreLog: Core Log + restartClash: Restart Clash Core + restartApp: Restart Application + vergeVersion: Verge Version + more: More + exit: Exit + tooltip: + systemProxy: System Proxy + tun: TUN + profile: Profile diff --git a/clash-verge-rev/src-tauri/locales/jp.yml b/clash-verge-rev/src-tauri/locales/jp.yml new file mode 100644 index 0000000000..4ed031a4a0 --- /dev/null +++ b/clash-verge-rev/src-tauri/locales/jp.yml @@ -0,0 +1,56 @@ +_version: 1 +notifications: + dashboardToggled: + title: Dashboard + body: Dashboard visibility has been updated. + clashModeChanged: + title: Mode Switch + body: Switched to {mode}. + systemProxyToggled: + title: System Proxy + body: System proxy status has been updated. + tunModeToggled: + title: TUN Mode + body: TUN mode status has been updated. + lightweightModeEntered: + title: Lightweight Mode + body: Entered lightweight mode. + appQuit: + title: About to Exit + body: Clash Verge is about to exit. + appHidden: + title: Application Hidden + body: Clash Verge is running in the background. +service: + adminPrompt: Installing the service requires administrator privileges. +tray: + dashboard: Dashboard + ruleMode: Rule Mode + globalMode: Global Mode + directMode: Direct Mode + outboundModes: Outbound Modes + rule: ルール + direct: ダイレクト + global: グローバル + profiles: Profiles + proxies: Proxies + systemProxy: System Proxy + tunMode: TUN Mode + closeAllConnections: Close All Connections + lightweightMode: Lightweight Mode + copyEnv: Copy Environment Variables + confDir: Configuration Directory + coreDir: Core Directory + logsDir: Log Directory + openDir: Open Directory + appLog: Application Log + coreLog: Core Log + restartClash: Restart Clash Core + restartApp: Restart Application + vergeVersion: Verge Version + more: More + exit: Exit + tooltip: + systemProxy: System Proxy + tun: TUN + profile: Profile diff --git a/clash-verge-rev/src-tauri/locales/ko.yml b/clash-verge-rev/src-tauri/locales/ko.yml new file mode 100644 index 0000000000..51dccf885d --- /dev/null +++ b/clash-verge-rev/src-tauri/locales/ko.yml @@ -0,0 +1,56 @@ +_version: 1 +notifications: + dashboardToggled: + title: 대시보드 + body: 대시보드 표시 상태가 업데이트되었습니다. + clashModeChanged: + title: 모드 전환 + body: "{mode}(으)로 전환되었습니다." + systemProxyToggled: + title: 시스템 프록시 + body: 시스템 프록시 상태가 업데이트되었습니다. + tunModeToggled: + title: TUN 모드 + body: TUN 모드 상태가 업데이트되었습니다. + lightweightModeEntered: + title: 경량 모드 + body: 경량 모드에 진입했습니다. + appQuit: + title: 곧 종료 + body: Clash Verge가 곧 종료됩니다. + appHidden: + title: 앱이 숨겨짐 + body: Clash Verge가 백그라운드에서 실행 중입니다. +service: + adminPrompt: 서비스를 설치하려면 관리자 권한이 필요합니다. +tray: + dashboard: 대시보드 + ruleMode: 규칙 모드 + globalMode: 전역 모드 + directMode: 직접 모드 + outboundModes: Outbound Modes + rule: 규칙 + direct: 직접 + global: 글로벌 + profiles: 프로필 + proxies: 프록시 + systemProxy: 시스템 프록시 + tunMode: TUN 모드 + closeAllConnections: 모든 연결 닫기 + lightweightMode: 경량 모드 + copyEnv: 환경 변수 복사 + confDir: 구성 디렉터리 + coreDir: 코어 디렉터리 + logsDir: 로그 디렉터리 + openDir: 디렉터리 열기 + appLog: 애플리케이션 로그 + coreLog: 코어 로그 + restartClash: Clash 코어 재시작 + restartApp: 애플리케이션 재시작 + vergeVersion: Verge 버전 + more: 더 보기 + exit: 종료 + tooltip: + systemProxy: 시스템 프록시 + tun: TUN + profile: 프로필 diff --git a/clash-verge-rev/src-tauri/locales/ru.yml b/clash-verge-rev/src-tauri/locales/ru.yml new file mode 100644 index 0000000000..cff8fe27c1 --- /dev/null +++ b/clash-verge-rev/src-tauri/locales/ru.yml @@ -0,0 +1,56 @@ +_version: 1 +notifications: + dashboardToggled: + title: Dashboard + body: Dashboard visibility has been updated. + clashModeChanged: + title: Mode Switch + body: Switched to {mode}. + systemProxyToggled: + title: System Proxy + body: System proxy status has been updated. + tunModeToggled: + title: TUN Mode + body: TUN mode status has been updated. + lightweightModeEntered: + title: Lightweight Mode + body: Entered lightweight mode. + appQuit: + title: About to Exit + body: Clash Verge is about to exit. + appHidden: + title: Application Hidden + body: Clash Verge is running in the background. +service: + adminPrompt: Installing the service requires administrator privileges. +tray: + dashboard: Dashboard + ruleMode: Rule Mode + globalMode: Global Mode + directMode: Direct Mode + outboundModes: Outbound Modes + rule: Правило + direct: Прямой + global: Глобальный + profiles: Profiles + proxies: Proxies + systemProxy: System Proxy + tunMode: TUN Mode + closeAllConnections: Close All Connections + lightweightMode: Lightweight Mode + copyEnv: Copy Environment Variables + confDir: Configuration Directory + coreDir: Core Directory + logsDir: Log Directory + openDir: Open Directory + appLog: Application Log + coreLog: Core Log + restartClash: Restart Clash Core + restartApp: Restart Application + vergeVersion: Verge Version + more: More + exit: Exit + tooltip: + systemProxy: System Proxy + tun: TUN + profile: Profile diff --git a/clash-verge-rev/src-tauri/locales/tr.yml b/clash-verge-rev/src-tauri/locales/tr.yml new file mode 100644 index 0000000000..f6ffb6895a --- /dev/null +++ b/clash-verge-rev/src-tauri/locales/tr.yml @@ -0,0 +1,56 @@ +_version: 1 +notifications: + dashboardToggled: + title: Dashboard + body: Dashboard visibility has been updated. + clashModeChanged: + title: Mode Switch + body: Switched to {mode}. + systemProxyToggled: + title: System Proxy + body: System proxy status has been updated. + tunModeToggled: + title: TUN Mode + body: TUN mode status has been updated. + lightweightModeEntered: + title: Lightweight Mode + body: Entered lightweight mode. + appQuit: + title: About to Exit + body: Clash Verge is about to exit. + appHidden: + title: Application Hidden + body: Clash Verge is running in the background. +service: + adminPrompt: Installing the service requires administrator privileges. +tray: + dashboard: Dashboard + ruleMode: Rule Mode + globalMode: Global Mode + directMode: Direct Mode + outboundModes: Outbound Modes + rule: Kural + direct: Doğrudan + global: Küresel + profiles: Profiles + proxies: Proxies + systemProxy: System Proxy + tunMode: TUN Mode + closeAllConnections: Close All Connections + lightweightMode: Lightweight Mode + copyEnv: Copy Environment Variables + confDir: Configuration Directory + coreDir: Core Directory + logsDir: Log Directory + openDir: Open Directory + appLog: Application Log + coreLog: Core Log + restartClash: Restart Clash Core + restartApp: Restart Application + vergeVersion: Verge Version + more: More + exit: Exit + tooltip: + systemProxy: System Proxy + tun: TUN + profile: Profile diff --git a/clash-verge-rev/src-tauri/locales/tt.yml b/clash-verge-rev/src-tauri/locales/tt.yml new file mode 100644 index 0000000000..a054627f71 --- /dev/null +++ b/clash-verge-rev/src-tauri/locales/tt.yml @@ -0,0 +1,56 @@ +_version: 1 +notifications: + dashboardToggled: + title: Dashboard + body: Dashboard visibility has been updated. + clashModeChanged: + title: Mode Switch + body: Switched to {mode}. + systemProxyToggled: + title: System Proxy + body: System proxy status has been updated. + tunModeToggled: + title: TUN Mode + body: TUN mode status has been updated. + lightweightModeEntered: + title: Lightweight Mode + body: Entered lightweight mode. + appQuit: + title: About to Exit + body: Clash Verge is about to exit. + appHidden: + title: Application Hidden + body: Clash Verge is running in the background. +service: + adminPrompt: Installing the service requires administrator privileges. +tray: + dashboard: Dashboard + ruleMode: Rule Mode + globalMode: Global Mode + directMode: Direct Mode + outboundModes: Outbound Modes + rule: Rule + direct: Direct + global: Global + profiles: Profiles + proxies: Proxies + systemProxy: System Proxy + tunMode: TUN Mode + closeAllConnections: Close All Connections + lightweightMode: Lightweight Mode + copyEnv: Copy Environment Variables + confDir: Configuration Directory + coreDir: Core Directory + logsDir: Log Directory + openDir: Open Directory + appLog: Application Log + coreLog: Core Log + restartClash: Restart Clash Core + restartApp: Restart Application + vergeVersion: Verge Version + more: More + exit: Exit + tooltip: + systemProxy: System Proxy + tun: TUN + profile: Profile diff --git a/clash-verge-rev/src-tauri/locales/zh.yml b/clash-verge-rev/src-tauri/locales/zh.yml new file mode 100644 index 0000000000..6b137faeb3 --- /dev/null +++ b/clash-verge-rev/src-tauri/locales/zh.yml @@ -0,0 +1,56 @@ +_version: 1 +notifications: + dashboardToggled: + title: 仪表板 + body: 仪表板显示状态已更新。 + clashModeChanged: + title: 模式切换 + body: 已切换至 {mode}。 + systemProxyToggled: + title: 系统代理 + body: 系统代理状态已更新。 + tunModeToggled: + title: TUN 模式 + body: TUN 模式状态已更新。 + lightweightModeEntered: + title: 轻量模式 + body: 已进入轻量模式。 + appQuit: + title: 即将退出 + body: Clash Verge 即将退出。 + appHidden: + title: 应用已隐藏 + body: Clash Verge 正在后台运行。 +service: + adminPrompt: 安装服务需要管理员权限 +tray: + dashboard: 仪表板 + ruleMode: 规则模式 + globalMode: 全局模式 + directMode: 直连模式 + outboundModes: 出站模式 + rule: 规则 + direct: 直连 + global: 全局 + profiles: 订阅 + proxies: 代理 + systemProxy: 系统代理 + tunMode: TUN 模式 + closeAllConnections: 关闭所有连接 + lightweightMode: 轻量模式 + copyEnv: 复制环境变量 + confDir: 配置目录 + coreDir: 内核目录 + logsDir: 日志目录 + openDir: 打开目录 + appLog: 应用日志 + coreLog: 内核日志 + restartClash: 重启 Clash 内核 + restartApp: 重启应用 + vergeVersion: Verge 版本 + more: 更多 + exit: 退出 + tooltip: + systemProxy: 系统代理 + tun: TUN + profile: 订阅 diff --git a/clash-verge-rev/src-tauri/locales/zhtw.yml b/clash-verge-rev/src-tauri/locales/zhtw.yml new file mode 100644 index 0000000000..9e07ca7143 --- /dev/null +++ b/clash-verge-rev/src-tauri/locales/zhtw.yml @@ -0,0 +1,56 @@ +_version: 1 +notifications: + dashboardToggled: + title: 儀表板 + body: 儀表板顯示狀態已更新。 + clashModeChanged: + title: 模式切換 + body: 已切換至 {mode}。 + systemProxyToggled: + title: 系統代理 + body: 系統代理狀態已更新。 + tunModeToggled: + title: 虛擬網路介面卡模式 + body: 已更新虛擬網路介面卡模式狀態。 + lightweightModeEntered: + title: 輕量模式 + body: 已進入輕量模式。 + appQuit: + title: 即將退出 + body: Clash Verge 即將退出。 + appHidden: + title: 應用已隱藏 + body: Clash Verge 正在背景執行。 +service: + adminPrompt: 安裝服務需要管理員權限 +tray: + dashboard: 儀表板 + ruleMode: 規則模式 + globalMode: 全域模式 + directMode: 直連模式 + outboundModes: 出站模式 + rule: 規則 + direct: 直連 + global: 全域 + profiles: 訂閱 + proxies: 代理 + systemProxy: 系統代理 + tunMode: 虛擬網路介面卡模式 + closeAllConnections: 關閉所有連線 + lightweightMode: 輕量模式 + copyEnv: 複製環境變數 + confDir: 設定目錄 + coreDir: 核心目錄 + logsDir: 日誌目錄 + openDir: 開啟目錄 + appLog: 應用程式日誌 + coreLog: 核心日誌 + restartClash: 重新啟動 Clash 核心 + restartApp: 重新啟動應用程式 + vergeVersion: Verge 版本 + more: 更多 + exit: 離開 + tooltip: + systemProxy: 系統代理 + tun: 虛擬網路介面卡 + profile: 訂閱 diff --git a/clash-verge-rev/src-tauri/packages/macos/info_merge.plist b/clash-verge-rev/src-tauri/packages/macos/info_merge.plist new file mode 100644 index 0000000000..75bd5abd5f --- /dev/null +++ b/clash-verge-rev/src-tauri/packages/macos/info_merge.plist @@ -0,0 +1,10 @@ + + + + + AssociatedBundleIdentifiers + + io.github.clash-verge-rev.clash-verge-rev.service + + + \ No newline at end of file diff --git a/clash-verge-rev/src-tauri/packages/windows/installer.nsi b/clash-verge-rev/src-tauri/packages/windows/installer.nsi index a34cb3ca1b..e6fc494070 100644 --- a/clash-verge-rev/src-tauri/packages/windows/installer.nsi +++ b/clash-verge-rev/src-tauri/packages/windows/installer.nsi @@ -1,12 +1,15 @@ -; This file is copied from https://github.com/tauri-apps/tauri/blob/tauri-v1.5/tooling/bundler/src/bundle/windows/templates/installer.nsi -; and edit to fit the needs of the project. the latest tauri 2.x has a different base nsi script. -RequestExecutionLevel admin - Unicode true -; Set the compression algorithm. Default is LZMA. -!if "{{compression}}" == "" - SetCompressor /SOLID lzma +ManifestDPIAware true +; Add in `dpiAwareness` `PerMonitorV2` to manifest for Windows 10 1607+ (note this should not affect lower versions since they should be able to ignore this and pick up `dpiAware` `true` set by `ManifestDPIAware true`) +; Currently undocumented on NSIS's website but is in the Docs folder of source tree, see +; https://github.com/kichik/nsis/blob/5fc0b87b819a9eec006df4967d08e522ddd651c9/Docs/src/attributes.but#L286-L300 +; https://github.com/tauri-apps/tauri/pull/10106 +ManifestDPIAwareness PerMonitorV2 + +!if "{{compression}}" == "none" + SetCompress off !else + ; Set the compression algorithm. We default to LZMA. SetCompressor /SOLID "{{compression}}" !endif @@ -14,20 +17,30 @@ Unicode true !include FileFunc.nsh !include x64.nsh !include WordFunc.nsh -!include "StrFunc.nsh" +!include "utils.nsh" +!include "FileAssociation.nsh" !include "Win\COM.nsh" !include "Win\Propkey.nsh" !include "WinVer.nsh" !include "LogicLib.nsh" -!addplugindir "$%AppData%\Local\NSIS\" +!include "StrFunc.nsh" ${StrCase} ${StrLoc} +!addplugindir "$%AppData%\Local\NSIS\" + +{{#if installer_hooks}} +!include "{{installer_hooks}}" +{{/if}} + +!define WEBVIEW2APPGUID "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" + !define MANUFACTURER "{{manufacturer}}" !define PRODUCTNAME "{{product_name}}" !define VERSION "{{version}}" !define VERSIONWITHBUILD "{{version_with_build}}" !define SHORTDESCRIPTION "{{short_description}}" +!define HOMEPAGE "{{homepage}}" !define INSTALLMODE "{{install_mode}}" !define LICENSE "{{license}}" !define INSTALLERICON "{{installer_icon}}" @@ -39,22 +52,41 @@ ${StrLoc} !define COPYRIGHT "{{copyright}}" !define OUTFILE "{{out_file}}" !define ARCH "{{arch}}" -!define PLUGINSPATH "{{additional_plugins_path}}" +!define ADDITIONALPLUGINSPATH "{{additional_plugins_path}}" !define ALLOWDOWNGRADES "{{allow_downgrades}}" !define DISPLAYLANGUAGESELECTOR "{{display_language_selector}}" !define INSTALLWEBVIEW2MODE "{{install_webview2_mode}}" !define WEBVIEW2INSTALLERARGS "{{webview2_installer_args}}" !define WEBVIEW2BOOTSTRAPPERPATH "{{webview2_bootstrapper_path}}" !define WEBVIEW2INSTALLERPATH "{{webview2_installer_path}}" +!define MINIMUMWEBVIEW2VERSION "{{minimum_webview2_version}}" !define UNINSTKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCTNAME}" -!define MANUPRODUCTKEY "Software\${MANUFACTURER}\${PRODUCTNAME}" +!define MANUKEY "Software\${MANUFACTURER}" +!define MANUPRODUCTKEY "${MANUKEY}\${PRODUCTNAME}" !define UNINSTALLERSIGNCOMMAND "{{uninstaller_sign_cmd}}" !define ESTIMATEDSIZE "{{estimated_size}}" +!define STARTMENUFOLDER "{{start_menu_folder}}" + +Var PassiveMode +Var UpdateMode +Var NoShortcutMode +Var WixMode +Var OldMainBinaryName +Var VC_REDIST_URL +Var VC_REDIST_EXE +Var VC_RUNTIME_READY +Var VC_RUNTIME_NEEDED Name "${PRODUCTNAME}" BrandingText "${COPYRIGHT}" OutFile "${OUTFILE}" +; We don't actually use this value as default install path, +; it's just for nsis to append the product name folder in the directory selector +; https://nsis.sourceforge.io/Reference/InstallDir +!define PLACEHOLDER_INSTALL_DIR "placeholder\${PRODUCTNAME}" +InstallDir "${PLACEHOLDER_INSTALL_DIR}" + VIProductVersion "${VERSIONWITHBUILD}" VIAddVersionKey "ProductName" "${PRODUCTNAME}" VIAddVersionKey "FileDescription" "${SHORTDESCRIPTION}" @@ -62,18 +94,19 @@ VIAddVersionKey "LegalCopyright" "${COPYRIGHT}" VIAddVersionKey "FileVersion" "${VERSION}" VIAddVersionKey "ProductVersion" "${VERSION}" -; Plugins path, currently exists for linux only -!if "${PLUGINSPATH}" != "" - !addplugindir "${PLUGINSPATH}" +# additional plugins +!if "${ADDITIONALPLUGINSPATH}" != "" + !addplugindir "${ADDITIONALPLUGINSPATH}" !endif +; Uninstaller signing command !if "${UNINSTALLERSIGNCOMMAND}" != "" !uninstfinalize '${UNINSTALLERSIGNCOMMAND}' !endif ; Handle install mode, `perUser`, `perMachine` or `both` !if "${INSTALLMODE}" == "perMachine" - RequestExecutionLevel highest + RequestExecutionLevel admin !endif !if "${INSTALLMODE}" == "currentUser" @@ -97,17 +130,17 @@ VIAddVersionKey "ProductVersion" "${VERSION}" !include MultiUser.nsh !endif -; installer icon +; Installer icon !if "${INSTALLERICON}" != "" !define MUI_ICON "${INSTALLERICON}" !endif -; installer sidebar image +; Installer sidebar image !if "${SIDEBARIMAGE}" != "" !define MUI_WELCOMEFINISHPAGE_BITMAP "${SIDEBARIMAGE}" !endif -; installer header image +; Installer header image !if "${HEADERIMAGE}" != "" !define MUI_HEADERIMAGE !define MUI_HEADERIMAGE_BITMAP "${HEADERIMAGE}" @@ -135,27 +168,26 @@ VIAddVersionKey "ProductVersion" "${VERSION}" !insertmacro MULTIUSER_PAGE_INSTALLMODE !endif - ; 4. Custom page to ask user if he wants to reinstall/uninstall -; only if a previous installtion was detected +; only if a previous installation was detected Var ReinstallPageCheck Page custom PageReinstall PageLeaveReinstall Function PageReinstall ; Uninstall previous WiX installation if exists. ; - ; A WiX installer stores the isntallation info in registry + ; A WiX installer stores the installation info in registry ; using a UUID and so we have to loop through all keys under ; `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall` ; and check if `DisplayName` and `Publisher` keys match ${PRODUCTNAME} and ${MANUFACTURER} ; - ; This has a potentional issue that there maybe another installation that matches + ; This has a potential issue that there maybe another installation that matches ; our ${PRODUCTNAME} and ${MANUFACTURER} but wasn't installed by our WiX installer, ; however, this should be fine since the user will have to confirm the uninstallation ; and they can chose to abort it if doesn't make sense. StrCpy $0 0 wix_loop: EnumRegKey $1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" $0 - StrCmp $1 "" wix_done ; Exit loop if there is no more keys to loop on + StrCmp $1 "" wix_loop_done ; Exit loop if there is no more keys to loop on IntOp $0 $0 + 1 ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "DisplayName" ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "Publisher" @@ -163,11 +195,11 @@ Function PageReinstall ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "UninstallString" ${StrCase} $R1 $R0 "L" ${StrLoc} $R0 $R1 "msiexec" ">" - StrCmp $R0 0 0 wix_done - StrCpy $R7 "wix" + StrCmp $R0 0 0 wix_loop_done + StrCpy $WixMode 1 StrCpy $R6 "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" Goto compare_version - wix_done: + wix_loop_done: ; Check if there is an existing installation, if not, abort the reinstall page ReadRegStr $R0 SHCTX "${UNINSTKEY}" "" @@ -178,7 +210,7 @@ Function PageReinstall ; and modify the messages presented to the user accordingly compare_version: StrCpy $R4 "$(older)" - ${If} $R7 == "wix" + ${If} $WixMode = 1 ReadRegStr $R0 HKLM "$R6" "DisplayVersion" ${Else} ReadRegStr $R0 SHCTX "${UNINSTKEY}" "DisplayVersion" @@ -188,21 +220,19 @@ Function PageReinstall nsis_tauri_utils::SemverCompare "${VERSION}" $R0 Pop $R0 ; Reinstalling the same version - ${If} $R0 == 0 + ${If} $R0 = 0 StrCpy $R1 "$(alreadyInstalledLong)" StrCpy $R2 "$(addOrReinstall)" StrCpy $R3 "$(uninstallApp)" !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(chooseMaintenanceOption)" - StrCpy $R5 "2" ; Upgrading - ${ElseIf} $R0 == 1 + ${ElseIf} $R0 = 1 StrCpy $R1 "$(olderOrUnknownVersionInstalled)" StrCpy $R2 "$(uninstallBeforeInstalling)" StrCpy $R3 "$(dontUninstall)" !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(choowHowToInstall)" - StrCpy $R5 "1" ; Downgrading - ${ElseIf} $R0 == -1 + ${ElseIf} $R0 = -1 StrCpy $R1 "$(newerVersionInstalled)" StrCpy $R2 "$(uninstallBeforeInstalling)" !if "${ALLOWDOWNGRADES}" == "true" @@ -211,43 +241,50 @@ Function PageReinstall StrCpy $R3 "$(dontUninstallDowngrade)" !endif !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(choowHowToInstall)" - StrCpy $R5 "1" ${Else} Abort ${EndIf} - Call SkipIfPassive - - nsDialogs::Create 1018 - Pop $R4 - ${IfThen} $(^RTL) == 1 ${|} nsDialogs::SetRTL $(^RTL) ${|} - - ${NSD_CreateLabel} 0 0 100% 24u $R1 - Pop $R1 - - ${NSD_CreateRadioButton} 30u 50u -30u 8u $R2 - Pop $R2 - ${NSD_OnClick} $R2 PageReinstallUpdateSelection - - ${NSD_CreateRadioButton} 30u 70u -30u 8u $R3 - Pop $R3 - ; disable this radio button if downgrading and downgrades are disabled - !if "${ALLOWDOWNGRADES}" == "false" - ${IfThen} $R0 == -1 ${|} EnableWindow $R3 0 ${|} - !endif - ${NSD_OnClick} $R3 PageReinstallUpdateSelection - - ; Check the first radio button if this the first time - ; we enter this page or if the second button wasn't - ; selected the last time we were on this page - ${If} $ReinstallPageCheck != 2 - SendMessage $R2 ${BM_SETCHECK} ${BST_CHECKED} 0 + ; Skip showing the page if passive + ; + ; Note that we don't call this earlier at the begining + ; of this function because we need to populate some variables + ; related to current installed version if detected and whether + ; we are downgrading or not. + ${If} $PassiveMode = 1 + Call PageLeaveReinstall ${Else} - SendMessage $R3 ${BM_SETCHECK} ${BST_CHECKED} 0 - ${EndIf} + nsDialogs::Create 1018 + Pop $R4 + ${IfThen} $(^RTL) = 1 ${|} nsDialogs::SetRTL $(^RTL) ${|} - ${NSD_SetFocus} $R2 - nsDialogs::Show + ${NSD_CreateLabel} 0 0 100% 24u $R1 + Pop $R1 + + ${NSD_CreateRadioButton} 30u 50u -30u 8u $R2 + Pop $R2 + ${NSD_OnClick} $R2 PageReinstallUpdateSelection + + ${NSD_CreateRadioButton} 30u 70u -30u 8u $R3 + Pop $R3 + ; Disable this radio button if downgrading and downgrades are disabled + !if "${ALLOWDOWNGRADES}" == "false" + ${IfThen} $R0 = -1 ${|} EnableWindow $R3 0 ${|} + !endif + ${NSD_OnClick} $R3 PageReinstallUpdateSelection + + ; Check the first radio button if this the first time + ; we enter this page or if the second button wasn't + ; selected the last time we were on this page + ${If} $ReinstallPageCheck <> 2 + SendMessage $R2 ${BM_SETCHECK} ${BST_CHECKED} 0 + ${Else} + SendMessage $R3 ${BM_SETCHECK} ${BST_CHECKED} 0 + ${EndIf} + + ${NSD_SetFocus} $R2 + nsDialogs::Show + ${EndIf} FunctionEnd Function PageReinstallUpdateSelection ${NSD_GetState} $R2 $R1 @@ -260,26 +297,54 @@ FunctionEnd Function PageLeaveReinstall ${NSD_GetState} $R2 $R1 - ; $R5 holds whether we are reinstalling the same version or not - ; $R5 == "1" -> different versions - ; $R5 == "2" -> same version - ; - ; $R1 holds the radio buttons state. its meaning is dependant on the context - StrCmp $R5 "1" 0 +2 ; Existing install is not the same version? - StrCmp $R1 "1" reinst_uninstall reinst_done ; $R1 == "1", then user chose to uninstall existing version, otherwise skip uninstalling - StrCmp $R1 "1" reinst_done ; Same version? skip uninstalling + ; If migrating from Wix, always uninstall + ${If} $WixMode = 1 + Goto reinst_uninstall + ${EndIf} + + ; In update mode, always proceeds without uninstalling + ${If} $UpdateMode = 1 + Goto reinst_done + ${EndIf} + + ; $R0 holds whether same(0)/upgrading(1)/downgrading(-1) version + ; $R1 holds the radio buttons state: + ; 1 => first choice was selected + ; 0 => second choice was selected + ${If} $R0 = 0 ; Same version, proceed + ${If} $R1 = 1 ; User chose to add/reinstall + Goto reinst_done + ${Else} ; User chose to uninstall + Goto reinst_uninstall + ${EndIf} + ${ElseIf} $R0 = 1 ; Upgrading + ${If} $R1 = 1 ; User chose to uninstall + Goto reinst_uninstall + ${Else} + Goto reinst_done ; User chose NOT to uninstall + ${EndIf} + ${ElseIf} $R0 = -1 ; Downgrading + ${If} $R1 = 1 ; User chose to uninstall + Goto reinst_uninstall + ${Else} + Goto reinst_done ; User chose NOT to uninstall + ${EndIf} + ${EndIf} reinst_uninstall: HideWindow ClearErrors - ${If} $R7 == "wix" + ${If} $WixMode = 1 ReadRegStr $R1 HKLM "$R6" "UninstallString" ExecWait '$R1' $0 ${Else} ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" ReadRegStr $R1 SHCTX "${UNINSTKEY}" "UninstallString" - ExecWait '$R1 /P _?=$4' $0 + ${IfThen} $UpdateMode = 1 ${|} StrCpy $R1 "$R1 /UPDATE" ${|} ; append /UPDATE + ${IfThen} $PassiveMode = 1 ${|} StrCpy $R1 "$R1 /P" ${|} ; append /P + StrCpy $R1 "$R1 _?=$4" ; append uninstall directory + ExecWait '$R1' $0 ${EndIf} BringToFront @@ -288,29 +353,36 @@ Function PageLeaveReinstall ${If} $0 <> 0 ${OrIf} ${FileExists} "$INSTDIR\${MAINBINARYNAME}.exe" - ${If} $0 = 1 ; User aborted uninstaller? - StrCmp $R5 "2" 0 +2 ; Is the existing install the same version? - Quit ; ...yes, already installed, we are done + ; User cancelled wix uninstaller? return to select un/reinstall page + ${If} $WixMode = 1 + ${AndIf} $0 = 1602 Abort ${EndIf} + + ; User cancelled NSIS uninstaller? return to select un/reinstall page + ${If} $0 = 1 + Abort + ${EndIf} + + ; Other erros? show generic error message and return to select un/reinstall page MessageBox MB_ICONEXCLAMATION "$(unableToUninstall)" Abort - ${Else} - StrCpy $0 $R1 1 - ${IfThen} $0 == '"' ${|} StrCpy $R1 $R1 -1 1 ${|} ; Strip quotes from UninstallString - Delete $R1 - RMDir $INSTDIR ${EndIf} reinst_done: FunctionEnd -; 5. Choose install directoy page +; 5. Choose install directory page !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive !insertmacro MUI_PAGE_DIRECTORY ; 6. Start menu shortcut page -!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive Var AppStartMenuFolder +!if "${STARTMENUFOLDER}" != "" + !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive + !define MUI_STARTMENUPAGE_DEFAULTFOLDER "${STARTMENUFOLDER}" +!else + !define MUI_PAGE_CUSTOMFUNCTION_PRE Skip +!endif !insertmacro MUI_PAGE_STARTMENU Application $AppStartMenuFolder ; 7. Installation page @@ -324,7 +396,7 @@ Var AppStartMenuFolder ; Use show readme button in the finish page as a button create a desktop shortcut !define MUI_FINISHPAGE_SHOWREADME !define MUI_FINISHPAGE_SHOWREADME_TEXT "$(createDesktop)" -!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateDesktopShortcut +!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateOrUpdateDesktopShortcut ; Show run app after installation. !define MUI_FINISHPAGE_RUN !define MUI_FINISHPAGE_RUN_FUNCTION RunMainBinary @@ -341,21 +413,40 @@ Var DeleteAppDataCheckbox Var DeleteAppDataCheckboxState !define /ifndef WS_EX_LAYOUTRTL 0x00400000 !define MUI_PAGE_CUSTOMFUNCTION_SHOW un.ConfirmShow -Function un.ConfirmShow - FindWindow $1 "#32770" "" $HWNDPARENT ; Find inner dialog - ${If} $(^RTL) == 1 - System::Call 'USER32::CreateWindowEx(i${__NSD_CheckBox_EXSTYLE}|${WS_EX_LAYOUTRTL},t"${__NSD_CheckBox_CLASS}",t "$(deleteAppData)",i${__NSD_CheckBox_STYLE},i 50,i 100,i 400, i 25,i$1,i0,i0,i0)i.s' - ${Else} - System::Call 'USER32::CreateWindowEx(i${__NSD_CheckBox_EXSTYLE},t"${__NSD_CheckBox_CLASS}",t "$(deleteAppData)",i${__NSD_CheckBox_STYLE},i 0,i 100,i 400, i 25,i$1,i0,i0,i0)i.s' - ${EndIf} - Pop $DeleteAppDataCheckbox - SendMessage $HWNDPARENT ${WM_GETFONT} 0 0 $1 - SendMessage $DeleteAppDataCheckbox ${WM_SETFONT} $1 1 +Function un.ConfirmShow ; Add add a `Delete app data` check box + ; $1 inner dialog HWND + ; $2 window DPI + ; $3 style + ; $4 x + ; $5 y + ; $6 width + ; $7 height + FindWindow $1 "#32770" "" $HWNDPARENT ; Find inner dialog + System::Call "user32::GetDpiForWindow(p r1) i .r2" + ${If} $(^RTL) = 1 + StrCpy $3 "${__NSD_CheckBox_EXSTYLE} | ${WS_EX_LAYOUTRTL}" + IntOp $4 50 * $2 + ${Else} + StrCpy $3 "${__NSD_CheckBox_EXSTYLE}" + IntOp $4 0 * $2 + ${EndIf} + IntOp $5 100 * $2 + IntOp $6 400 * $2 + IntOp $7 25 * $2 + IntOp $4 $4 / 96 + IntOp $5 $5 / 96 + IntOp $6 $6 / 96 + IntOp $7 $7 / 96 + System::Call 'user32::CreateWindowEx(i r3, w "${__NSD_CheckBox_CLASS}", w "$(deleteAppData)", i ${__NSD_CheckBox_STYLE}, i r4, i r5, i r6, i r7, p r1, i0, i0, i0) i .s' + Pop $DeleteAppDataCheckbox + SendMessage $HWNDPARENT ${WM_GETFONT} 0 0 $1 + SendMessage $DeleteAppDataCheckbox ${WM_SETFONT} $1 1 FunctionEnd !define MUI_PAGE_CUSTOMFUNCTION_LEAVE un.ConfirmLeave Function un.ConfirmLeave - SendMessage $DeleteAppDataCheckbox ${BM_GETCHECK} 0 0 $DeleteAppDataCheckboxState + SendMessage $DeleteAppDataCheckbox ${BM_GETCHECK} 0 0 $DeleteAppDataCheckboxState FunctionEnd +!define MUI_PAGE_CUSTOMFUNCTION_PRE un.SkipIfPassive !insertmacro MUI_UNPAGE_CONFIRM ; 2. Uninstalling Page @@ -370,29 +461,21 @@ FunctionEnd !include "{{this}}" {{/each}} -!macro SetContext - !if "${INSTALLMODE}" == "currentUser" - SetShellVarContext current - !else if "${INSTALLMODE}" == "perMachine" - SetShellVarContext all - !endif - - ${If} ${RunningX64} - !if "${ARCH}" == "x64" - SetRegView 64 - !else if "${ARCH}" == "arm64" - SetRegView 64 - !else - SetRegView 32 - !endif - ${EndIf} -!macroend - -Var PassiveMode Function .onInit ${GetOptions} $CMDLINE "/P" $PassiveMode - IfErrors +2 0 + ${IfNot} ${Errors} StrCpy $PassiveMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/NS" $NoShortcutMode + ${IfNot} ${Errors} + StrCpy $NoShortcutMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/UPDATE" $UpdateMode + ${IfNot} ${Errors} + StrCpy $UpdateMode 1 + ${EndIf} !if "${DISPLAYLANGUAGESELECTOR}" == "true" !insertmacro MUI_LANGDLL_DISPLAY @@ -400,7 +483,7 @@ Function .onInit !insertmacro SetContext - ${If} $INSTDIR == "" + ${If} $INSTDIR == "${PLACEHOLDER_INSTALL_DIR}" ; Set default install location !if "${INSTALLMODE}" == "perMachine" ${If} ${RunningX64} @@ -427,6 +510,30 @@ Function .onInit !endif FunctionEnd + +Function CheckVCRuntime64 + Push $R0 + Push $R1 + StrCpy $VC_RUNTIME_READY "0" + StrCpy $R1 "$WINDIR\Sysnative" + IfFileExists "$R1\kernel32.dll" 0 +3 + IfFileExists "$R1\vcruntime140.dll" 0 missing + IfFileExists "$R1\msvcp140.dll" 0 missing + Goto found + StrCpy $R1 "$WINDIR\System32" + IfFileExists "$R1\vcruntime140.dll" 0 missing + IfFileExists "$R1\msvcp140.dll" 0 missing + found: + StrCpy $VC_RUNTIME_READY "1" + Goto done + missing: + StrCpy $VC_RUNTIME_READY "0" + done: + Pop $R1 + Pop $R0 +FunctionEnd + + !macro CheckAllVergeProcesses ; Check if clash-verge-service.exe is running !if "${INSTALLMODE}" == "currentUser" @@ -512,7 +619,7 @@ FunctionEnd !macro StartVergeService ; Check if the service exists SimpleSC::ExistsService "clash_verge_service" - Pop $0 ; 0:service exists;other: service not exists + Pop $0 ; 0: service exists; other: service not exists ; Service exists ${If} $0 == 0 Push $0 @@ -523,14 +630,14 @@ FunctionEnd ${If} $0 == 0 Push $0 ${If} $1 == 0 - DetailPrint "Restart ${PRODUCTNAME} Service..." - SimpleSC::StartService "clash_verge_service" "" 30 + DetailPrint "Restart ${PRODUCTNAME} Service..." + SimpleSC::StartService "clash_verge_service" "" 30 ${EndIf} ${ElseIf} $0 != 0 - Push $0 - SimpleSC::GetErrorMessage - Pop $0 - MessageBox MB_OK|MB_ICONSTOP "Check Service Status Error ($0)" + Push $0 + SimpleSC::GetErrorMessage + Pop $0 + MessageBox MB_OK|MB_ICONSTOP "Check Service Status Error ($0)" ${EndIf} ${EndIf} !macroend @@ -538,7 +645,7 @@ FunctionEnd !macro RemoveVergeService ; Check if the service exists SimpleSC::ExistsService "clash_verge_service" - Pop $0 ; 0:service exists;other: service not exists + Pop $0 ; 0: service exists; other: service not exists ; Service exists ${If} $0 == 0 Push $0 @@ -553,23 +660,23 @@ FunctionEnd SimpleSC::StopService "clash_verge_service" 1 30 Pop $0 ; returns an errorcode (<>0) otherwise success (0) ${If} $0 == 0 - DetailPrint "Removing ${PRODUCTNAME} Service..." - SimpleSC::RemoveService "clash_verge_service" + DetailPrint "Removing ${PRODUCTNAME} Service..." + SimpleSC::RemoveService "clash_verge_service" ${ElseIf} $0 != 0 - Push $0 - SimpleSC::GetErrorMessage - Pop $0 - MessageBox MB_OK|MB_ICONSTOP "${PRODUCTNAME} Service Stop Error ($0)" - ${EndIf} - ${ElseIf} $1 == 0 - DetailPrint "Removing ${PRODUCTNAME} Service..." - SimpleSC::RemoveService "clash_verge_service" - ${EndIf} - ${ElseIf} $0 != 0 Push $0 SimpleSC::GetErrorMessage Pop $0 - MessageBox MB_OK|MB_ICONSTOP "Check Service Status Error ($0)" + MessageBox MB_OK|MB_ICONSTOP "${PRODUCTNAME} Service Stop Error ($0)" + ${EndIf} + ${ElseIf} $1 == 0 + DetailPrint "Removing ${PRODUCTNAME} Service..." + SimpleSC::RemoveService "clash_verge_service" + ${EndIf} + ${ElseIf} $0 != 0 + Push $0 + SimpleSC::GetErrorMessage + Pop $0 + MessageBox MB_OK|MB_ICONSTOP "Check Service Status Error ($0)" ${EndIf} ${EndIf} !macroend @@ -577,240 +684,252 @@ FunctionEnd Section EarlyChecks ; Abort silent installer if downgrades is disabled !if "${ALLOWDOWNGRADES}" == "false" - IfSilent 0 silent_downgrades_done + ${If} ${Silent} ; If downgrading - ${If} $R0 == -1 + ${If} $R0 = -1 System::Call 'kernel32::AttachConsole(i -1)i.r0' - ${If} $0 != 0 + ${If} $0 <> 0 System::Call 'kernel32::GetStdHandle(i -11)i.r0' System::call 'kernel32::SetConsoleTextAttribute(i r0, i 0x0004)' ; set red color FileWrite $0 "$(silentDowngrades)" ${EndIf} Abort ${EndIf} - silent_downgrades_done: + ${EndIf} !endif SectionEnd +Section CheckAndInstallVSRuntime + StrCpy $VC_RUNTIME_NEEDED "0" + + ${If} ${IsNativeARM64} + StrCpy $VC_REDIST_URL "https://aka.ms/vs/17/release/vc_redist.arm64.exe" + StrCpy $VC_REDIST_EXE "vc_redist.arm64.exe" + Call CheckVCRuntime64 + ${If} $VC_RUNTIME_READY != "1" + StrCpy $VC_RUNTIME_NEEDED "1" + ${EndIf} + + ${ElseIf} ${RunningX64} + StrCpy $VC_REDIST_URL "https://aka.ms/vs/17/release/vc_redist.x64.exe" + StrCpy $VC_REDIST_EXE "vc_redist.x64.exe" + Call CheckVCRuntime64 + ${If} $VC_RUNTIME_READY != "1" + StrCpy $VC_RUNTIME_NEEDED "1" + ${EndIf} + + ${Else} + StrCpy $VC_REDIST_URL "https://aka.ms/vs/17/release/vc_redist.x86.exe" + StrCpy $VC_REDIST_EXE "vc_redist.x86.exe" + + IfFileExists "$SYSDIR\vcruntime140.dll" 0 filesMissing32 + IfFileExists "$SYSDIR\msvcp140.dll" 0 filesMissing32 + Goto afterFileCheck32 + filesMissing32: + StrCpy $VC_RUNTIME_NEEDED "1" + afterFileCheck32: + ${EndIf} + + ${If} $VC_RUNTIME_NEEDED != "1" + ${If} ${IsNativeARM64} + SetRegView 64 + ClearErrors + ReadRegDword $R0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\arm64" "Installed" + ${If} ${Errors} + StrCpy $R0 0 + ${EndIf} + SetRegView 32 + ${ElseIf} ${RunningX64} + SetRegView 64 + ClearErrors + ReadRegDword $R0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\${ARCH}" "Installed" + ${If} ${Errors} + StrCpy $R0 0 + ${EndIf} + SetRegView 32 + ${Else} + ClearErrors + ReadRegDword $R0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x86" "Installed" + ${If} ${Errors} + StrCpy $R0 0 + ${EndIf} + ${EndIf} + + ${If} $R0 != "1" + StrCpy $VC_RUNTIME_NEEDED "1" + ${EndIf} + ${EndIf} + + ${If} $VC_RUNTIME_NEEDED != "1" + DetailPrint "已检测到匹配的 Visual C++ Redistributable,跳过安装" + Goto done_vc + ${EndIf} + + DetailPrint "正在下载 Visual C++ Redistributable..." + nsisdl::download "$VC_REDIST_URL" "$TEMP\$VC_REDIST_EXE" + Pop $0 + ${If} $0 == "success" + DetailPrint "正在安装 Visual C++ Redistributable..." + ExecWait '"$TEMP\$VC_REDIST_EXE" /quiet /norestart' $0 + ${If} $0 == 0 + DetailPrint "Visual C++ Redistributable 安装成功" + ${Else} + DetailPrint "Visual C++ Redistributable 安装失败" + ${EndIf} + Delete "$TEMP\$VC_REDIST_EXE" + ${Else} + DetailPrint "Visual C++ Redistributable 下载失败" + ${EndIf} + + done_vc: +SectionEnd + Section WebView2 ; Check if Webview2 is already installed and skip this section ${If} ${RunningX64} - ReadRegStr $4 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ReadRegStr $4 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" ${Else} - ReadRegStr $4 HKLM "SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ReadRegStr $4 HKLM "SOFTWARE\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" + ${EndIf} + ${If} $4 == "" + ReadRegStr $4 HKCU "SOFTWARE\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" ${EndIf} - ReadRegStr $5 HKCU "SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" - StrCmp $4 "" 0 webview2_done - StrCmp $5 "" 0 webview2_done - - ; Webview2 install modes - !if "${INSTALLWEBVIEW2MODE}" == "downloadBootstrapper" - Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" - DetailPrint "$(webview2Downloading)" - NSISdl::download "https://go.microsoft.com/fwlink/p/?LinkId=2124703" "$TEMP\MicrosoftEdgeWebview2Setup.exe" - Pop $0 - ${If} $0 == 0 - DetailPrint "$(webview2DownloadSuccess)" - ${Else} - DetailPrint "$(webview2DownloadError)" - Abort "$(webview2AbortError)" - ${EndIf} - StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" - Goto install_webview2 - !endif - - !if "${INSTALLWEBVIEW2MODE}" == "embedBootstrapper" - Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" - File "/oname=$TEMP\MicrosoftEdgeWebview2Setup.exe" "${WEBVIEW2BOOTSTRAPPERPATH}" - DetailPrint "$(installingWebview2)" - StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" - Goto install_webview2 - !endif - - !if "${INSTALLWEBVIEW2MODE}" == "offlineInstaller" - Delete "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" - File "/oname=$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" "${WEBVIEW2INSTALLERPATH}" - DetailPrint "$(installingWebview2)" - StrCpy $6 "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" - Goto install_webview2 - !endif - - Goto webview2_done - - install_webview2: - DetailPrint "$(installingWebview2)" - ; $6 holds the path to the webview2 installer - ExecWait "$6 ${WEBVIEW2INSTALLERARGS} /install" $1 - ${If} $1 == 0 - DetailPrint "$(webview2InstallSuccess)" - ${Else} - DetailPrint "$(webview2InstallError)" - Abort "$(webview2AbortError)" - ${EndIf} - webview2_done: -SectionEnd - -!macro CheckIfAppIsRunning - !if "${INSTALLMODE}" == "currentUser" - nsis_tauri_utils::FindProcessCurrentUser "${MAINBINARYNAME}.exe" - !else - nsis_tauri_utils::FindProcess "${MAINBINARYNAME}.exe" - !endif - Pop $R0 - ${If} $R0 = 0 - IfSilent kill 0 - ${IfThen} $PassiveMode != 1 ${|} MessageBox MB_OKCANCEL "${PRODUCTNAME} 正在运行 $\n点击确定以终止运行" IDOK kill IDCANCEL cancel ${|} - kill: - !if "${INSTALLMODE}" == "currentUser" - nsis_tauri_utils::KillProcessCurrentUser "${MAINBINARYNAME}.exe" - !else - nsis_tauri_utils::KillProcess "${MAINBINARYNAME}.exe" - !endif - Pop $R0 - Sleep 500 - ${If} $R0 = 0 - Goto app_check_done + ${If} $4 == "" + ; Webview2 installation + ; + ; Skip if updating + ${If} $UpdateMode <> 1 + !if "${INSTALLWEBVIEW2MODE}" == "downloadBootstrapper" + Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" + DetailPrint "$(webview2Downloading)" + NSISdl::download "https://go.microsoft.com/fwlink/p/?LinkId=2124703" "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Pop $0 + ${If} $0 == "success" + DetailPrint "$(webview2DownloadSuccess)" ${Else} - IfSilent silent ui - silent: - System::Call 'kernel32::AttachConsole(i -1)i.r0' - ${If} $0 != 0 - System::Call 'kernel32::GetStdHandle(i -11)i.r0' - System::call 'kernel32::SetConsoleTextAttribute(i r0, i 0x0004)' ; set red color - FileWrite $0 "$(appRunning)$\n" + DetailPrint "$(webview2DownloadError)" + Abort "$(webview2AbortError)" + ${EndIf} + StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Goto install_webview2 + !endif + + !if "${INSTALLWEBVIEW2MODE}" == "embedBootstrapper" + Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" + File "/oname=$TEMP\MicrosoftEdgeWebview2Setup.exe" "${WEBVIEW2BOOTSTRAPPERPATH}" + DetailPrint "$(installingWebview2)" + StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Goto install_webview2 + !endif + + !if "${INSTALLWEBVIEW2MODE}" == "offlineInstaller" + Delete "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" + File "/oname=$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" "${WEBVIEW2INSTALLERPATH}" + DetailPrint "$(installingWebview2)" + StrCpy $6 "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" + Goto install_webview2 + !endif + + Goto webview2_done + + install_webview2: + DetailPrint "$(installingWebview2)" + ; $6 holds the path to the webview2 installer + ExecWait "$6 ${WEBVIEW2INSTALLERARGS} /install" $1 + ${If} $1 = 0 + DetailPrint "$(webview2InstallSuccess)" + ${Else} + DetailPrint "$(webview2InstallError)" + Abort "$(webview2AbortError)" + ${EndIf} + webview2_done: + ${EndIf} + ${Else} + !if "${MINIMUMWEBVIEW2VERSION}" != "" + ${VersionCompare} "${MINIMUMWEBVIEW2VERSION}" "$4" $R0 + ${If} $R0 = 1 + update_webview: + DetailPrint "$(installingWebview2)" + ${If} ${RunningX64} + ReadRegStr $R1 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate" "path" + ${Else} + ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\EdgeUpdate" "path" + ${EndIf} + ${If} $R1 == "" + ReadRegStr $R1 HKCU "SOFTWARE\Microsoft\EdgeUpdate" "path" + ${EndIf} + ${If} $R1 != "" + ; Chromium updater docs: https://source.chromium.org/chromium/chromium/src/+/main:docs/updater/user_manual.md + ; Modified from "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft EdgeWebView\ModifyPath" + ExecWait `"$R1" /install appguid=${WEBVIEW2APPGUID}&needsadmin=true` $1 + ${If} $1 = 0 + DetailPrint "$(webview2InstallSuccess)" + ${Else} + MessageBox MB_ICONEXCLAMATION|MB_ABORTRETRYIGNORE "$(webview2InstallError)" IDIGNORE ignore IDRETRY update_webview + Quit + ignore: ${EndIf} - Abort - ui: - Abort "$(failedToKillApp)" - ${EndIf} - cancel: - Abort "$(appRunning)" + ${EndIf} + ${EndIf} + !endif ${EndIf} - app_check_done: -!macroend - -Var VC_REDIST_URL -Var VC_REDIST_EXE - -Section CheckAndInstallVSRuntime - ; 检查是否已安装 Visual C++ Redistributable - ${If} ${IsNativeARM64} - StrCpy $VC_REDIST_URL "https://aka.ms/vs/17/release/vc_redist.arm64.exe" - StrCpy $VC_REDIST_EXE "vc_redist.arm64.exe" - - ; 检查关键DLL - IfFileExists "$SYSDIR\vcruntime140.dll" 0 checkInstall - IfFileExists "$SYSDIR\msvcp140.dll" Done checkInstall - - ${ElseIf} ${RunningX64} - StrCpy $VC_REDIST_URL "https://aka.ms/vs/17/release/vc_redist.x64.exe" - StrCpy $VC_REDIST_EXE "vc_redist.x64.exe" - - ; 检查关键DLL - IfFileExists "$SYSDIR\vcruntime140.dll" 0 checkInstall - IfFileExists "$SYSDIR\msvcp140.dll" Done checkInstall - - ${Else} - StrCpy $VC_REDIST_URL "https://aka.ms/vs/17/release/vc_redist.x86.exe" - StrCpy $VC_REDIST_EXE "vc_redist.x86.exe" - - ; 检查关键DLL - IfFileExists "$SYSDIR\vcruntime140.dll" 0 checkInstall - IfFileExists "$SYSDIR\msvcp140.dll" Done checkInstall - ${EndIf} - - checkInstall: - ; 检查注册表 - ${If} ${RunningX64} - SetRegView 64 - ReadRegDword $R0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\${ARCH}" "Installed" - ${If} $R0 == "1" - Goto Done - ${EndIf} - ${Else} - ReadRegDword $R0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x86" "Installed" - ${If} $R0 == "1" - Goto Done - ${EndIf} - ${EndIf} - - ; 如果没有安装,则下载并安装 - DetailPrint "正在下载 Visual C++ Redistributable..." - nsisdl::download "$VC_REDIST_URL" "$TEMP\$VC_REDIST_EXE" - Pop $0 - ${If} $0 == "success" - DetailPrint "正在安装 Visual C++ Redistributable..." - ExecWait '"$TEMP\$VC_REDIST_EXE" /quiet /norestart' $0 - ${If} $0 == 0 - DetailPrint "Visual C++ Redistributable 安装成功" - ${Else} - DetailPrint "Visual C++ Redistributable 安装失败" - ${EndIf} - Delete "$TEMP\$VC_REDIST_EXE" - ${Else} - DetailPrint "Visual C++ Redistributable 下载失败" - ${EndIf} - - Done: SectionEnd Section Install SetOutPath $INSTDIR + + !ifmacrodef NSIS_HOOK_PREINSTALL + !insertmacro NSIS_HOOK_PREINSTALL + !endif + nsExec::Exec 'netsh int tcp res' - !insertmacro CheckIfAppIsRunning + + !insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}" !insertmacro CheckAllVergeProcesses - ; 修复系统级启动文件夹 + ; Ensure startup folders exist CreateDirectory "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup" - DetailPrint "系统级启动文件夹已确保存在" + DetailPrint "Ensured system startup folder exists" - ; 修复用户级启动文件夹 SetShellVarContext current StrCpy $0 "$SMPROGRAMS\Startup" CreateDirectory "$0" - DetailPrint "已确保用户级启动文件夹存在: $0" + DetailPrint "Ensured user startup folder exists: $0" - ; 删除 window-state.json 文件 .window-state.json 文件 - DetailPrint "开始删除 window-state.json or .window-state.json" - SetShellVarContext current + ; Remove stale window-state files + DetailPrint "Removing window-state.json / .window-state.json" Delete "$APPDATA\io.github.clash-verge-rev.clash-verge-rev\window-state.json" Delete "$APPDATA\io.github.clash-verge-rev.clash-verge-rev\.window-state.json" - ; 清理自启动注册表项 - DetailPrint "Cleaning auto-launch registry entries..." - + ; Clean legacy auto-launch registry entries StrCpy $R1 "Software\Microsoft\Windows\CurrentVersion\Run" SetRegView 64 - ; 清理旧版本的注册表项 (Clash Verge) ReadRegStr $R2 HKCU "$R1" "Clash Verge" ${If} $R2 != "" DeleteRegValue HKCU "$R1" "Clash Verge" ${EndIf} - ReadRegStr $R2 HKLM "$R1" "Clash Verge" ${If} $R2 != "" DeleteRegValue HKLM "$R1" "Clash Verge" ${EndIf} - - ; 清理新版本的注册表项 (clash-verge) ReadRegStr $R2 HKCU "$R1" "clash-verge" ${If} $R2 != "" DeleteRegValue HKCU "$R1" "clash-verge" ${EndIf} - ReadRegStr $R2 HKLM "$R1" "clash-verge" ${If} $R2 != "" DeleteRegValue HKLM "$R1" "clash-verge" ${EndIf} - ; Delete old files before installation - ; Delete clash-verge.desktop + ; Remove legacy executables IfFileExists "$INSTDIR\Clash Verge.exe" 0 +2 Delete "$INSTDIR\Clash Verge.exe" + !insertmacro SetContext + ; Copy main executable File "${MAINBINARYSRCPATH}" @@ -819,16 +938,31 @@ Section Install CreateDirectory "$INSTDIR\\{{this}}" {{/each}} {{#each resources}} - File /a "/oname={{this.[1]}}" "{{@key}}" + File /a "/oname={{this.[1]}}" "{{no-escape @key}}" {{/each}} ; Copy external binaries {{#each binaries}} - File /a "/oname={{this}}" "{{@key}}" + File /a "/oname={{this}}" "{{no-escape @key}}" {{/each}} !insertmacro StartVergeService + ; Create file associations + {{#each file_associations as |association| ~}} + {{#each association.ext as |ext| ~}} + !insertmacro APP_ASSOCIATE "{{ext}}" "{{or association.name ext}}" "{{association-description association.description ext}}" "$INSTDIR\${MAINBINARYNAME}.exe,0" "Open with ${PRODUCTNAME}" "$INSTDIR\${MAINBINARYNAME}.exe $\"%1$\"" + {{/each}} + {{/each}} + + ; Register deep links + {{#each deep_link_protocols as |protocol| ~}} + WriteRegStr SHCTX "Software\Classes\\{{protocol}}" "URL Protocol" "" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}" "" "URL:${BUNDLEID} protocol" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}\DefaultIcon" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\",0" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}\shell\open\command" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\"" + {{/each}} + ; Create uninstaller WriteUninstaller "$INSTDIR\uninstall.exe" @@ -841,6 +975,16 @@ Section Install WriteRegStr SHCTX "${UNINSTKEY}" $MultiUser.InstallMode 1 !endif + ; Remove old main binary if it doesn't match new main binary name + ReadRegStr $OldMainBinaryName SHCTX "${UNINSTKEY}" "MainBinaryName" + ${If} $OldMainBinaryName != "" + ${AndIf} $OldMainBinaryName != "${MAINBINARYNAME}.exe" + Delete "$INSTDIR\$OldMainBinaryName" + ${EndIf} + + ; Save current MAINBINARYNAME for future updates + WriteRegStr SHCTX "${UNINSTKEY}" "MainBinaryName" "${MAINBINARYNAME}.exe" + ; Registry information for add/remove programs WriteRegStr SHCTX "${UNINSTKEY}" "DisplayName" "${PRODUCTNAME}" WriteRegStr SHCTX "${UNINSTKEY}" "DisplayIcon" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\"" @@ -850,42 +994,51 @@ Section Install WriteRegStr SHCTX "${UNINSTKEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" WriteRegDWORD SHCTX "${UNINSTKEY}" "NoModify" "1" WriteRegDWORD SHCTX "${UNINSTKEY}" "NoRepair" "1" - WriteRegDWORD SHCTX "${UNINSTKEY}" "EstimatedSize" "${ESTIMATEDSIZE}" - ; Create start menu shortcut (GUI) + ${GetSize} "$INSTDIR" "/M=uninstall.exe /S=0K /G=0" $0 $1 $2 + IntOp $0 $0 + ${ESTIMATEDSIZE} + IntFmt $0 "0x%08X" $0 + WriteRegDWORD SHCTX "${UNINSTKEY}" "EstimatedSize" "$0" + + !if "${HOMEPAGE}" != "" + WriteRegStr SHCTX "${UNINSTKEY}" "URLInfoAbout" "${HOMEPAGE}" + WriteRegStr SHCTX "${UNINSTKEY}" "URLUpdateInfo" "${HOMEPAGE}" + WriteRegStr SHCTX "${UNINSTKEY}" "HelpLink" "${HOMEPAGE}" + !endif + + ; Create start menu shortcut !insertmacro MUI_STARTMENU_WRITE_BEGIN Application - Call CreateStartMenuShortcut + Call CreateOrUpdateStartMenuShortcut !insertmacro MUI_STARTMENU_WRITE_END - ; Create shortcuts for silent and passive installers, which - ; can be disabled by passing `/NS` flag - ; GUI installer has buttons for users to control creating them - IfSilent check_ns_flag 0 - ${IfThen} $PassiveMode == 1 ${|} Goto check_ns_flag ${|} - Goto shortcuts_done - check_ns_flag: - ${GetOptions} $CMDLINE "/NS" $R0 - IfErrors 0 shortcuts_done - Call CreateDesktopShortcut - Call CreateStartMenuShortcut - shortcuts_done: + ; Create desktop shortcut for silent and passive installers + ; because finish page will be skipped + ${If} $PassiveMode = 1 + ${OrIf} ${Silent} + Call CreateOrUpdateDesktopShortcut + ${EndIf} + + !ifmacrodef NSIS_HOOK_POSTINSTALL + !insertmacro NSIS_HOOK_POSTINSTALL + !endif ; Auto close this page for passive mode - ${IfThen} $PassiveMode == 1 ${|} SetAutoClose true ${|} + ${If} $PassiveMode = 1 + SetAutoClose true + ${EndIf} SectionEnd Function .onInstSuccess ; Check for `/R` flag only in silent and passive installers because ; GUI installer has a toggle for the user to (re)start the app - IfSilent check_r_flag 0 - ${IfThen} $PassiveMode == 1 ${|} Goto check_r_flag ${|} - Goto run_done - check_r_flag: + ${If} $PassiveMode = 1 + ${OrIf} ${Silent} ${GetOptions} $CMDLINE "/R" $R0 - IfErrors run_done 0 + ${IfNot} ${Errors} ${GetOptions} $CMDLINE "/ARGS" $R0 nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" "$R0" - run_done: + ${EndIf} + ${EndIf} FunctionEnd Function un.onInit @@ -896,77 +1049,61 @@ Function un.onInit !endif !insertmacro MUI_UNGETLANGUAGE + + ${GetOptions} $CMDLINE "/P" $PassiveMode + ${IfNot} ${Errors} + StrCpy $PassiveMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/UPDATE" $UpdateMode + ${IfNot} ${Errors} + StrCpy $UpdateMode 1 + ${EndIf} FunctionEnd -!macro DeleteAppUserModelId - !insertmacro ComHlpr_CreateInProcInstance ${CLSID_DestinationList} ${IID_ICustomDestinationList} r1 "" - ${If} $1 P<> 0 - ${ICustomDestinationList::DeleteList} $1 '("${BUNDLEID}")' - ${IUnknown::Release} $1 "" - ${EndIf} - !insertmacro ComHlpr_CreateInProcInstance ${CLSID_ApplicationDestinations} ${IID_IApplicationDestinations} r1 "" - ${If} $1 P<> 0 - ${IApplicationDestinations::SetAppID} $1 '("${BUNDLEID}")i.r0' - ${If} $0 >= 0 - ${IApplicationDestinations::RemoveAllDestinations} $1 '' - ${EndIf} - ${IUnknown::Release} $1 "" - ${EndIf} -!macroend - -; From https://stackoverflow.com/a/42816728/16993372 -!macro UnpinShortcut shortcut - !insertmacro ComHlpr_CreateInProcInstance ${CLSID_StartMenuPin} ${IID_IStartMenuPinnedList} r0 "" - ${If} $0 P<> 0 - System::Call 'SHELL32::SHCreateItemFromParsingName(ws, p0, g "${IID_IShellItem}", *p0r1)' "${shortcut}" - ${If} $1 P<> 0 - ${IStartMenuPinnedList::RemoveFromList} $0 '(r1)' - ${IUnknown::Release} $1 "" - ${EndIf} - ${IUnknown::Release} $0 "" - ${EndIf} -!macroend - Section Uninstall - !insertmacro CheckIfAppIsRunning + !ifmacrodef NSIS_HOOK_PREUNINSTALL + !insertmacro NSIS_HOOK_PREUNINSTALL + !endif + + !insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}" !insertmacro CheckAllVergeProcesses !insertmacro RemoveVergeService - ; 删除 window-state.json 文件 .window-state.json 文件 - DetailPrint "开始删除 window-state.json or .window-state.json" + ; Remove cached window state files + DetailPrint "Removing window-state.json / .window-state.json" SetShellVarContext current Delete "$APPDATA\io.github.clash-verge-rev.clash-verge-rev\window-state.json" Delete "$APPDATA\io.github.clash-verge-rev.clash-verge-rev\.window-state.json" - ; 清理自启动注册表项 - DetailPrint "Cleaning auto-launch registry entries..." - + ; Clean legacy auto-launch registry entries StrCpy $R1 "Software\Microsoft\Windows\CurrentVersion\Run" SetRegView 64 - ; 清理旧版本的注册表项 (Clash Verge) ReadRegStr $R2 HKCU "$R1" "Clash Verge" ${If} $R2 != "" DeleteRegValue HKCU "$R1" "Clash Verge" ${EndIf} - ReadRegStr $R2 HKLM "$R1" "Clash Verge" ${If} $R2 != "" DeleteRegValue HKLM "$R1" "Clash Verge" ${EndIf} - - ; 清理新版本的注册表项 (clash-verge) ReadRegStr $R2 HKCU "$R1" "clash-verge" ${If} $R2 != "" DeleteRegValue HKCU "$R1" "clash-verge" ${EndIf} - ReadRegStr $R2 HKLM "$R1" "clash-verge" ${If} $R2 != "" DeleteRegValue HKLM "$R1" "clash-verge" ${EndIf} + ; Remove legacy executables + IfFileExists "$INSTDIR\Clash Verge.exe" 0 +2 + Delete "$INSTDIR\Clash Verge.exe" + + !insertmacro SetContext + ; Delete the app directory and its content from disk ; Copy main executable Delete "$INSTDIR\${MAINBINARYNAME}.exe" @@ -975,15 +1112,27 @@ Section Uninstall {{#each resources}} Delete "$INSTDIR\\{{this.[1]}}" {{/each}} - Delete "$INSTDIR\resources" + ; Delete external binaries {{#each binaries}} Delete "$INSTDIR\\{{this}}" {{/each}} - ; Delete clash-verge.desktop - IfFileExists "$INSTDIR\Clash Verge.exe" 0 +2 - Delete "$INSTDIR\Clash Verge.exe" + ; Delete app associations + {{#each file_associations as |association| ~}} + {{#each association.ext as |ext| ~}} + !insertmacro APP_UNASSOCIATE "{{ext}}" "{{or association.name ext}}" + {{/each}} + {{/each}} + + ; Delete deep links + {{#each deep_link_protocols as |protocol| ~}} + ReadRegStr $R7 SHCTX "Software\Classes\\{{protocol}}\shell\open\command" "" + ${If} $R7 == "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\"" + DeleteRegKey SHCTX "Software\Classes\\{{protocol}}" + ${EndIf} + {{/each}} + ; Delete uninstaller Delete "$INSTDIR\uninstall.exe" @@ -993,146 +1142,94 @@ Section Uninstall {{/each}} RMDir "$INSTDIR" - ; 删除固定栏 - !insertmacro DeleteAppUserModelId - !insertmacro UnpinShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" - !insertmacro UnpinShortcut "$DESKTOP\${PRODUCTNAME}.lnk" - !insertmacro UnpinShortcut "$SMPROGRAMS\$AppStartMenuFolder\${MAINBINARYNAME}.lnk" - !insertmacro UnpinShortcut "$DESKTOP\${MAINBINARYNAME}.lnk" + ; Remove shortcuts if not updating + ${If} $UpdateMode <> 1 + !insertmacro DeleteAppUserModelId - ; 删除所有用户的桌面快捷方式 - DetailPrint "开始删除所有用户桌面的 ${PRODUCTNAME} 快捷方式..." - - ; 删除公共桌面快捷方式 - Delete "C:\Users\Public\Desktop\Clash Verge.lnk" - Delete "C:\Users\Public\Desktop\clash-verge.lnk" - - ; 枚举所有用户配置文件目录 - SetRegView 64 - ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList" "" - - ; 初始化循环 - StrCpy $R1 0 - Loop: - EnumRegKey $R2 HKLM "SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList" $R1 - ${If} $R2 == "" - Goto Done + ; Remove start menu shortcut + !insertmacro MUI_STARTMENU_GETFOLDER Application $AppStartMenuFolder + !insertmacro IsShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + Delete "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + RMDir "$SMPROGRAMS\$AppStartMenuFolder" + ${EndIf} + !insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" + Delete "$SMPROGRAMS\${PRODUCTNAME}.lnk" ${EndIf} - ; 读取用户配置文件路径 - ReadRegStr $R3 HKLM "SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$R2" "ProfileImagePath" - ${If} $R3 != "" - ; 构建用户桌面路径 - StrCpy $R4 "$R3\Desktop" - - ; 删除该用户桌面的快捷方式 - Delete "$R4\Clash Verge.lnk" - Delete "$R4\clash-verge.lnk" - - DetailPrint "尝试删除用户 '$R3' 桌面的 ${PRODUCTNAME} 快捷方式" + ; Remove desktop shortcuts + !insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$DESKTOP\${PRODUCTNAME}.lnk" + Delete "$DESKTOP\${PRODUCTNAME}.lnk" ${EndIf} - ; 递增循环计数器 - IntOp $R1 $R1 + 1 - Goto Loop - Done: + ; Remove legacy public desktop shortcuts + Delete "C:\Users\Public\Desktop\Clash Verge.lnk" + Delete "C:\Users\Public\Desktop\clash-verge.lnk" - DetailPrint "所有用户桌面快捷方式删除完成" + ; Remove legacy shortcuts from all user desktops + DetailPrint "Removing ${PRODUCTNAME} shortcuts from all user desktops..." + SetRegView 64 + StrCpy $R1 0 + LegacyUserLoop: + EnumRegKey $R2 HKLM "SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList" $R1 + ${If} $R2 == "" + Goto LegacyUserDone + ${EndIf} + ReadRegStr $R3 HKLM "SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$R2" "ProfileImagePath" + ${If} $R3 != "" + StrCpy $R4 "$R3\Desktop" + Delete "$R4\Clash Verge.lnk" + Delete "$R4\clash-verge.lnk" + ${EndIf} + IntOp $R1 $R1 + 1 + Goto LegacyUserLoop + LegacyUserDone: + !insertmacro SetContext - ; 删除用户级开始菜单中的应用程序文件夹和快捷方式 - DetailPrint "删除用户级开始菜单中的应用程序文件夹和快捷方式..." - RMDir /r /REBOOTOK "$SMPROGRAMS\Clash Verge" - RMDir /r /REBOOTOK "$SMPROGRAMS\clash-verge" - DetailPrint "删除用户级开始菜单中的应用程序文件夹和快捷方式完成" + ; Remove legacy start menu folders + SetShellVarContext current + RMDir /r /REBOOTOK "$SMPROGRAMS\Clash Verge" + RMDir /r /REBOOTOK "$SMPROGRAMS\clash-verge" + !insertmacro SetContext + RMDir /r /REBOOTOK "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Clash Verge" + RMDir /r /REBOOTOK "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\clash-verge" - ; 删除系统级开始菜单中的应用程序文件夹和快捷方式 - DetailPrint "删除系统级开始菜单中的应用程序文件夹和快捷方式..." - RMDir /r /REBOOTOK "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Clash Verge" - RMDir /r /REBOOTOK "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\clash-verge" - DetailPrint "删除系统级开始菜单中的应用程序文件夹和快捷方式完成" - - ; 删除所有带 Clash Verge 或 clash-verge 的注册表项 - DetailPrint "开始清理所有 ${PRODUCTNAME} 相关的注册表项..." - - ; 设置注册表查看模式 (64位) - SetRegView 64 - - ; 清理 CurrentVersion\Run 中的自启动项 - StrCpy $R1 "Software\Microsoft\Windows\CurrentVersion\Run" - DeleteRegValue HKCU "$R1" "Clash Verge" - DeleteRegValue HKCU "$R1" "clash-verge" - DeleteRegValue HKLM "$R1" "Clash Verge" - DeleteRegValue HKLM "$R1" "clash-verge" - - ; 清理 App Paths - DeleteRegKey HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\Clash Verge.exe" - DeleteRegKey HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\clash-verge.exe" - - ; 删除指定的注册表路径 - DeleteRegKey HKLM "Software\Clash Verge Rev" - DeleteRegKey HKCU "Software\Clash Verge Rev" - DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClashVerge" - DeleteRegKey HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Clash Verge" - - ; 清理 Uninstall 信息 - StrCpy $R1 0 - EnumUninstallLoop: - EnumRegKey $R2 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" $R1 - ${If} $R2 == "" - Goto EnumUninstallDone - ${EndIf} - - ReadRegStr $R3 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$R2" "DisplayName" - ${If} $R3 != "" - StrCmp $R3 "Clash Verge" 0 +3 - StrCmp $R3 "clash-verge" 0 +2 - DeleteRegKey HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$R2" - ${EndIf} - - IntOp $R1 $R1 + 1 - Goto EnumUninstallLoop - EnumUninstallDone: - - ; 清理用户特定的注册表项 - StrCpy $R1 0 - EnumHKCULoop: - EnumRegKey $R2 HKCU "SOFTWARE" $R1 - ${If} $R2 == "" - Goto EnumHKCUDone - ${EndIf} - - ReadRegStr $R3 HKCU "SOFTWARE\$R2" "" - ${If} $R3 != "" - StrCmp $R3 "Clash Verge" 0 +3 - StrCmp $R3 "clash-verge" 0 +2 - DeleteRegKey HKCU "SOFTWARE\$R2" - ${EndIf} - - IntOp $R1 $R1 + 1 - Goto EnumHKCULoop - EnumHKCUDone: - - ; 清理系统范围的注册表项 - StrCpy $R1 0 - EnumHKLMLoop: - EnumRegKey $R2 HKLM "SOFTWARE" $R1 - ${If} $R2 == "" - Goto EnumHKLMDone - ${EndIf} - - ReadRegStr $R3 HKLM "SOFTWARE\$R2" "" - ${If} $R3 != "" - StrCmp $R3 "Clash Verge" 0 +3 - StrCmp $R3 "clash-verge" 0 +2 - DeleteRegKey HKLM "SOFTWARE\$R2" - ${EndIf} - - IntOp $R1 $R1 + 1 - Goto EnumHKLMLoop - EnumHKLMDone: - - DetailPrint "注册表清理完成" + ; Clean legacy registry keys + SetRegView 64 + DeleteRegKey HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\Clash Verge.exe" + DeleteRegKey HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\clash-verge.exe" + DeleteRegKey HKLM "Software\Clash Verge Rev" + DeleteRegKey HKLM "Software\Clash Verge" + DeleteRegKey HKCU "Software\Clash Verge Rev" + DeleteRegKey HKCU "Software\Clash Verge" + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClashVerge" + DeleteRegKey HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Clash Verge" + StrCpy $R1 0 + LegacyUninstallLoop: + EnumRegKey $R2 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" $R1 + ${If} $R2 == "" + Goto LegacyUninstallDone + ${EndIf} + ReadRegStr $R3 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$R2" "DisplayName" + ${If} $R3 != "" + StrCmp $R3 "Clash Verge" 0 +3 + StrCmp $R3 "clash-verge" 0 +2 + DeleteRegKey HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$R2" + ${EndIf} + IntOp $R1 $R1 + 1 + Goto LegacyUninstallLoop + LegacyUninstallDone: + !insertmacro SetContext + ${EndIf} ; Remove registry information for add/remove programs !if "${INSTALLMODE}" == "both" @@ -1143,18 +1240,41 @@ Section Uninstall DeleteRegKey HKCU "${UNINSTKEY}" !endif - DeleteRegValue HKCU "${MANUPRODUCTKEY}" "Installer Language" + ; Removes the Autostart entry for ${PRODUCTNAME} from the HKCU Run key if it exists. + ; This ensures the program does not launch automatically after uninstallation if it exists. + ; If it doesn't exist, it does nothing. + ; We do this when not updating (to preserve the registry value on updates) + ${If} $UpdateMode <> 1 + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}" + ${EndIf} + + ; Delete app data if the checkbox is selected + ; and if not updating + ${If} $DeleteAppDataCheckboxState = 1 + ${AndIf} $UpdateMode <> 1 + ; Clear the install location $INSTDIR from registry + DeleteRegKey SHCTX "${MANUPRODUCTKEY}" + DeleteRegKey /ifempty SHCTX "${MANUKEY}" + + ; Clear the install language from registry + DeleteRegValue HKCU "${MANUPRODUCTKEY}" "Installer Language" + DeleteRegKey /ifempty HKCU "${MANUPRODUCTKEY}" + DeleteRegKey /ifempty HKCU "${MANUKEY}" - ; Delete app data - ${If} $DeleteAppDataCheckboxState == 1 SetShellVarContext current RmDir /r "$APPDATA\${BUNDLEID}" RmDir /r "$LOCALAPPDATA\${BUNDLEID}" ${EndIf} - ${GetOptions} $CMDLINE "/P" $R0 - IfErrors +2 0 + !ifmacrodef NSIS_HOOK_POSTUNINSTALL + !insertmacro NSIS_HOOK_POSTUNINSTALL + !endif + + ; Auto close if passive mode or updating + ${If} $PassiveMode = 1 + ${OrIf} $UpdateMode = 1 SetAutoClose true + ${EndIf} SectionEnd Function RestorePreviousInstallLocation @@ -1163,42 +1283,78 @@ Function RestorePreviousInstallLocation StrCpy $INSTDIR $4 FunctionEnd -Function SkipIfPassive - ${IfThen} $PassiveMode == 1 ${|} Abort ${|} +Function Skip + Abort FunctionEnd -!macro SetLnkAppUserModelId shortcut - !insertmacro ComHlpr_CreateInProcInstance ${CLSID_ShellLink} ${IID_IShellLink} r0 "" - ${If} $0 P<> 0 - ${IUnknown::QueryInterface} $0 '("${IID_IPersistFile}",.r1)' - ${If} $1 P<> 0 - ${IPersistFile::Load} $1 '("${shortcut}", ${STGM_READWRITE})' - ${IUnknown::QueryInterface} $0 '("${IID_IPropertyStore}",.r2)' - ${If} $2 P<> 0 - System::Call 'Oleaut32::SysAllocString(w "${BUNDLEID}") i.r3' - System::Call '*${SYSSTRUCT_PROPERTYKEY}(${PKEY_AppUserModel_ID})p.r4' - System::Call '*${SYSSTRUCT_PROPVARIANT}(${VT_BSTR},,&i4 $3)p.r5' - ${IPropertyStore::SetValue} $2 '($4,$5)' +Function SkipIfPassive + ${IfThen} $PassiveMode = 1 ${|} Abort ${|} +FunctionEnd +Function un.SkipIfPassive + ${IfThen} $PassiveMode = 1 ${|} Abort ${|} +FunctionEnd - System::Call 'Oleaut32::SysFreeString($3)' - System::Free $4 - System::Free $5 - ${IPropertyStore::Commit} $2 "" - ${IPersistFile::Save} $1 '("${shortcut}",1)' - ${EndIf} - ${IUnknown::Release} $1 "" - ${EndIf} - ${IUnknown::Release} $0 "" +Function CreateOrUpdateStartMenuShortcut + ; We used to use product name as MAINBINARYNAME + ; migrate old shortcuts to target the new MAINBINARYNAME + StrCpy $R0 0 + + !insertmacro IsShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + StrCpy $R0 1 + ${EndIf} + + !insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + StrCpy $R0 1 + ${EndIf} + + ${If} $R0 = 1 + Return + ${EndIf} + + ; Skip creating shortcut if in update mode or no shortcut mode + ; but always create if migrating from wix + ${If} $WixMode = 0 + ${If} $UpdateMode = 1 + ${OrIf} $NoShortcutMode = 1 + Return + ${EndIf} + ${EndIf} + + !if "${STARTMENUFOLDER}" != "" + CreateDirectory "$SMPROGRAMS\$AppStartMenuFolder" + CreateShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + !else + CreateShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\${PRODUCTNAME}.lnk" + !endif +FunctionEnd + +Function CreateOrUpdateDesktopShortcut + ; We used to use product name as MAINBINARYNAME + ; migrate old shortcuts to target the new MAINBINARYNAME + !insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Return + ${EndIf} + + ; Skip creating shortcut if in update mode or no shortcut mode + ; but always create if migrating from wix + ${If} $WixMode = 0 + ${If} $UpdateMode = 1 + ${OrIf} $NoShortcutMode = 1 + Return + ${EndIf} ${EndIf} -!macroend -Function CreateDesktopShortcut CreateShortcut "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" !insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk" FunctionEnd - -Function CreateStartMenuShortcut - CreateDirectory "$SMPROGRAMS\$AppStartMenuFolder" - CreateShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" - !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" -FunctionEnd diff --git a/clash-verge-rev/src-tauri/rust-toolchain.toml b/clash-verge-rev/src-tauri/rust-toolchain.toml new file mode 100644 index 0000000000..cdeba7a2b8 --- /dev/null +++ b/clash-verge-rev/src-tauri/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.91.0" +components = ["rustfmt", "clippy"] diff --git a/clash-verge-rev/src-tauri/rustfmt.toml b/clash-verge-rev/src-tauri/rustfmt.toml index baaa750e30..7e141a0b8a 100644 --- a/clash-verge-rev/src-tauri/rustfmt.toml +++ b/clash-verge-rev/src-tauri/rustfmt.toml @@ -6,7 +6,7 @@ use_small_heuristics = "Default" reorder_imports = true reorder_modules = true remove_nested_parens = true -edition = "2021" +edition = "2024" merge_derives = true use_try_shorthand = false use_field_init_shorthand = false diff --git a/clash-verge-rev/src-tauri/src/cmd/app.rs b/clash-verge-rev/src-tauri/src/cmd/app.rs index 6a3a3dd872..4a806acb70 100644 --- a/clash-verge-rev/src-tauri/src/cmd/app.rs +++ b/clash-verge-rev/src-tauri/src/cmd/app.rs @@ -1,37 +1,60 @@ use super::CmdResult; +use crate::core::sysopt::Sysopt; +use crate::utils::resolve::ui::{self, UiReadyStage}; use crate::{ + cmd::StringifyErr as _, feat, logging, - utils::{dirs, logging::Type}, - wrap_err, + utils::{ + dirs::{self, PathBufExec as _}, + logging::Type, + }, }; -use tauri::{AppHandle, Manager}; +use smartstring::alias::String; +use std::path::Path; +use tauri::{AppHandle, Manager as _}; +use tokio::fs; +use tokio::io::AsyncWriteExt as _; /// 打开应用程序所在目录 #[tauri::command] pub async fn open_app_dir() -> CmdResult<()> { - let app_dir = wrap_err!(dirs::app_home_dir())?; - wrap_err!(open::that(app_dir)) + 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 = wrap_err!(tauri::utils::platform::current_exe())?; + let core_dir = tauri::utils::platform::current_exe().stringify_err()?; let core_dir = core_dir.parent().ok_or("failed to get core dir")?; - wrap_err!(open::that(core_dir)) + open::that(core_dir).stringify_err() } /// 打开日志目录 #[tauri::command] pub async fn open_logs_dir() -> CmdResult<()> { - let log_dir = wrap_err!(dirs::app_logs_dir())?; - wrap_err!(open::that(log_dir)) + 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<()> { - wrap_err!(open::that(url)) + open::that(url.as_str()).stringify_err() +} + +// TODO 后续可以为前端提供接口,当前作为托盘菜单使用 +/// 打开 Verge 最新日志 +#[tauri::command] +pub async fn open_app_log() -> CmdResult<()> { + open::that(dirs::app_latest_log().stringify_err()?).stringify_err() +} + +// TODO 后续可以为前端提供接口,当前作为托盘菜单使用 +/// 打开 Clash 最新日志 +#[tauri::command] +pub async fn open_core_log() -> CmdResult<()> { + open::that(dirs::clash_latest_log().stringify_err()?).stringify_err() } /// 打开/关闭开发者工具 @@ -68,36 +91,39 @@ pub fn get_portable_flag() -> CmdResult { /// 获取应用目录 #[tauri::command] pub fn get_app_dir() -> CmdResult { - let app_home_dir = wrap_err!(dirs::app_home_dir())? + let app_home_dir = dirs::app_home_dir() + .stringify_err()? .to_string_lossy() - .to_string(); + .into(); Ok(app_home_dir) } /// 获取当前自启动状态 #[tauri::command] pub fn get_auto_launch_status() -> CmdResult { - use crate::core::sysopt::Sysopt; - wrap_err!(Sysopt::global().get_launch_status()) + Sysopt::global().get_launch_status().stringify_err() } /// 下载图标缓存 #[tauri::command] pub async fn download_icon_cache(url: String, name: String) -> CmdResult { - let icon_cache_dir = wrap_err!(dirs::app_home_dir())?.join("icons").join("cache"); - let icon_path = icon_cache_dir.join(&name); + let icon_cache_dir = dirs::app_home_dir() + .stringify_err()? + .join("icons") + .join("cache"); + let icon_path = icon_cache_dir.join(name.as_str()); if icon_path.exists() { - return Ok(icon_path.to_string_lossy().to_string()); + return Ok(icon_path.to_string_lossy().into()); } if !icon_cache_dir.exists() { - let _ = std::fs::create_dir_all(&icon_cache_dir); + let _ = fs::create_dir_all(&icon_cache_dir).await; } - let temp_path = icon_cache_dir.join(format!("{}.downloading", &name)); + let temp_path = icon_cache_dir.join(format!("{}.downloading", name.as_str())); - let response = wrap_err!(reqwest::get(&url).await)?; + let response = reqwest::get(url.as_str()).await.stringify_err()?; let content_type = response .headers() @@ -107,7 +133,7 @@ pub async fn download_icon_cache(url: String, name: String) -> CmdResult let is_image = content_type.starts_with("image/"); - let content = wrap_err!(response.bytes().await)?; + let content = response.bytes().await.stringify_err()?; let is_html = content.len() > 15 && (content.starts_with(b" CmdResult if is_image && !is_html { { - let mut file = match std::fs::File::create(&temp_path) { + let mut file = match fs::File::create(&temp_path).await { Ok(file) => file, Err(_) => { if icon_path.exists() { - return Ok(icon_path.to_string_lossy().to_string()); + return Ok(icon_path.to_string_lossy().into()); } return Err("Failed to create temporary file".into()); } }; - - wrap_err!(std::io::copy(&mut content.as_ref(), &mut file))?; + file.write_all(content.as_ref()).await.stringify_err()?; + file.flush().await.stringify_err()?; } if !icon_path.exists() { - match std::fs::rename(&temp_path, &icon_path) { + match fs::rename(&temp_path, &icon_path).await { Ok(_) => {} Err(_) => { - let _ = std::fs::remove_file(&temp_path); + let _ = temp_path.remove_if_exists().await; if icon_path.exists() { - return Ok(icon_path.to_string_lossy().to_string()); + return Ok(icon_path.to_string_lossy().into()); } } } } else { - let _ = std::fs::remove_file(&temp_path); + let _ = temp_path.remove_if_exists().await; } - Ok(icon_path.to_string_lossy().to_string()) + Ok(icon_path.to_string_lossy().into()) } else { - let _ = std::fs::remove_file(&temp_path); - Err(format!("下载的内容不是有效图片: {url}")) + let _ = temp_path.remove_if_exists().await; + Err(format!("下载的内容不是有效图片: {}", url.as_str()).into()) } } @@ -159,34 +185,43 @@ pub struct IconInfo { /// 复制图标文件 #[tauri::command] -pub fn copy_icon_file(path: String, icon_info: IconInfo) -> CmdResult { - use std::{fs, path::Path}; +pub async fn copy_icon_file(path: String, icon_info: IconInfo) -> CmdResult { + let file_path = Path::new(path.as_str()); - let file_path = Path::new(&path); - - let icon_dir = wrap_err!(dirs::app_home_dir())?.join("icons"); + let icon_dir = dirs::app_home_dir().stringify_err()?.join("icons"); if !icon_dir.exists() { - let _ = fs::create_dir_all(&icon_dir); + let _ = fs::create_dir_all(&icon_dir).await; } - let ext = match file_path.extension() { - Some(e) => e.to_string_lossy().to_string(), - None => "ico".to_string(), + let ext: String = match file_path.extension() { + Some(e) => e.to_string_lossy().into(), + None => "ico".into(), }; let dest_path = icon_dir.join(format!( "{0}-{1}.{ext}", - icon_info.name, icon_info.current_t + icon_info.name.as_str(), + icon_info.current_t.as_str() )); if file_path.exists() { if icon_info.previous_t.trim() != "" { - fs::remove_file( - icon_dir.join(format!("{0}-{1}.png", icon_info.name, icon_info.previous_t)), - ) - .unwrap_or_default(); - fs::remove_file( - icon_dir.join(format!("{0}-{1}.ico", icon_info.name, icon_info.previous_t)), - ) - .unwrap_or_default(); + icon_dir + .join(format!( + "{0}-{1}.png", + icon_info.name.as_str(), + icon_info.previous_t.as_str() + )) + .remove_if_exists() + .await + .unwrap_or_default(); + icon_dir + .join(format!( + "{0}-{1}.ico", + icon_info.name.as_str(), + icon_info.previous_t.as_str() + )) + .remove_if_exists() + .await + .unwrap_or_default(); } logging!( info, @@ -195,42 +230,27 @@ pub fn copy_icon_file(path: String, icon_info: IconInfo) -> CmdResult { path, dest_path ); - match fs::copy(file_path, &dest_path) { - Ok(_) => Ok(dest_path.to_string_lossy().to_string()), - Err(err) => Err(err.to_string()), + match fs::copy(file_path, &dest_path).await { + Ok(_) => Ok(dest_path.to_string_lossy().into()), + Err(err) => Err(err.to_string().into()), } } else { - Err("file not found".to_string()) + Err("file not found".into()) } } /// 通知UI已准备就绪 #[tauri::command] pub fn notify_ui_ready() -> CmdResult<()> { - log::info!(target: "app", "前端UI已准备就绪"); - crate::utils::resolve::ui::mark_ui_ready(); + logging!(info, Type::Cmd, "前端UI已准备就绪"); + ui::mark_ui_ready(); Ok(()) } /// UI加载阶段 #[tauri::command] -pub fn update_ui_stage(stage: String) -> CmdResult<()> { - log::info!(target: "app", "UI加载阶段更新: {stage}"); - - use crate::utils::resolve::ui::UiReadyStage; - - let stage_enum = match stage.as_str() { - "NotStarted" => UiReadyStage::NotStarted, - "Loading" => UiReadyStage::Loading, - "DomReady" => UiReadyStage::DomReady, - "ResourcesLoaded" => UiReadyStage::ResourcesLoaded, - "Ready" => UiReadyStage::Ready, - _ => { - log::warn!(target: "app", "未知的UI加载阶段: {stage}"); - return Err(format!("未知的UI加载阶段: {stage}")); - } - }; - - crate::utils::resolve::ui::update_ui_ready_stage(stage_enum); +pub fn update_ui_stage(stage: UiReadyStage) -> CmdResult<()> { + logging!(info, Type::Cmd, "UI加载阶段更新: {:?}", &stage); + ui::update_ui_ready_stage(stage); Ok(()) } diff --git a/clash-verge-rev/src-tauri/src/cmd/backup.rs b/clash-verge-rev/src-tauri/src/cmd/backup.rs new file mode 100644 index 0000000000..238e81bdcc --- /dev/null +++ b/clash-verge-rev/src-tauri/src/cmd/backup.rs @@ -0,0 +1,36 @@ +use super::CmdResult; +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).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/clash-verge-rev/src-tauri/src/cmd/clash.rs b/clash-verge-rev/src-tauri/src/cmd/clash.rs index 9fdb4c4475..ab37a4b3d7 100644 --- a/clash-verge-rev/src-tauri/src/cmd/clash.rs +++ b/clash-verge-rev/src-tauri/src/cmd/clash.rs @@ -1,15 +1,16 @@ -use std::collections::VecDeque; - use super::CmdResult; +use crate::utils::dirs; use crate::{ - config::Config, - core::{self, CoreManager, RunningMode, handle, logger}, + cmd::StringifyErr as _, + config::{ClashInfo, Config}, + constants, + core::{CoreManager, handle, validate::CoreConfigValidator}, }; -use crate::{config::*, feat, logging, utils::logging::Type, wrap_err}; +use crate::{feat, logging, utils::logging::Type}; +use compact_str::CompactString; use serde_yaml_ng::Mapping; -// use std::time::Duration; - -// const CONFIG_REFRESH_INTERVAL: Duration = Duration::from_secs(60); +use smartstring::alias::String; +use tokio::fs; /// 复制Clash环境变量 #[tauri::command] @@ -21,13 +22,13 @@ pub async fn copy_clash_env() -> CmdResult { /// 获取Clash信息 #[tauri::command] pub async fn get_clash_info() -> CmdResult { - Ok(Config::clash().await.latest_ref().get_client_info()) + Ok(Config::clash().await.data_arc().get_client_info()) } /// 修改Clash配置 #[tauri::command] pub async fn patch_clash_config(payload: Mapping) -> CmdResult { - wrap_err!(feat::patch_clash(payload).await) + feat::patch_clash(payload).await.stringify_err() } /// 修改Clash模式 @@ -42,10 +43,7 @@ pub async fn patch_clash_mode(payload: String) -> CmdResult { pub async fn change_clash_core(clash_core: String) -> CmdResult> { logging!(info, Type::Config, "changing core to {clash_core}"); - match CoreManager::global() - .change_core(Some(clash_core.clone())) - .await - { + match CoreManager::global().change_core(&clash_core).await { Ok(_) => { // 切换内核后重启内核 match CoreManager::global().restart_core().await { @@ -55,22 +53,23 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult> Type::Core, "core changed and restarted to {clash_core}" ); - handle::Handle::notice_message("config_core::change_success", &clash_core); + handle::Handle::notice_message("config_core::change_success", clash_core); handle::Handle::refresh_clash(); Ok(None) } Err(err) => { - let error_msg = format!("Core changed but failed to restart: {err}"); + let error_msg: String = + format!("Core changed but failed to restart: {err}").into(); + handle::Handle::notice_message("config_core::change_error", error_msg.clone()); logging!(error, Type::Core, "{error_msg}"); - handle::Handle::notice_message("config_core::change_error", &error_msg); Ok(Some(error_msg)) } } } Err(err) => { - let error_msg = err.to_string(); + let error_msg: String = err; logging!(error, Type::Core, "failed to change core: {error_msg}"); - handle::Handle::notice_message("config_core::change_error", &error_msg); + handle::Handle::notice_message("config_core::change_error", error_msg.clone()); Ok(Some(error_msg)) } } @@ -79,7 +78,7 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult> /// 启动核心 #[tauri::command] pub async fn start_core() -> CmdResult { - let result = wrap_err!(CoreManager::global().start_core().await); + let result = CoreManager::global().start_core().await.stringify_err(); if result.is_ok() { handle::Handle::refresh_clash(); } @@ -89,7 +88,7 @@ pub async fn start_core() -> CmdResult { /// 关闭核心 #[tauri::command] pub async fn stop_core() -> CmdResult { - let result = wrap_err!(CoreManager::global().stop_core().await); + let result = CoreManager::global().stop_core().await.stringify_err(); if result.is_ok() { handle::Handle::refresh_clash(); } @@ -99,7 +98,7 @@ pub async fn stop_core() -> CmdResult { /// 重启核心 #[tauri::command] pub async fn restart_core() -> CmdResult { - let result = wrap_err!(CoreManager::global().restart_core().await); + let result = CoreManager::global().restart_core().await.stringify_err(); if result.is_ok() { handle::Handle::refresh_clash(); } @@ -112,7 +111,7 @@ pub async fn test_delay(url: String) -> CmdResult { let result = match feat::test_delay(url).await { Ok(delay) => delay, Err(e) => { - log::error!(target: "app", "{}", e); + logging!(error, Type::Cmd, "{}", e); 10000u32 } }; @@ -128,14 +127,12 @@ pub async fn save_dns_config(dns_config: Mapping) -> CmdResult { // 获取DNS配置文件路径 let dns_path = dirs::app_home_dir() - .map_err(|e| e.to_string())? - .join("dns_config.yaml"); + .stringify_err()? + .join(constants::files::DNS_CONFIG); // 保存DNS配置到文件 - let yaml_str = serde_yaml_ng::to_string(&dns_config).map_err(|e| e.to_string())?; - fs::write(&dns_path, yaml_str) - .await - .map_err(|e| e.to_string())?; + let yaml_str = serde_yaml_ng::to_string(&dns_config).stringify_err()?; + fs::write(&dns_path, yaml_str).await.stringify_err()?; logging!(info, Type::Config, "DNS config saved to {dns_path:?}"); Ok(()) @@ -144,33 +141,25 @@ pub async fn save_dns_config(dns_config: Mapping) -> CmdResult { /// 应用或撤销DNS配置 #[tauri::command] pub async fn apply_dns_config(apply: bool) -> CmdResult { - use crate::{ - config::Config, - core::{CoreManager, handle}, - utils::dirs, - }; - if apply { // 读取DNS配置文件 let dns_path = dirs::app_home_dir() - .map_err(|e| e.to_string())? - .join("dns_config.yaml"); + .stringify_err()? + .join(constants::files::DNS_CONFIG); if !dns_path.exists() { logging!(warn, Type::Config, "DNS config file not found"); return Err("DNS config file not found".into()); } - let dns_yaml = tokio::fs::read_to_string(&dns_path).await.map_err(|e| { + let dns_yaml = fs::read_to_string(&dns_path).await.stringify_err_log(|e| { logging!(error, Type::Config, "Failed to read DNS config: {e}"); - e.to_string() })?; // 解析DNS配置 - let patch_config = - serde_yaml_ng::from_str::(&dns_yaml).map_err(|e| { + let patch_config = serde_yaml_ng::from_str::(&dns_yaml) + .stringify_err_log(|e| { logging!(error, Type::Config, "Failed to parse DNS config: {e}"); - e.to_string() })?; logging!(info, Type::Config, "Applying DNS config from file"); @@ -180,30 +169,26 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult { patch.insert("dns".into(), patch_config.into()); // 应用DNS配置到运行时配置 - Config::runtime().await.draft_mut().patch_config(patch); + Config::runtime().await.edit_draft(|d| { + d.patch_config(patch); + }); // 重新生成配置 - Config::generate().await.map_err(|err| { - logging!( - error, - Type::Config, - "Failed to regenerate config with DNS: {err}" - ); - "Failed to regenerate config with DNS".to_string() + Config::generate().await.stringify_err_log(|err| { + let err = format!("Failed to regenerate config with DNS: {err}"); + logging!(error, Type::Config, "{err}"); })?; // 应用新配置 - CoreManager::global().update_config().await.map_err(|err| { - logging!( - error, - Type::Config, - "Failed to apply config with DNS: {err}" - ); - "Failed to apply config with DNS".to_string() - })?; + CoreManager::global() + .update_config() + .await + .stringify_err_log(|err| { + let err = format!("Failed to apply config with DNS: {err}"); + logging!(error, Type::Config, "{err}"); + })?; logging!(info, Type::Config, "DNS config successfully applied"); - handle::Handle::refresh_clash(); } else { // 当关闭DNS设置时,重新生成配置(不加载DNS配置文件) logging!( @@ -212,24 +197,23 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult { "DNS settings disabled, regenerating config" ); - Config::generate().await.map_err(|err| { - logging!(error, Type::Config, "Failed to regenerate config: {err}"); - "Failed to regenerate config".to_string() + Config::generate().await.stringify_err_log(|err| { + let err = format!("Failed to regenerate config: {err}"); + logging!(error, Type::Config, "{err}"); })?; - CoreManager::global().update_config().await.map_err(|err| { - logging!( - error, - Type::Config, - "Failed to apply regenerated config: {err}" - ); - "Failed to apply regenerated config".to_string() - })?; + CoreManager::global() + .update_config() + .await + .stringify_err_log(|err| { + let err = format!("Failed to apply regenerated config: {err}"); + logging!(error, Type::Config, "{err}"); + })?; logging!(info, Type::Config, "Config regenerated successfully"); - handle::Handle::refresh_clash(); } + handle::Handle::refresh_clash(); Ok(()) } @@ -239,8 +223,8 @@ pub fn check_dns_config_exists() -> CmdResult { use crate::utils::dirs; let dns_path = dirs::app_home_dir() - .map_err(|e| e.to_string())? - .join("dns_config.yaml"); + .stringify_err()? + .join(constants::files::DNS_CONFIG); Ok(dns_path.exists()) } @@ -252,48 +236,38 @@ pub async fn get_dns_config_content() -> CmdResult { use tokio::fs; let dns_path = dirs::app_home_dir() - .map_err(|e| e.to_string())? - .join("dns_config.yaml"); + .stringify_err()? + .join(constants::files::DNS_CONFIG); - if !fs::try_exists(&dns_path).await.map_err(|e| e.to_string())? { + if !fs::try_exists(&dns_path).await.stringify_err()? { return Err("DNS config file not found".into()); } - let content = fs::read_to_string(&dns_path) - .await - .map_err(|e| e.to_string())?; + let content = fs::read_to_string(&dns_path).await.stringify_err()?.into(); Ok(content) } /// 验证DNS配置文件 #[tauri::command] pub async fn validate_dns_config() -> CmdResult<(bool, String)> { - use crate::{core::CoreManager, utils::dirs}; - - let app_dir = dirs::app_home_dir().map_err(|e| e.to_string())?; - let dns_path = app_dir.join("dns_config.yaml"); + let app_dir = dirs::app_home_dir().stringify_err()?; + let dns_path = app_dir.join(constants::files::DNS_CONFIG); let dns_path_str = dns_path.to_str().unwrap_or_default(); if !dns_path.exists() { - return Ok((false, "DNS config file not found".to_string())); + return Ok((false, "DNS config file not found".into())); } - match CoreManager::global() - .validate_config_file(dns_path_str, None) + CoreConfigValidator::validate_config_file(dns_path_str, None) .await - { - Ok(result) => Ok(result), - Err(e) => Err(e.to_string()), - } + .stringify_err() } #[tauri::command] -pub async fn get_clash_logs() -> CmdResult> { - let logs = match core::CoreManager::global().get_running_mode() { - // TODO: 服务模式下日志获取接口 - RunningMode::Service => VecDeque::new(), - RunningMode::Sidecar => logger::Logger::global().get_logs().clone(), - _ => VecDeque::new(), - }; +pub async fn get_clash_logs() -> CmdResult> { + let logs = CoreManager::global() + .get_clash_logs() + .await + .unwrap_or_default(); Ok(logs) } diff --git a/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker.rs b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker.rs deleted file mode 100644 index 9bccc46bc5..0000000000 --- a/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker.rs +++ /dev/null @@ -1,1576 +0,0 @@ -use crate::{logging, utils::logging::Type}; -use chrono::Local; -use regex::Regex; -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, sync::Arc}; -use tauri::command; -use tokio::{sync::Mutex, task::JoinSet}; - -// 定义解锁测试项目的结构 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UnlockItem { - name: String, - status: String, - region: Option, - check_time: Option, -} - -// 获取当前本地时间字符串 -fn get_local_date_string() -> String { - let now = Local::now(); - now.format("%Y-%m-%d %H:%M:%S").to_string() -} - -// 将国家代码转换为对应的emoji -fn country_code_to_emoji(country_code: &str) -> String { - // 转换为大写 - let country_code = country_code.to_uppercase(); - - // 确保使用国家代码的前两个字符来生成emoji - if country_code.len() < 2 { - return String::new(); - } - - // 使用前两个字符生成emoji - let bytes = country_code.as_bytes(); - let c1 = 0x1F1E6 + (bytes[0] as u32) - ('A' as u32); - let c2 = 0x1F1E6 + (bytes[1] as u32) - ('A' as u32); - - char::from_u32(c1) - .and_then(|c1| char::from_u32(c2).map(|c2| format!("{c1}{c2}"))) - .unwrap_or_default() -} - -// 测试哔哩哔哩中国大陆 -async fn check_bilibili_china_mainland(client: &Client) -> UnlockItem { - let url = "https://api.bilibili.com/pgc/player/web/playurl?avid=82846771&qn=0&type=&otype=json&ep_id=307247&fourk=1&fnver=0&fnval=16&module=bangumi"; - - let result = client.get(url).send().await; - - match result { - Ok(response) => match response.json::().await { - Ok(body) => { - if let Some(code) = body.get("code").and_then(|v| v.as_i64()) { - let status = if code == 0 { - "Yes" - } else if code == -10403 { - "No" - } else { - "Failed" - }; - - UnlockItem { - name: "哔哩哔哩大陆".to_string(), - status: status.to_string(), - region: None, - check_time: Some(get_local_date_string()), - } - } else { - UnlockItem { - name: "哔哩哔哩大陆".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - } - } - } - Err(_) => UnlockItem { - name: "哔哩哔哩大陆".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }, - }, - Err(_) => UnlockItem { - name: "哔哩哔哩大陆".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }, - } -} - -// 测试哔哩哔哩港澳台 -async fn check_bilibili_hk_mc_tw(client: &Client) -> UnlockItem { - let url = "https://api.bilibili.com/pgc/player/web/playurl?avid=18281381&cid=29892777&qn=0&type=&otype=json&ep_id=183799&fourk=1&fnver=0&fnval=16&module=bangumi"; - - let result = client.get(url).send().await; - - match result { - Ok(response) => match response.json::().await { - Ok(body) => { - if let Some(code) = body.get("code").and_then(|v| v.as_i64()) { - let status = if code == 0 { - "Yes" - } else if code == -10403 { - "No" - } else { - "Failed" - }; - - UnlockItem { - name: "哔哩哔哩港澳台".to_string(), - status: status.to_string(), - region: None, - check_time: Some(get_local_date_string()), - } - } else { - UnlockItem { - name: "哔哩哔哩港澳台".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - } - } - } - Err(_) => UnlockItem { - name: "哔哩哔哩港澳台".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }, - }, - Err(_) => UnlockItem { - name: "哔哩哔哩港澳台".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }, - } -} - -// 合并的ChatGPT检测功能,包含iOS和Web测试以及国家代码获取 -async fn check_chatgpt_combined(client: &Client) -> Vec { - // 结果集 - let mut results = Vec::new(); - - // 1. 获取国家代码 - let url_country = "https://chat.openai.com/cdn-cgi/trace"; - let result_country = client.get(url_country).send().await; - - // 解析区域信息 - let region = match result_country { - Ok(response) => { - if let Ok(body) = response.text().await { - let mut map = HashMap::new(); - for line in body.lines() { - if let Some(index) = line.find('=') { - let key = &line[0..index]; - let value = &line[index + 1..]; - map.insert(key.to_string(), value.to_string()); - } - } - - map.get("loc").map(|loc| { - let emoji = country_code_to_emoji(loc); - format!("{emoji}{loc}") - }) - } else { - None - } - } - Err(_) => None, - }; - - // 2. 测试 ChatGPT iOS - let url_ios = "https://ios.chat.openai.com/"; - let result_ios = client.get(url_ios).send().await; - - // 解析iOS测试结果 - let ios_status = match result_ios { - Ok(response) => { - if let Ok(body) = response.text().await { - let body_lower = body.to_lowercase(); - if body_lower.contains("you may be connected to a disallowed isp") { - "Disallowed ISP" - } else if body_lower.contains("request is not allowed. please try again later.") { - "Yes" - } else if body_lower.contains("sorry, you have been blocked") { - "Blocked" - } else { - "Failed" - } - } else { - "Failed" - } - } - Err(_) => "Failed", - }; - - // 3. 测试 ChatGPT Web - let url_web = "https://api.openai.com/compliance/cookie_requirements"; - let result_web = client.get(url_web).send().await; - - // 解析Web测试结果 - let web_status = match result_web { - Ok(response) => { - if let Ok(body) = response.text().await { - let body_lower = body.to_lowercase(); - if body_lower.contains("unsupported_country") { - "Unsupported Country/Region" - } else { - "Yes" - } - } else { - "Failed" - } - } - Err(_) => "Failed", - }; - - // 添加iOS测试结果 - results.push(UnlockItem { - name: "ChatGPT iOS".to_string(), - status: ios_status.to_string(), - region: region.clone(), - check_time: Some(get_local_date_string()), - }); - - // 添加Web测试结果 - results.push(UnlockItem { - name: "ChatGPT Web".to_string(), - status: web_status.to_string(), - region, - check_time: Some(get_local_date_string()), - }); - - results -} - -// 测试Gemini -async fn check_gemini(client: &Client) -> UnlockItem { - let url = "https://gemini.google.com"; - - let result = client.get(url).send().await; - - match result { - Ok(response) => { - if let Ok(body) = response.text().await { - let is_ok = body.contains("45631641,null,true"); - let status = if is_ok { "Yes" } else { "No" }; - - // 尝试提取国家代码 - let re = match Regex::new(r#",2,1,200,"([A-Z]{3})""#) { - Ok(re) => re, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile Gemini regex: {}", - e - ); - return UnlockItem { - name: "Gemini".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - }; - let region = re.captures(&body).and_then(|caps| { - caps.get(1).map(|m| { - let country_code = m.as_str(); - let emoji = country_code_to_emoji(country_code); - format!("{emoji}{country_code}") - }) - }); - - UnlockItem { - name: "Gemini".to_string(), - status: status.to_string(), - region, - check_time: Some(get_local_date_string()), - } - } else { - UnlockItem { - name: "Gemini".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - } - } - } - Err(_) => UnlockItem { - name: "Gemini".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }, - } -} - -// 测试 YouTube Premium -async fn check_youtube_premium(client: &Client) -> UnlockItem { - let url = "https://www.youtube.com/premium"; - - let result = client.get(url).send().await; - - match result { - Ok(response) => { - if let Ok(body) = response.text().await { - let body_lower = body.to_lowercase(); - - if body_lower.contains("youtube premium is not available in your country") { - UnlockItem { - name: "Youtube Premium".to_string(), - status: "No".to_string(), - region: None, - check_time: Some(get_local_date_string()), - } - } else if body_lower.contains("ad-free") { - // 尝试解析国家代码 - let re = match Regex::new(r#"id="country-code"[^>]*>([^<]+)<"#) { - Ok(re) => re, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile YouTube Premium regex: {}", - e - ); - return UnlockItem { - name: "Youtube Premium".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - }; - let region = re.captures(&body).and_then(|caps| { - caps.get(1).map(|m| { - let country_code = m.as_str().trim(); - let emoji = country_code_to_emoji(country_code); - format!("{emoji}{country_code}") - }) - }); - - UnlockItem { - name: "Youtube Premium".to_string(), - status: "Yes".to_string(), - region, - check_time: Some(get_local_date_string()), - } - } else { - UnlockItem { - name: "Youtube Premium".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - } - } - } else { - UnlockItem { - name: "Youtube Premium".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - } - } - } - Err(_) => UnlockItem { - name: "Youtube Premium".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }, - } -} - -// 测试动画疯(Bahamut Anime) -async fn check_bahamut_anime(client: &Client) -> UnlockItem { - // 创建带Cookie存储的客户端 - let cookie_store = Arc::new(reqwest::cookie::Jar::default()); - - // 使用带Cookie的客户端 - let client_with_cookies = match reqwest::Client::builder() - .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36") - .cookie_provider(Arc::clone(&cookie_store)) - .build() { - Ok(client) => client, - Err(e) => { - logging!(error, Type::Network, "Failed to create client with cookies for Bahamut Anime: {}", e); - client.clone() - } - }; - - // 第一步:获取设备ID (会自动保存Cookie) - let device_url = "https://ani.gamer.com.tw/ajax/getdeviceid.php"; - let device_id = match client_with_cookies.get(device_url).send().await { - Ok(response) => { - match response.text().await { - Ok(text) => { - // 使用正则提取deviceid - match Regex::new(r#""deviceid"\s*:\s*"([^"]+)"#) { - Ok(re) => re - .captures(&text) - .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())) - .unwrap_or_default(), - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile deviceid regex for Bahamut Anime: {}", - e - ); - String::new() - } - } - } - Err(_) => String::new(), - } - } - Err(_) => String::new(), - }; - - if device_id.is_empty() { - return UnlockItem { - name: "Bahamut Anime".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - // 第二步:使用设备ID检查访问权限 (使用相同的Cookie) - let url = - format!("https://ani.gamer.com.tw/ajax/token.php?adID=89422&sn=37783&device={device_id}"); - - let token_result = match client_with_cookies.get(&url).send().await { - Ok(response) => { - match response.text().await { - Ok(body) => { - // 检查内容是否可访问 - 更精确地匹配animeSn - if body.contains("animeSn") { - Some(body) - } else { - None - } - } - Err(_) => None, - } - } - Err(_) => None, - }; - - // 如果无法获取token或不包含animeSn,表示不支持 - if token_result.is_none() { - return UnlockItem { - name: "Bahamut Anime".to_string(), - status: "No".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - // 第三步:访问主页获取区域信息 (使用相同的Cookie) - let region = match client_with_cookies - .get("https://ani.gamer.com.tw/") - .send() - .await - { - Ok(response) => match response.text().await { - Ok(body) => match Regex::new(r#"data-geo="([^"]+)"#) { - Ok(region_re) => region_re - .captures(&body) - .and_then(|caps| caps.get(1)) - .map(|m| { - let country_code = m.as_str(); - let emoji = country_code_to_emoji(country_code); - format!("{emoji}{country_code}") - }), - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile region regex for Bahamut Anime: {}", - e - ); - None - } - }, - Err(_) => None, - }, - Err(_) => None, - }; - - // 解锁成功 - UnlockItem { - name: "Bahamut Anime".to_string(), - status: "Yes".to_string(), - region, - check_time: Some(get_local_date_string()), - } -} - -// 测试 Netflix -async fn check_netflix(client: &Client) -> UnlockItem { - // 首先尝试使用Fast.com API检测Netflix CDN区域 - let cdn_result = check_netflix_cdn(client).await; - if cdn_result.status == "Yes" { - return cdn_result; - } - - // 如果CDN方法失败,尝试传统的内容检测方法 - // 测试两个 Netflix 内容 (LEGO Ninjago 和 Breaking Bad) - let url1 = "https://www.netflix.com/title/81280792"; // LEGO Ninjago - let url2 = "https://www.netflix.com/title/70143836"; // Breaking Bad - - // 创建简单的请求(不添加太多头部信息) - let result1 = client - .get(url1) - .timeout(std::time::Duration::from_secs(30)) - .send() - .await; - - // 检查连接失败情况 - if let Err(e) = &result1 { - eprintln!("Netflix请求错误: {e}"); - return UnlockItem { - name: "Netflix".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - // 如果第一个请求成功,尝试第二个请求 - let result2 = client - .get(url2) - .timeout(std::time::Duration::from_secs(30)) - .send() - .await; - - if let Err(e) = &result2 { - eprintln!("Netflix请求错误: {e}"); - return UnlockItem { - name: "Netflix".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - // 获取状态码 - let status1 = match result1 { - Ok(response) => response.status().as_u16(), - Err(e) => { - logging!( - error, - Type::Network, - "Failed to get Netflix response 1: {}", - e - ); - return UnlockItem { - name: "Netflix".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - }; - let status2 = match result2 { - Ok(response) => response.status().as_u16(), - Err(e) => { - logging!( - error, - Type::Network, - "Failed to get Netflix response 2: {}", - e - ); - return UnlockItem { - name: "Netflix".to_string(), - status: "Failed".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - }; - - // 根据状态码判断解锁状况 - if status1 == 404 && status2 == 404 { - return UnlockItem { - name: "Netflix".to_string(), - status: "Originals Only".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - if status1 == 403 || status2 == 403 { - return UnlockItem { - name: "Netflix".to_string(), - status: "No".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - if status1 == 200 || status1 == 301 || status2 == 200 || status2 == 301 { - // 成功解锁,尝试获取地区信息 - // 使用Netflix测试内容获取区域 - let test_url = "https://www.netflix.com/title/80018499"; - match client - .get(test_url) - .timeout(std::time::Duration::from_secs(30)) - .send() - .await - { - Ok(response) => { - // 检查重定向位置 - if let Some(location) = response.headers().get("location") - && let Ok(location_str) = location.to_str() - { - // 解析位置获取区域 - let parts: Vec<&str> = location_str.split('/').collect(); - if parts.len() >= 4 { - let region_code = parts[3].split('-').next().unwrap_or("unknown"); - let emoji = country_code_to_emoji(region_code); - return UnlockItem { - name: "Netflix".to_string(), - status: "Yes".to_string(), - region: Some(format!("{emoji}{region_code}")), - check_time: Some(get_local_date_string()), - }; - } - } - // 如果没有重定向,假设是美国 - let emoji = country_code_to_emoji("us"); - UnlockItem { - name: "Netflix".to_string(), - status: "Yes".to_string(), - region: Some(format!("{}{}", emoji, "us")), - check_time: Some(get_local_date_string()), - } - } - Err(e) => { - eprintln!("获取Netflix区域信息失败: {e}"); - UnlockItem { - name: "Netflix".to_string(), - status: "Yes (但无法获取区域)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - } - } - } - } else { - // 其他未知错误状态 - UnlockItem { - name: "Netflix".to_string(), - status: format!("Failed (状态码: {status1}_{status2}"), - region: None, - check_time: Some(get_local_date_string()), - } - } -} - -// 使用Fast.com API检测Netflix CDN区域 -async fn check_netflix_cdn(client: &Client) -> UnlockItem { - // Fast.com API URL - let url = "https://api.fast.com/netflix/speedtest/v2?https=true&token=YXNkZmFzZGxmbnNkYWZoYXNkZmhrYWxm&urlCount=5"; - - let result = client - .get(url) - .timeout(std::time::Duration::from_secs(30)) - .send() - .await; - - match result { - Ok(response) => { - // 检查状态码 - if response.status().as_u16() == 403 { - return UnlockItem { - name: "Netflix".to_string(), - status: "No (IP Banned By Netflix)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - // 尝试解析响应 - match response.json::().await { - Ok(data) => { - // 尝试从数据中提取区域信息 - if let Some(targets) = data.get("targets").and_then(|t| t.as_array()) - && !targets.is_empty() - && let Some(location) = targets[0].get("location") - && let Some(country) = location.get("country").and_then(|c| c.as_str()) - { - let emoji = country_code_to_emoji(country); - return UnlockItem { - name: "Netflix".to_string(), - status: "Yes".to_string(), - region: Some(format!("{emoji}{country}")), - check_time: Some(get_local_date_string()), - }; - } - - // 如果无法解析区域信息 - UnlockItem { - name: "Netflix".to_string(), - status: "Unknown".to_string(), - region: None, - check_time: Some(get_local_date_string()), - } - } - Err(e) => { - eprintln!("解析Fast.com API响应失败: {e}"); - UnlockItem { - name: "Netflix".to_string(), - status: "Failed (解析错误)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - } - } - } - } - Err(e) => { - eprintln!("Fast.com API请求失败: {e}"); - UnlockItem { - name: "Netflix".to_string(), - status: "Failed (CDN API)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - } - } - } -} - -// 测试 Disney+ -async fn check_disney_plus(client: &Client) -> UnlockItem { - // Disney+ 不支持 IPv6,但这里不做额外检查,因为我们使用的是系统默认网络 - - // 第一步:获取 assertion - let device_api_url = "https://disney.api.edge.bamgrid.com/devices"; - let auth_header = - "Bearer ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84"; - - let device_req_body = serde_json::json!({ - "deviceFamily": "browser", - "applicationRuntime": "chrome", - "deviceProfile": "windows", - "attributes": {} - }); - - let device_result = client - .post(device_api_url) - .header("authorization", auth_header) - .header("content-type", "application/json; charset=UTF-8") - .json(&device_req_body) - .send() - .await; - - // 检查网络连接 - if device_result.is_err() { - return UnlockItem { - name: "Disney+".to_string(), - status: "Failed (Network Connection)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - let device_response = match device_result { - Ok(response) => response, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to get Disney+ device response: {}", - e - ); - return UnlockItem { - name: "Disney+".to_string(), - status: "Failed (Network Connection)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - }; - - // 检查是否 403 错误 - if device_response.status().as_u16() == 403 { - return UnlockItem { - name: "Disney+".to_string(), - status: "No (IP Banned By Disney+)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - let device_body = match device_response.text().await { - Ok(body) => body, - Err(_) => { - return UnlockItem { - name: "Disney+".to_string(), - status: "Failed (Error: Cannot read response)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - }; - - // 提取 assertion - let re = match Regex::new(r#""assertion"\s*:\s*"([^"]+)"#) { - Ok(re) => re, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile assertion regex for Disney+: {}", - e - ); - return UnlockItem { - name: "Disney+".to_string(), - status: "Failed (Regex Error)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - }; - let assertion = match re.captures(&device_body) { - Some(caps) => caps.get(1).map(|m| m.as_str().to_string()), - None => None, - }; - - if assertion.is_none() { - return UnlockItem { - name: "Disney+".to_string(), - status: "Failed (Error: Cannot extract assertion)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - // 第二步:获取 token - let token_url = "https://disney.api.edge.bamgrid.com/token"; - - // 构建请求体 - 使用表单数据格式而非 JSON - let assertion_str = match assertion { - Some(assertion) => assertion, - None => { - logging!(error, Type::Network, "No assertion found for Disney+"); - return UnlockItem { - name: "Disney+".to_string(), - status: "Failed (No Assertion)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - }; - let token_body = [ - ( - "grant_type", - "urn:ietf:params:oauth:grant-type:token-exchange", - ), - ("latitude", "0"), - ("longitude", "0"), - ("platform", "browser"), - ("subject_token", assertion_str.as_str()), - ( - "subject_token_type", - "urn:bamtech:params:oauth:token-type:device", - ), - ]; - - let token_result = client - .post(token_url) - .header("authorization", auth_header) - .header("content-type", "application/x-www-form-urlencoded") - .form(&token_body) // 使用 form 而不是 json - .send() - .await; - - if token_result.is_err() { - return UnlockItem { - name: "Disney+".to_string(), - status: "Failed (Network Connection)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - let token_response = match token_result { - Ok(response) => response, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to get Disney+ token response: {}", - e - ); - return UnlockItem { - name: "Disney+".to_string(), - status: "Failed (Network Connection)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - }; - let token_status = token_response.status(); - - // 保存原始响应用于调试 - let token_body_text = match token_response.text().await { - Ok(body) => body, - Err(_) => { - return UnlockItem { - name: "Disney+".to_string(), - status: "Failed (Error: Cannot read token response)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - }; - - // 检查是否被禁止的地区 - if token_body_text.contains("forbidden-location") || token_body_text.contains("403 ERROR") { - return UnlockItem { - name: "Disney+".to_string(), - status: "No (IP Banned By Disney+)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - // 尝试解析 JSON - let token_json: Result = serde_json::from_str(&token_body_text); - - let refresh_token = match token_json { - Ok(json) => json - .get("refresh_token") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - Err(_) => { - // 如果 JSON 解析失败,尝试使用正则表达式 - match Regex::new(r#""refresh_token"\s*:\s*"([^"]+)"#) { - Ok(refresh_token_re) => refresh_token_re - .captures(&token_body_text) - .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())), - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile refresh_token regex for Disney+: {}", - e - ); - None - } - } - } - }; - - // 如果仍然无法获取 refresh token - if refresh_token.is_none() { - return UnlockItem { - name: "Disney+".to_string(), - status: format!( - "Failed (Error: Cannot extract refresh token, status: {}, response: {})", - token_status.as_u16(), - token_body_text.chars().take(100).collect::() + "..." - ), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - // 第三步:使用 GraphQL 获取地区信息 - let graphql_url = "https://disney.api.edge.bamgrid.com/graph/v1/device/graphql"; - - // GraphQL API 通常接受 JSON 格式 - let graphql_payload = format!( - r#"{{"query":"mutation refreshToken($input: RefreshTokenInput!) {{ refreshToken(refreshToken: $input) {{ activeSession {{ sessionId }} }} }}","variables":{{"input":{{"refreshToken":"{}"}}}}}}"#, - refresh_token.unwrap_or_default() - ); - - let graphql_result = client - .post(graphql_url) - .header("authorization", auth_header) - .header("content-type", "application/json") - .body(graphql_payload) - .send() - .await; - - if graphql_result.is_err() { - return UnlockItem { - name: "Disney+".to_string(), - status: "Failed (Network Connection)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - // 检查 Disney+ 主页的可用性 - let preview_check = client.get("https://disneyplus.com").send().await; - - let is_unavailable = match preview_check { - Ok(response) => { - let url = response.url().to_string(); - url.contains("preview") || url.contains("unavailable") - } - Err(_) => true, - }; - - // 解析 GraphQL 响应获取区域信息 - let graphql_response = match graphql_result { - Ok(response) => response, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to get Disney+ GraphQL response: {}", - e - ); - return UnlockItem { - name: "Disney+".to_string(), - status: "Failed (Network Connection)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - }; - let graphql_status = graphql_response.status(); - let graphql_body_text = match graphql_response.text().await { - Ok(text) => text, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to read Disney+ GraphQL response text: {}", - e - ); - String::new() - } - }; - - // 如果 GraphQL 响应为空或明显错误,尝试直接获取区域信息 - if graphql_body_text.is_empty() || graphql_status.as_u16() >= 400 { - // 尝试直接从主页获取区域信息 - let region_from_main = match client.get("https://www.disneyplus.com/").send().await { - Ok(response) => match response.text().await { - Ok(body) => match Regex::new(r#"region"\s*:\s*"([^"]+)"#) { - Ok(region_re) => region_re - .captures(&body) - .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())), - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile Disney+ main page region regex: {}", - e - ); - None - } - }, - Err(_) => None, - }, - Err(_) => None, - }; - - if let Some(region) = region_from_main { - let emoji = country_code_to_emoji(®ion); - return UnlockItem { - name: "Disney+".to_string(), - status: "Yes".to_string(), - region: Some(format!("{emoji}{region} (from main page)")), - check_time: Some(get_local_date_string()), - }; - } - - // 如果主页也无法获取区域信息,返回详细错误 - if graphql_body_text.is_empty() { - return UnlockItem { - name: "Disney+".to_string(), - status: format!( - "Failed (GraphQL error: empty response, status: {})", - graphql_status.as_u16() - ), - region: None, - check_time: Some(get_local_date_string()), - }; - } - return UnlockItem { - name: "Disney+".to_string(), - status: format!( - "Failed (GraphQL error: {}, status: {})", - graphql_body_text.chars().take(50).collect::() + "...", - graphql_status.as_u16() - ), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - // 提取国家代码 - let region_re = match Regex::new(r#""countryCode"\s*:\s*"([^"]+)"#) { - Ok(re) => re, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile Disney+ countryCode regex: {}", - e - ); - return UnlockItem { - name: "Disney+".to_string(), - status: "Failed (Regex Error)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - }; - let region_code = region_re - .captures(&graphql_body_text) - .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())); - - // 提取支持状态 - let supported_re = match Regex::new(r#""inSupportedLocation"\s*:\s*(false|true)"#) { - Ok(re) => re, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile Disney+ supported location regex: {}", - e - ); - return UnlockItem { - name: "Disney+".to_string(), - status: "Failed (Regex Error)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - }; - let in_supported_location = supported_re - .captures(&graphql_body_text) - .and_then(|caps| caps.get(1).map(|m| m.as_str() == "true")); - - // 判断结果 - if region_code.is_none() { - // 尝试直接从主页获取区域信息 - let region_from_main = match client.get("https://www.disneyplus.com/").send().await { - Ok(response) => match response.text().await { - Ok(body) => match Regex::new(r#"region"\s*:\s*"([^"]+)"#) { - Ok(region_re) => region_re - .captures(&body) - .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())), - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile Disney+ main page region regex: {}", - e - ); - None - } - }, - Err(_) => None, - }, - Err(_) => None, - }; - - if let Some(region) = region_from_main { - let emoji = country_code_to_emoji(®ion); - return UnlockItem { - name: "Disney+".to_string(), - status: "Yes".to_string(), - region: Some(format!("{emoji}{region} (from main page)")), - check_time: Some(get_local_date_string()), - }; - } - - return UnlockItem { - name: "Disney+".to_string(), - status: "No".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - let region = match region_code { - Some(code) => code, - None => { - logging!(error, Type::Network, "No region code found for Disney+"); - return UnlockItem { - name: "Disney+".to_string(), - status: "No".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - }; - - // 判断日本地区 - if region == "JP" { - let emoji = country_code_to_emoji("JP"); - return UnlockItem { - name: "Disney+".to_string(), - status: "Yes".to_string(), - region: Some(format!("{emoji}{region}")), - check_time: Some(get_local_date_string()), - }; - } - - // 判断不可用区域 - if is_unavailable { - return UnlockItem { - name: "Disney+".to_string(), - status: "No".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - // 判断支持状态 - match in_supported_location { - Some(false) => { - let emoji = country_code_to_emoji(®ion); - UnlockItem { - name: "Disney+".to_string(), - status: "Soon".to_string(), - region: Some(format!("{emoji}{region}(即将上线)")), - check_time: Some(get_local_date_string()), - } - } - Some(true) => { - let emoji = country_code_to_emoji(®ion); - UnlockItem { - name: "Disney+".to_string(), - status: "Yes".to_string(), - region: Some(format!("{emoji}{region}")), - check_time: Some(get_local_date_string()), - } - } - None => UnlockItem { - name: "Disney+".to_string(), - status: format!("Failed (Error: Unknown region status for {region})"), - region: None, - check_time: Some(get_local_date_string()), - }, - } -} - -// 测试 Amazon Prime Video -async fn check_prime_video(client: &Client) -> UnlockItem { - // 访问 Prime Video 主页 - let url = "https://www.primevideo.com"; - - let result = client.get(url).send().await; - - // 检查网络连接 - if result.is_err() { - return UnlockItem { - name: "Prime Video".to_string(), - status: "Failed (Network Connection)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - // 解析响应内容 - let response = match result { - Ok(response) => response, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to get Prime Video response: {}", - e - ); - return UnlockItem { - name: "Prime Video".to_string(), - status: "Failed (Network Connection)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - }; - - match response.text().await { - Ok(body) => { - // 检查是否被地区限制 - let is_blocked = body.contains("isServiceRestricted"); - - // 提取地区信息 - let region_re = match Regex::new(r#""currentTerritory":"([^"]+)"#) { - Ok(re) => re, - Err(e) => { - logging!( - error, - Type::Network, - "Failed to compile Prime Video region regex: {}", - e - ); - return UnlockItem { - name: "Prime Video".to_string(), - status: "Failed (Regex Error)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - }; - let region_code = region_re - .captures(&body) - .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())); - - // 判断结果 - if is_blocked { - return UnlockItem { - name: "Prime Video".to_string(), - status: "No (Service Not Available)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - if let Some(region) = region_code { - let emoji = country_code_to_emoji(®ion); - return UnlockItem { - name: "Prime Video".to_string(), - status: "Yes".to_string(), - region: Some(format!("{emoji}{region}")), - check_time: Some(get_local_date_string()), - }; - } - - // 页面解析错误 - if !is_blocked && region_code.is_none() { - return UnlockItem { - name: "Prime Video".to_string(), - status: "Failed (Error: PAGE ERROR)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }; - } - - // 未知错误 - UnlockItem { - name: "Prime Video".to_string(), - status: "Failed (Error: Unknown Region)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - } - } - Err(_) => UnlockItem { - name: "Prime Video".to_string(), - status: "Failed (Error: Cannot read response)".to_string(), - region: None, - check_time: Some(get_local_date_string()), - }, - } -} - -// 获取所有解锁项目的列表 -#[command] -pub async fn get_unlock_items() -> Result, String> { - let items = vec![ - UnlockItem { - name: "哔哩哔哩大陆".to_string(), - status: "Pending".to_string(), - region: None, - check_time: None, - }, - UnlockItem { - name: "哔哩哔哩港澳台".to_string(), - status: "Pending".to_string(), - region: None, - check_time: None, - }, - UnlockItem { - name: "ChatGPT iOS".to_string(), - status: "Pending".to_string(), - region: None, - check_time: None, - }, - UnlockItem { - name: "ChatGPT Web".to_string(), - status: "Pending".to_string(), - region: None, - check_time: None, - }, - UnlockItem { - name: "Gemini".to_string(), - status: "Pending".to_string(), - region: None, - check_time: None, - }, - UnlockItem { - name: "Youtube Premium".to_string(), - status: "Pending".to_string(), - region: None, - check_time: None, - }, - UnlockItem { - name: "Bahamut Anime".to_string(), - status: "Pending".to_string(), - region: None, - check_time: None, - }, - UnlockItem { - name: "Netflix".to_string(), - status: "Pending".to_string(), - region: None, - check_time: None, - }, - UnlockItem { - name: "Disney+".to_string(), - status: "Pending".to_string(), - region: None, - check_time: None, - }, - UnlockItem { - name: "Prime Video".to_string(), - status: "Pending".to_string(), - region: None, - check_time: None, - }, - ]; - - Ok(items) -} - -// 开始检测流媒体解锁状态 -#[command] -pub async fn check_media_unlock() -> Result, String> { - // 创建一个http客户端,增加更多配置 - let client = match Client::builder() - .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36") - .timeout(std::time::Duration::from_secs(30)) // 全局超时设置 - .danger_accept_invalid_certs(true) // 接受无效证书,防止SSL错误 - .danger_accept_invalid_hostnames(true) // 接受无效主机名 - .tcp_keepalive(std::time::Duration::from_secs(60)) // TCP keepalive - .connection_verbose(true) // 详细连接信息 - .build() { - Ok(client) => client, - Err(e) => return Err(format!("创建HTTP客户端失败: {e}")), - }; - - // 创建共享的结果集 - let results = Arc::new(Mutex::new(Vec::new())); - - // 创建一个任务集,用于并行处理所有检测 - let mut tasks = JoinSet::new(); - - // 共享的Client实例 - let client_arc = Arc::new(client); - - // 添加哔哩哔哩大陆检测任务 - { - let client = Arc::clone(&client_arc); - let results = Arc::clone(&results); - tasks.spawn(async move { - let result = check_bilibili_china_mainland(&client).await; - let mut results = results.lock().await; - results.push(result); - }); - } - - // 添加哔哩哔哩港澳台检测任务 - { - let client = Arc::clone(&client_arc); - let results = Arc::clone(&results); - tasks.spawn(async move { - let result = check_bilibili_hk_mc_tw(&client).await; - let mut results = results.lock().await; - results.push(result); - }); - } - - // 添加合并的ChatGPT检测任务 - { - let client = Arc::clone(&client_arc); - let results = Arc::clone(&results); - tasks.spawn(async move { - let chatgpt_results = check_chatgpt_combined(&client).await; - let mut results = results.lock().await; - results.extend(chatgpt_results); - }); - } - - // 添加Gemini检测任务 - { - let client = Arc::clone(&client_arc); - let results = Arc::clone(&results); - tasks.spawn(async move { - let result = check_gemini(&client).await; - let mut results = results.lock().await; - results.push(result); - }); - } - - // 添加YouTube Premium检测任务 - { - let client = Arc::clone(&client_arc); - let results = Arc::clone(&results); - tasks.spawn(async move { - let result = check_youtube_premium(&client).await; - let mut results = results.lock().await; - results.push(result); - }); - } - - // 添加动画疯检测任务 - { - let client = Arc::clone(&client_arc); - let results = Arc::clone(&results); - tasks.spawn(async move { - let result = check_bahamut_anime(&client).await; - let mut results = results.lock().await; - results.push(result); - }); - } - - // 添加 Netflix 检测任务 - { - let client = Arc::clone(&client_arc); - let results = Arc::clone(&results); - tasks.spawn(async move { - let result = check_netflix(&client).await; - let mut results = results.lock().await; - results.push(result); - }); - } - - // 添加 Disney+ 检测任务 - { - let client = Arc::clone(&client_arc); - let results = Arc::clone(&results); - tasks.spawn(async move { - let result = check_disney_plus(&client).await; - let mut results = results.lock().await; - results.push(result); - }); - } - - // 添加 Prime Video 检测任务 - { - let client = Arc::clone(&client_arc); - let results = Arc::clone(&results); - tasks.spawn(async move { - let result = check_prime_video(&client).await; - let mut results = results.lock().await; - results.push(result); - }); - } - - // 等待所有任务完成 - while let Some(res) = tasks.join_next().await { - if let Err(e) = res { - eprintln!("任务执行失败: {e}"); - } - } - - // 获取所有结果 - let results = match Arc::try_unwrap(results) { - Ok(mutex) => mutex.into_inner(), - Err(_) => { - logging!( - error, - Type::Network, - "Failed to unwrap results Arc, references still exist" - ); - return Err("Failed to collect results".to_string()); - } - }; - - Ok(results) -} diff --git a/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/bahamut.rs b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/bahamut.rs new file mode 100644 index 0000000000..9600117452 --- /dev/null +++ b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/bahamut.rs @@ -0,0 +1,124 @@ +use std::sync::Arc; + +use regex::Regex; +use reqwest::{Client, cookie::Jar}; + +use crate::{logging, utils::logging::Type}; + +use super::UnlockItem; +use super::utils::{country_code_to_emoji, get_local_date_string}; + +pub(super) async fn check_bahamut_anime(client: &Client) -> UnlockItem { + let cookie_store = Arc::new(Jar::default()); + + let client_with_cookies = match Client::builder() + .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36") + .cookie_provider(Arc::clone(&cookie_store)) + .build() { + Ok(client) => client, + Err(e) => { + logging!( + error, + Type::Network, + "Failed to create client with cookies for Bahamut Anime: {}", + e + ); + client.clone() + } + }; + + let device_url = "https://ani.gamer.com.tw/ajax/getdeviceid.php"; + let device_id = match client_with_cookies.get(device_url).send().await { + Ok(response) => match response.text().await { + Ok(text) => match Regex::new(r#""deviceid"\s*:\s*"([^"]+)"#) { + Ok(re) => re + .captures(&text) + .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())) + .unwrap_or_default(), + Err(e) => { + logging!( + error, + Type::Network, + "Failed to compile deviceid regex for Bahamut Anime: {}", + e + ); + String::new() + } + }, + Err(_) => String::new(), + }, + Err(_) => String::new(), + }; + + if device_id.is_empty() { + return UnlockItem { + name: "Bahamut Anime".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + let url = + format!("https://ani.gamer.com.tw/ajax/token.php?adID=89422&sn=37783&device={device_id}"); + + let token_result = match client_with_cookies.get(&url).send().await { + Ok(response) => match response.text().await { + Ok(body) => { + if body.contains("animeSn") { + Some(body) + } else { + None + } + } + Err(_) => None, + }, + Err(_) => None, + }; + + if token_result.is_none() { + return UnlockItem { + name: "Bahamut Anime".to_string(), + status: "No".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + let region = match client_with_cookies + .get("https://ani.gamer.com.tw/") + .send() + .await + { + Ok(response) => match response.text().await { + Ok(body) => match Regex::new(r#"data-geo="([^"]+)"#) { + Ok(region_re) => region_re + .captures(&body) + .and_then(|caps| caps.get(1)) + .map(|m| { + let country_code = m.as_str(); + let emoji = country_code_to_emoji(country_code); + format!("{emoji}{country_code}") + }), + Err(e) => { + logging!( + error, + Type::Network, + "Failed to compile region regex for Bahamut Anime: {}", + e + ); + None + } + }, + Err(_) => None, + }, + Err(_) => None, + }; + + UnlockItem { + name: "Bahamut Anime".to_string(), + status: "Yes".to_string(), + region, + check_time: Some(get_local_date_string()), + } +} diff --git a/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/bilibili.rs b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/bilibili.rs new file mode 100644 index 0000000000..72c8697c5a --- /dev/null +++ b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/bilibili.rs @@ -0,0 +1,91 @@ +use reqwest::Client; +use serde_json::Value; + +use super::UnlockItem; +use super::utils::get_local_date_string; + +pub(super) async fn check_bilibili_china_mainland(client: &Client) -> UnlockItem { + let url = "https://api.bilibili.com/pgc/player/web/playurl?avid=82846771&qn=0&type=&otype=json&ep_id=307247&fourk=1&fnver=0&fnval=16&module=bangumi"; + + match client.get(url).send().await { + Ok(response) => match response.json::().await { + Ok(body) => { + let status = body + .get("code") + .and_then(|v| v.as_i64()) + .map(|code| { + if code == 0 { + "Yes" + } else if code == -10403 { + "No" + } else { + "Failed" + } + }) + .unwrap_or("Failed"); + + UnlockItem { + name: "哔哩哔哩大陆".to_string(), + status: status.to_string(), + region: None, + check_time: Some(get_local_date_string()), + } + } + Err(_) => UnlockItem { + name: "哔哩哔哩大陆".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }, + }, + Err(_) => UnlockItem { + name: "哔哩哔哩大陆".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }, + } +} + +pub(super) async fn check_bilibili_hk_mc_tw(client: &Client) -> UnlockItem { + let url = "https://api.bilibili.com/pgc/player/web/playurl?avid=18281381&cid=29892777&qn=0&type=&otype=json&ep_id=183799&fourk=1&fnver=0&fnval=16&module=bangumi"; + + match client.get(url).send().await { + Ok(response) => match response.json::().await { + Ok(body) => { + let status = body + .get("code") + .and_then(|v| v.as_i64()) + .map(|code| { + if code == 0 { + "Yes" + } else if code == -10403 { + "No" + } else { + "Failed" + } + }) + .unwrap_or("Failed"); + + UnlockItem { + name: "哔哩哔哩港澳台".to_string(), + status: status.to_string(), + region: None, + check_time: Some(get_local_date_string()), + } + } + Err(_) => UnlockItem { + name: "哔哩哔哩港澳台".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }, + }, + Err(_) => UnlockItem { + name: "哔哩哔哩港澳台".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }, + } +} diff --git a/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/chatgpt.rs b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/chatgpt.rs new file mode 100644 index 0000000000..8c441a3455 --- /dev/null +++ b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/chatgpt.rs @@ -0,0 +1,94 @@ +use std::collections::HashMap; + +use reqwest::Client; + +use super::UnlockItem; +use super::utils::{country_code_to_emoji, get_local_date_string}; + +pub(super) async fn check_chatgpt_combined(client: &Client) -> Vec { + let mut results = Vec::new(); + + let url_country = "https://chat.openai.com/cdn-cgi/trace"; + let result_country = client.get(url_country).send().await; + + let region = match result_country { + Ok(response) => { + if let Ok(body) = response.text().await { + let mut map = HashMap::new(); + for line in body.lines() { + if let Some(index) = line.find('=') { + let key = &line[..index]; + let value = &line[index + 1..]; + map.insert(key.to_string(), value.to_string()); + } + } + + map.get("loc").map(|loc| { + let emoji = country_code_to_emoji(loc); + format!("{emoji}{loc}") + }) + } else { + None + } + } + Err(_) => None, + }; + + let url_ios = "https://ios.chat.openai.com/"; + let result_ios = client.get(url_ios).send().await; + + let ios_status = match result_ios { + Ok(response) => { + if let Ok(body) = response.text().await { + let body_lower = body.to_lowercase(); + if body_lower.contains("you may be connected to a disallowed isp") { + "Disallowed ISP" + } else if body_lower.contains("request is not allowed. please try again later.") { + "Yes" + } else if body_lower.contains("sorry, you have been blocked") { + "Blocked" + } else { + "Failed" + } + } else { + "Failed" + } + } + Err(_) => "Failed", + }; + + let url_web = "https://api.openai.com/compliance/cookie_requirements"; + let result_web = client.get(url_web).send().await; + + let web_status = match result_web { + Ok(response) => { + if let Ok(body) = response.text().await { + let body_lower = body.to_lowercase(); + if body_lower.contains("unsupported_country") { + "Unsupported Country/Region" + } else { + "Yes" + } + } else { + "Failed" + } + } + Err(_) => "Failed", + }; + + results.push(UnlockItem { + name: "ChatGPT iOS".to_string(), + status: ios_status.to_string(), + region: region.clone(), + check_time: Some(get_local_date_string()), + }); + + results.push(UnlockItem { + name: "ChatGPT Web".to_string(), + status: web_status.to_string(), + region, + check_time: Some(get_local_date_string()), + }); + + results +} diff --git a/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/claude.rs b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/claude.rs new file mode 100644 index 0000000000..525b9a7fe1 --- /dev/null +++ b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/claude.rs @@ -0,0 +1,60 @@ +use reqwest::Client; + +use super::UnlockItem; +use super::utils::{country_code_to_emoji, get_local_date_string}; + +const BLOCKED_CODES: [&str; 10] = ["AF", "BY", "CN", "CU", "HK", "IR", "KP", "MO", "RU", "SY"]; + +pub(super) async fn check_claude(client: &Client) -> UnlockItem { + let url = "https://claude.ai/cdn-cgi/trace"; + + match client.get(url).send().await { + Ok(response) => match response.text().await { + Ok(body) => { + let mut country_code: Option = None; + + for line in body.lines() { + if let Some(rest) = line.strip_prefix("loc=") { + country_code = Some(rest.trim().to_uppercase()); + break; + } + } + + if let Some(code) = country_code { + let emoji = country_code_to_emoji(&code); + let status = if BLOCKED_CODES.contains(&code.as_str()) { + "No" + } else { + "Yes" + }; + + UnlockItem { + name: "Claude".to_string(), + status: status.to_string(), + region: Some(format!("{emoji}{code}")), + check_time: Some(get_local_date_string()), + } + } else { + UnlockItem { + name: "Claude".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + } + } + } + Err(_) => UnlockItem { + name: "Claude".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }, + }, + Err(_) => UnlockItem { + name: "Claude".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }, + } +} diff --git a/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/disney_plus.rs b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/disney_plus.rs new file mode 100644 index 0000000000..dc18978eb1 --- /dev/null +++ b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/disney_plus.rs @@ -0,0 +1,490 @@ +use regex::Regex; +use reqwest::Client; + +use crate::{logging, utils::logging::Type}; + +use super::UnlockItem; +use super::utils::{country_code_to_emoji, get_local_date_string}; + +#[allow(clippy::cognitive_complexity)] +pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem { + let device_api_url = "https://disney.api.edge.bamgrid.com/devices"; + let auth_header = + "Bearer ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84"; + + let device_req_body = serde_json::json!({ + "deviceFamily": "browser", + "applicationRuntime": "chrome", + "deviceProfile": "windows", + "attributes": {} + }); + + let device_result = client + .post(device_api_url) + .header("authorization", auth_header) + .header("content-type", "application/json; charset=UTF-8") + .json(&device_req_body) + .send() + .await; + + if device_result.is_err() { + return UnlockItem { + name: "Disney+".to_string(), + status: "Failed (Network Connection)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + let device_response = match device_result { + Ok(response) => response, + Err(e) => { + logging!( + error, + Type::Network, + "Failed to get Disney+ device response: {}", + e + ); + return UnlockItem { + name: "Disney+".to_string(), + status: "Failed (Network Connection)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + }; + + if device_response.status().as_u16() == 403 { + return UnlockItem { + name: "Disney+".to_string(), + status: "No (IP Banned By Disney+)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + let device_body = match device_response.text().await { + Ok(body) => body, + Err(_) => { + return UnlockItem { + name: "Disney+".to_string(), + status: "Failed (Error: Cannot read response)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + }; + + let re = match Regex::new(r#""assertion"\s*:\s*"([^"]+)"#) { + Ok(re) => re, + Err(e) => { + logging!( + error, + Type::Network, + "Failed to compile assertion regex for Disney+: {}", + e + ); + return UnlockItem { + name: "Disney+".to_string(), + status: "Failed (Regex Error)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + }; + let assertion = match re.captures(&device_body) { + Some(caps) => caps.get(1).map(|m| m.as_str().to_string()), + None => None, + }; + + if assertion.is_none() { + return UnlockItem { + name: "Disney+".to_string(), + status: "Failed (Error: Cannot extract assertion)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + let token_url = "https://disney.api.edge.bamgrid.com/token"; + let assertion_str = match assertion { + Some(assertion) => assertion, + None => { + logging!(error, Type::Network, "No assertion found for Disney+"); + return UnlockItem { + name: "Disney+".to_string(), + status: "Failed (No Assertion)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + }; + let token_body = [ + ( + "grant_type", + "urn:ietf:params:oauth:grant-type:token-exchange", + ), + ("latitude", "0"), + ("longitude", "0"), + ("platform", "browser"), + ("subject_token", assertion_str.as_str()), + ( + "subject_token_type", + "urn:bamtech:params:oauth:token-type:device", + ), + ]; + + let token_result = client + .post(token_url) + .header("authorization", auth_header) + .header("content-type", "application/x-www-form-urlencoded") + .form(&token_body) + .send() + .await; + + if token_result.is_err() { + return UnlockItem { + name: "Disney+".to_string(), + status: "Failed (Network Connection)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + let token_response = match token_result { + Ok(response) => response, + Err(e) => { + logging!( + error, + Type::Network, + "Failed to get Disney+ token response: {}", + e + ); + return UnlockItem { + name: "Disney+".to_string(), + status: "Failed (Network Connection)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + }; + let token_status = token_response.status(); + + let token_body_text = match token_response.text().await { + Ok(body) => body, + Err(_) => { + return UnlockItem { + name: "Disney+".to_string(), + status: "Failed (Error: Cannot read token response)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + }; + + if token_body_text.contains("forbidden-location") || token_body_text.contains("403 ERROR") { + return UnlockItem { + name: "Disney+".to_string(), + status: "No (IP Banned By Disney+)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + let token_json: Result = serde_json::from_str(&token_body_text); + + let refresh_token = match token_json { + Ok(json) => json + .get("refresh_token") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + Err(_) => match Regex::new(r#""refresh_token"\s*:\s*"([^"]+)"#) { + Ok(refresh_token_re) => refresh_token_re + .captures(&token_body_text) + .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())), + Err(e) => { + logging!( + error, + Type::Network, + "Failed to compile refresh_token regex for Disney+: {}", + e + ); + None + } + }, + }; + + if refresh_token.is_none() { + return UnlockItem { + name: "Disney+".to_string(), + status: format!( + "Failed (Error: Cannot extract refresh token, status: {}, response: {})", + token_status.as_u16(), + token_body_text.chars().take(100).collect::() + "..." + ), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + let graphql_url = "https://disney.api.edge.bamgrid.com/graph/v1/device/graphql"; + + let graphql_payload = format!( + r#"{{"query":"mutation refreshToken($input: RefreshTokenInput!) {{ refreshToken(refreshToken: $input) {{ activeSession {{ sessionId }} }} }}","variables":{{"input":{{"refreshToken":"{}"}}}}}}"#, + refresh_token.unwrap_or_default() + ); + + let graphql_result = client + .post(graphql_url) + .header("authorization", auth_header) + .header("content-type", "application/json") + .body(graphql_payload) + .send() + .await; + + if graphql_result.is_err() { + return UnlockItem { + name: "Disney+".to_string(), + status: "Failed (Network Connection)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + let preview_check = client.get("https://disneyplus.com").send().await; + + let is_unavailable = match preview_check { + Ok(response) => { + let url = response.url().to_string(); + url.contains("preview") || url.contains("unavailable") + } + Err(_) => true, + }; + + let graphql_response = match graphql_result { + Ok(response) => response, + Err(e) => { + logging!( + error, + Type::Network, + "Failed to get Disney+ GraphQL response: {}", + e + ); + return UnlockItem { + name: "Disney+".to_string(), + status: "Failed (Network Connection)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + }; + let graphql_status = graphql_response.status(); + let graphql_body_text = match graphql_response.text().await { + Ok(text) => text, + Err(e) => { + logging!( + error, + Type::Network, + "Failed to read Disney+ GraphQL response text: {}", + e + ); + String::new() + } + }; + + if graphql_body_text.is_empty() || graphql_status.as_u16() >= 400 { + let region_from_main = match client.get("https://www.disneyplus.com/").send().await { + Ok(response) => match response.text().await { + Ok(body) => match Regex::new(r#"region"\s*:\s*"([^"]+)"#) { + Ok(region_re) => region_re + .captures(&body) + .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())), + Err(e) => { + logging!( + error, + Type::Network, + "Failed to compile Disney+ main page region regex: {}", + e + ); + None + } + }, + Err(_) => None, + }, + Err(_) => None, + }; + + if let Some(region) = region_from_main { + let emoji = country_code_to_emoji(®ion); + return UnlockItem { + name: "Disney+".to_string(), + status: "Yes".to_string(), + region: Some(format!("{emoji}{region} (from main page)")), + check_time: Some(get_local_date_string()), + }; + } + + if graphql_body_text.is_empty() { + return UnlockItem { + name: "Disney+".to_string(), + status: format!( + "Failed (GraphQL error: empty response, status: {})", + graphql_status.as_u16() + ), + region: None, + check_time: Some(get_local_date_string()), + }; + } + return UnlockItem { + name: "Disney+".to_string(), + status: format!( + "Failed (GraphQL error: {}, status: {})", + graphql_body_text.chars().take(50).collect::() + "...", + graphql_status.as_u16() + ), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + let region_re = match Regex::new(r#""countryCode"\s*:\s*"([^"]+)"#) { + Ok(re) => re, + Err(e) => { + logging!( + error, + Type::Network, + "Failed to compile Disney+ countryCode regex: {}", + e + ); + return UnlockItem { + name: "Disney+".to_string(), + status: "Failed (Regex Error)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + }; + let region_code = region_re + .captures(&graphql_body_text) + .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())); + + let supported_re = match Regex::new(r#""inSupportedLocation"\s*:\s*(false|true)"#) { + Ok(re) => re, + Err(e) => { + logging!( + error, + Type::Network, + "Failed to compile Disney+ supported location regex: {}", + e + ); + return UnlockItem { + name: "Disney+".to_string(), + status: "Failed (Regex Error)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + }; + let in_supported_location = supported_re + .captures(&graphql_body_text) + .and_then(|caps| caps.get(1).map(|m| m.as_str() == "true")); + + if region_code.is_none() { + let region_from_main = match client.get("https://www.disneyplus.com/").send().await { + Ok(response) => match response.text().await { + Ok(body) => match Regex::new(r#"region"\s*:\s*"([^"]+)"#) { + Ok(region_re) => region_re + .captures(&body) + .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())), + Err(e) => { + logging!( + error, + Type::Network, + "Failed to compile Disney+ main page region regex: {}", + e + ); + None + } + }, + Err(_) => None, + }, + Err(_) => None, + }; + + if let Some(region) = region_from_main { + let emoji = country_code_to_emoji(®ion); + return UnlockItem { + name: "Disney+".to_string(), + status: "Yes".to_string(), + region: Some(format!("{emoji}{region} (from main page)")), + check_time: Some(get_local_date_string()), + }; + } + + return UnlockItem { + name: "Disney+".to_string(), + status: "No".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + let region = match region_code { + Some(code) => code, + None => { + logging!(error, Type::Network, "No region code found for Disney+"); + return UnlockItem { + name: "Disney+".to_string(), + status: "No".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + }; + + if region == "JP" { + let emoji = country_code_to_emoji("JP"); + return UnlockItem { + name: "Disney+".to_string(), + status: "Yes".to_string(), + region: Some(format!("{emoji}{region}")), + check_time: Some(get_local_date_string()), + }; + } + + if is_unavailable { + return UnlockItem { + name: "Disney+".to_string(), + status: "No".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + match in_supported_location { + Some(false) => { + let emoji = country_code_to_emoji(®ion); + UnlockItem { + name: "Disney+".to_string(), + status: "Soon".to_string(), + region: Some(format!("{emoji}{region}(即将上线)")), + check_time: Some(get_local_date_string()), + } + } + Some(true) => { + let emoji = country_code_to_emoji(®ion); + UnlockItem { + name: "Disney+".to_string(), + status: "Yes".to_string(), + region: Some(format!("{emoji}{region}")), + check_time: Some(get_local_date_string()), + } + } + None => UnlockItem { + name: "Disney+".to_string(), + status: format!("Failed (Error: Unknown region status for {region})"), + region: None, + check_time: Some(get_local_date_string()), + }, + } +} diff --git a/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/gemini.rs b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/gemini.rs new file mode 100644 index 0000000000..4701bb6ef0 --- /dev/null +++ b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/gemini.rs @@ -0,0 +1,66 @@ +use regex::Regex; +use reqwest::Client; + +use crate::{logging, utils::logging::Type}; + +use super::UnlockItem; +use super::utils::{country_code_to_emoji, get_local_date_string}; + +pub(super) async fn check_gemini(client: &Client) -> UnlockItem { + let url = "https://gemini.google.com"; + + match client.get(url).send().await { + Ok(response) => { + if let Ok(body) = response.text().await { + let is_ok = body.contains("45631641,null,true"); + let status = if is_ok { "Yes" } else { "No" }; + + let re = match Regex::new(r#",2,1,200,"([A-Z]{3})""#) { + Ok(re) => re, + Err(e) => { + logging!( + error, + Type::Network, + "Failed to compile Gemini regex: {}", + e + ); + return UnlockItem { + name: "Gemini".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + }; + + let region = re.captures(&body).and_then(|caps| { + caps.get(1).map(|m| { + let country_code = m.as_str(); + let emoji = country_code_to_emoji(country_code); + format!("{emoji}{country_code}") + }) + }); + + UnlockItem { + name: "Gemini".to_string(), + status: status.to_string(), + region, + check_time: Some(get_local_date_string()), + } + } else { + UnlockItem { + name: "Gemini".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + } + } + } + Err(_) => UnlockItem { + name: "Gemini".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }, + } +} diff --git a/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/mod.rs b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/mod.rs new file mode 100644 index 0000000000..c01df29261 --- /dev/null +++ b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/mod.rs @@ -0,0 +1,188 @@ +use std::sync::Arc; + +use reqwest::Client; +use tauri::command; +use tokio::{sync::Mutex, task::JoinSet}; + +use crate::{logging, utils::logging::Type}; + +mod bahamut; +mod bilibili; +mod chatgpt; +mod claude; +mod disney_plus; +mod gemini; +mod netflix; +mod prime_video; +mod spotify; +mod tiktok; +mod types; +mod utils; +mod youtube; + +pub use types::UnlockItem; + +use bahamut::check_bahamut_anime; +use bilibili::{check_bilibili_china_mainland, check_bilibili_hk_mc_tw}; +use chatgpt::check_chatgpt_combined; +use claude::check_claude; +use disney_plus::check_disney_plus; +use gemini::check_gemini; +use netflix::check_netflix; +use prime_video::check_prime_video; +use spotify::check_spotify; +use tiktok::check_tiktok; +use youtube::check_youtube_premium; + +#[command] +pub async fn get_unlock_items() -> Result, String> { + Ok(types::default_unlock_items()) +} + +#[command] +pub async fn check_media_unlock() -> Result, String> { + let client = match Client::builder() + .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36") + .timeout(std::time::Duration::from_secs(30)) + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true) + .tcp_keepalive(std::time::Duration::from_secs(60)) + .connection_verbose(true) + .build() { + Ok(client) => client, + Err(e) => return Err(format!("创建HTTP客户端失败: {e}")), + }; + + let results = Arc::new(Mutex::new(Vec::new())); + let mut tasks = JoinSet::new(); + let client_arc = Arc::new(client); + + { + let client = Arc::clone(&client_arc); + let results = Arc::clone(&results); + tasks.spawn(async move { + let result = check_bilibili_china_mainland(&client).await; + results.lock().await.push(result); + }); + } + + { + let client = Arc::clone(&client_arc); + let results = Arc::clone(&results); + tasks.spawn(async move { + let result = check_bilibili_hk_mc_tw(&client).await; + results.lock().await.push(result); + }); + } + + { + let client = Arc::clone(&client_arc); + let results = Arc::clone(&results); + tasks.spawn(async move { + let chatgpt_results = check_chatgpt_combined(&client).await; + let mut results = results.lock().await; + results.extend(chatgpt_results); + }); + } + + { + let client = Arc::clone(&client_arc); + let results = Arc::clone(&results); + tasks.spawn(async move { + let result = check_claude(&client).await; + results.lock().await.push(result); + }); + } + + { + let client = Arc::clone(&client_arc); + let results = Arc::clone(&results); + tasks.spawn(async move { + let result = check_gemini(&client).await; + results.lock().await.push(result); + }); + } + + { + let client = Arc::clone(&client_arc); + let results = Arc::clone(&results); + tasks.spawn(async move { + let result = check_youtube_premium(&client).await; + results.lock().await.push(result); + }); + } + + { + let client = Arc::clone(&client_arc); + let results = Arc::clone(&results); + tasks.spawn(async move { + let result = check_bahamut_anime(&client).await; + results.lock().await.push(result); + }); + } + + { + let client = Arc::clone(&client_arc); + let results = Arc::clone(&results); + tasks.spawn(async move { + let result = check_netflix(&client).await; + results.lock().await.push(result); + }); + } + + { + let client = Arc::clone(&client_arc); + let results = Arc::clone(&results); + tasks.spawn(async move { + let result = check_disney_plus(&client).await; + results.lock().await.push(result); + }); + } + + { + let client = Arc::clone(&client_arc); + let results = Arc::clone(&results); + tasks.spawn(async move { + let result = check_spotify(&client).await; + results.lock().await.push(result); + }); + } + + { + let client = Arc::clone(&client_arc); + let results = Arc::clone(&results); + tasks.spawn(async move { + let result = check_tiktok(&client).await; + results.lock().await.push(result); + }); + } + + { + let client = Arc::clone(&client_arc); + let results = Arc::clone(&results); + tasks.spawn(async move { + let result = check_prime_video(&client).await; + results.lock().await.push(result); + }); + } + + while let Some(res) = tasks.join_next().await { + if let Err(e) = res { + eprintln!("任务执行失败: {e}"); + } + } + + let results = match Arc::try_unwrap(results) { + Ok(mutex) => mutex.into_inner(), + Err(_) => { + logging!( + error, + Type::Network, + "Failed to unwrap results Arc, references still exist" + ); + return Err("Failed to collect results".to_string()); + } + }; + + Ok(results) +} diff --git a/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/netflix.rs b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/netflix.rs new file mode 100644 index 0000000000..bc1bc30b29 --- /dev/null +++ b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/netflix.rs @@ -0,0 +1,220 @@ +use reqwest::Client; +use serde_json::Value; + +use crate::{logging, utils::logging::Type}; + +use super::UnlockItem; +use super::utils::{country_code_to_emoji, get_local_date_string}; + +pub(super) async fn check_netflix(client: &Client) -> UnlockItem { + let cdn_result = check_netflix_cdn(client).await; + if cdn_result.status == "Yes" { + return cdn_result; + } + + let url1 = "https://www.netflix.com/title/81280792"; + let url2 = "https://www.netflix.com/title/70143836"; + + let result1 = client + .get(url1) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await; + + if let Err(e) = &result1 { + eprintln!("Netflix请求错误: {e}"); + return UnlockItem { + name: "Netflix".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + let result2 = client + .get(url2) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await; + + if let Err(e) = &result2 { + eprintln!("Netflix请求错误: {e}"); + return UnlockItem { + name: "Netflix".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + let status1 = match result1 { + Ok(response) => response.status().as_u16(), + Err(e) => { + logging!( + error, + Type::Network, + "Failed to get Netflix response 1: {}", + e + ); + return UnlockItem { + name: "Netflix".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + }; + + let status2 = match result2 { + Ok(response) => response.status().as_u16(), + Err(e) => { + logging!( + error, + Type::Network, + "Failed to get Netflix response 2: {}", + e + ); + return UnlockItem { + name: "Netflix".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + }; + + if status1 == 404 && status2 == 404 { + return UnlockItem { + name: "Netflix".to_string(), + status: "Originals Only".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + if status1 == 403 || status2 == 403 { + return UnlockItem { + name: "Netflix".to_string(), + status: "No".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + if status1 == 200 || status1 == 301 || status2 == 200 || status2 == 301 { + let test_url = "https://www.netflix.com/title/80018499"; + match client + .get(test_url) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + { + Ok(response) => { + if let Some(location) = response.headers().get("location") + && let Ok(location_str) = location.to_str() + { + let parts: Vec<&str> = location_str.split('/').collect(); + if parts.len() >= 4 { + let region_code = parts[3].split('-').next().unwrap_or("unknown"); + let emoji = country_code_to_emoji(region_code); + return UnlockItem { + name: "Netflix".to_string(), + status: "Yes".to_string(), + region: Some(format!("{emoji}{region_code}")), + check_time: Some(get_local_date_string()), + }; + } + } + + let emoji = country_code_to_emoji("us"); + UnlockItem { + name: "Netflix".to_string(), + status: "Yes".to_string(), + region: Some(format!("{emoji}{}", "us")), + check_time: Some(get_local_date_string()), + } + } + Err(e) => { + eprintln!("获取Netflix区域信息失败: {e}"); + UnlockItem { + name: "Netflix".to_string(), + status: "Yes (但无法获取区域)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + } + } + } + } else { + UnlockItem { + name: "Netflix".to_string(), + status: format!("Failed (状态码: {status1}_{status2}"), + region: None, + check_time: Some(get_local_date_string()), + } + } +} + +async fn check_netflix_cdn(client: &Client) -> UnlockItem { + let url = "https://api.fast.com/netflix/speedtest/v2?https=true&token=YXNkZmFzZGxmbnNkYWZoYXNkZmhrYWxm&urlCount=5"; + + match client + .get(url) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + { + Ok(response) => { + if response.status().as_u16() == 403 { + return UnlockItem { + name: "Netflix".to_string(), + status: "No (IP Banned By Netflix)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + match response.json::().await { + Ok(data) => { + if let Some(targets) = data.get("targets").and_then(|t| t.as_array()) + && !targets.is_empty() + && let Some(location) = targets[0].get("location") + && let Some(country) = location.get("country").and_then(|c| c.as_str()) + { + let emoji = country_code_to_emoji(country); + return UnlockItem { + name: "Netflix".to_string(), + status: "Yes".to_string(), + region: Some(format!("{emoji}{country}")), + check_time: Some(get_local_date_string()), + }; + } + + UnlockItem { + name: "Netflix".to_string(), + status: "Unknown".to_string(), + region: None, + check_time: Some(get_local_date_string()), + } + } + Err(e) => { + eprintln!("解析Fast.com API响应失败: {e}"); + UnlockItem { + name: "Netflix".to_string(), + status: "Failed (解析错误)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + } + } + } + } + Err(e) => { + eprintln!("Fast.com API请求失败: {e}"); + UnlockItem { + name: "Netflix".to_string(), + status: "Failed (CDN API)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + } + } + } +} diff --git a/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/prime_video.rs b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/prime_video.rs new file mode 100644 index 0000000000..fb76723d1a --- /dev/null +++ b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/prime_video.rs @@ -0,0 +1,108 @@ +use regex::Regex; +use reqwest::Client; + +use crate::{logging, utils::logging::Type}; + +use super::UnlockItem; +use super::utils::{country_code_to_emoji, get_local_date_string}; + +pub(super) async fn check_prime_video(client: &Client) -> UnlockItem { + let url = "https://www.primevideo.com"; + + let result = client.get(url).send().await; + + if result.is_err() { + return UnlockItem { + name: "Prime Video".to_string(), + status: "Failed (Network Connection)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + let response = match result { + Ok(response) => response, + Err(e) => { + logging!( + error, + Type::Network, + "Failed to get Prime Video response: {}", + e + ); + return UnlockItem { + name: "Prime Video".to_string(), + status: "Failed (Network Connection)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + }; + + match response.text().await { + Ok(body) => { + let is_blocked = body.contains("isServiceRestricted"); + + let region_re = match Regex::new(r#""currentTerritory":"([^"]+)""#) { + Ok(re) => re, + Err(e) => { + logging!( + error, + Type::Network, + "Failed to compile Prime Video region regex: {}", + e + ); + return UnlockItem { + name: "Prime Video".to_string(), + status: "Failed (Regex Error)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + }; + let region_code = region_re + .captures(&body) + .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())); + + if is_blocked { + return UnlockItem { + name: "Prime Video".to_string(), + status: "No (Service Not Available)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + if let Some(region) = region_code { + let emoji = country_code_to_emoji(®ion); + return UnlockItem { + name: "Prime Video".to_string(), + status: "Yes".to_string(), + region: Some(format!("{emoji}{region}")), + check_time: Some(get_local_date_string()), + }; + } + + if !is_blocked { + return UnlockItem { + name: "Prime Video".to_string(), + status: "Failed (Error: PAGE ERROR)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }; + } + + UnlockItem { + name: "Prime Video".to_string(), + status: "Failed (Error: Unknown Region)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + } + } + Err(_) => UnlockItem { + name: "Prime Video".to_string(), + status: "Failed (Error: Cannot read response)".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }, + } +} diff --git a/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/spotify.rs b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/spotify.rs new file mode 100644 index 0000000000..88cdea2346 --- /dev/null +++ b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/spotify.rs @@ -0,0 +1,79 @@ +use reqwest::{Client, Url}; + +use super::UnlockItem; +use super::utils::{country_code_to_emoji, get_local_date_string}; + +pub(super) async fn check_spotify(client: &Client) -> UnlockItem { + let url = "https://www.spotify.com/api/content/v1/country-selector?platform=web&format=json"; + + match client.get(url).send().await { + Ok(response) => { + let final_url = response.url().clone(); + let status_code = response.status(); + let body = response.text().await.unwrap_or_default(); + + let region = extract_region(&final_url).or_else(|| extract_region_from_body(&body)); + let status = determine_status(status_code.as_u16(), &body); + + UnlockItem { + name: "Spotify".to_string(), + status: status.to_string(), + region, + check_time: Some(get_local_date_string()), + } + } + Err(_) => UnlockItem { + name: "Spotify".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }, + } +} + +fn determine_status(status: u16, body: &str) -> &'static str { + if status == 403 || status == 451 { + return "No"; + } + + if !(200..300).contains(&status) { + return "Failed"; + } + + let body_lower = body.to_lowercase(); + if body_lower.contains("not available in your country") { + return "No"; + } + + "Yes" +} + +fn extract_region(url: &Url) -> Option { + let mut segments = url.path_segments()?; + let first_segment = segments.next()?; + + if first_segment.is_empty() || first_segment == "api" { + return None; + } + + let country_code = first_segment.split('-').next().unwrap_or(first_segment); + let upper = country_code.to_uppercase(); + let emoji = country_code_to_emoji(&upper); + Some(format!("{emoji}{upper}")) +} + +fn extract_region_from_body(body: &str) -> Option { + let marker = "\"countryCode\":\""; + if let Some(idx) = body.find(marker) { + let start = idx + marker.len(); + let rest = &body[start..]; + if let Some(end) = rest.find('"') { + let code = rest[..end].to_uppercase(); + if !code.is_empty() { + let emoji = country_code_to_emoji(&code); + return Some(format!("{emoji}{code}")); + } + } + } + None +} diff --git a/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/tiktok.rs b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/tiktok.rs new file mode 100644 index 0000000000..c3bb46ac12 --- /dev/null +++ b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/tiktok.rs @@ -0,0 +1,87 @@ +use std::sync::OnceLock; + +use regex::Regex; +use reqwest::Client; + +use super::UnlockItem; +use super::utils::{country_code_to_emoji, get_local_date_string}; + +pub(super) async fn check_tiktok(client: &Client) -> UnlockItem { + let trace_url = "https://www.tiktok.com/cdn-cgi/trace"; + + let mut status = String::from("Failed"); + let mut region = None; + + if let Ok(response) = client.get(trace_url).send().await { + let status_code = response.status().as_u16(); + if let Ok(body) = response.text().await { + status = determine_status(status_code, &body).to_string(); + region = extract_region_from_body(&body); + } + } + + if (region.is_none() || status == "Failed") + && let Ok(response) = client.get("https://www.tiktok.com/").send().await + { + let status_code = response.status().as_u16(); + if let Ok(body) = response.text().await { + let fallback_status = determine_status(status_code, &body); + let fallback_region = extract_region_from_body(&body); + + if status != "No" { + status = fallback_status.to_string(); + } + + if region.is_none() { + region = fallback_region; + } + } + } + + UnlockItem { + name: "TikTok".to_string(), + status, + region, + check_time: Some(get_local_date_string()), + } +} + +fn determine_status(status: u16, body: &str) -> &'static str { + if status == 403 || status == 451 { + return "No"; + } + + if !(200..300).contains(&status) { + return "Failed"; + } + + let body_lower = body.to_lowercase(); + if body_lower.contains("access denied") + || body_lower.contains("not available in your region") + || body_lower.contains("tiktok is not available") + { + return "No"; + } + + "Yes" +} + +fn extract_region_from_body(body: &str) -> Option { + static REGION_REGEX: OnceLock> = OnceLock::new(); + let regex = REGION_REGEX + .get_or_init(|| Regex::new(r#""region"\s*:\s*"([a-zA-Z-]+)""#).ok()) + .as_ref()?; + + if let Some(caps) = regex.captures(body) + && let Some(matched) = caps.get(1) + { + let raw = matched.as_str(); + let country_code = raw.split('-').next().unwrap_or(raw).to_uppercase(); + if !country_code.is_empty() { + let emoji = country_code_to_emoji(&country_code); + return Some(format!("{emoji}{country_code}")); + } + } + + None +} diff --git a/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/types.rs b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/types.rs new file mode 100644 index 0000000000..dd93d37816 --- /dev/null +++ b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/types.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnlockItem { + pub name: String, + pub status: String, + pub region: Option, + pub check_time: Option, +} + +impl UnlockItem { + pub fn pending(name: &str) -> Self { + Self { + name: name.to_string(), + status: "Pending".to_string(), + region: None, + check_time: None, + } + } +} + +const DEFAULT_UNLOCK_ITEM_NAMES: [&str; 13] = [ + "哔哩哔哩大陆", + "哔哩哔哩港澳台", + "ChatGPT iOS", + "ChatGPT Web", + "Claude", + "Gemini", + "Youtube Premium", + "Bahamut Anime", + "Netflix", + "Disney+", + "Prime Video", + "Spotify", + "TikTok", +]; + +pub fn default_unlock_items() -> Vec { + DEFAULT_UNLOCK_ITEM_NAMES + .iter() + .map(|name| UnlockItem::pending(name)) + .collect() +} diff --git a/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/utils.rs b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/utils.rs new file mode 100644 index 0000000000..c1852b4d8d --- /dev/null +++ b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/utils.rs @@ -0,0 +1,21 @@ +use chrono::Local; + +pub fn get_local_date_string() -> String { + let now = Local::now(); + now.format("%Y-%m-%d %H:%M:%S").to_string() +} + +pub fn country_code_to_emoji(country_code: &str) -> String { + let country_code = country_code.to_uppercase(); + if country_code.len() < 2 { + return String::new(); + } + + let bytes = country_code.as_bytes(); + let c1 = 0x1F1E6 + (bytes[0] as u32) - ('A' as u32); + let c2 = 0x1F1E6 + (bytes[1] as u32) - ('A' as u32); + + char::from_u32(c1) + .and_then(|c1| char::from_u32(c2).map(|c2| format!("{c1}{c2}"))) + .unwrap_or_default() +} diff --git a/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/youtube.rs b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/youtube.rs new file mode 100644 index 0000000000..d7caf6f45f --- /dev/null +++ b/clash-verge-rev/src-tauri/src/cmd/media_unlock_checker/youtube.rs @@ -0,0 +1,66 @@ +use regex::Regex; +use reqwest::Client; + +use crate::{logging, utils::logging::Type}; + +use super::UnlockItem; +use super::utils::{country_code_to_emoji, get_local_date_string}; + +pub(super) async fn check_youtube_premium(client: &Client) -> UnlockItem { + let url = "https://www.youtube.com/premium"; + + match client.get(url).send().await { + Ok(response) => { + if let Ok(body) = response.text().await { + let body_lower = body.to_lowercase(); + let mut status = "Failed"; + let mut region = None; + + if body_lower.contains("youtube premium is not available in your country") { + status = "No"; + } else if body_lower.contains("ad-free") { + match Regex::new(r#"id="country-code"[^>]*>([^<]+)<"#) { + Ok(re) => { + if let Some(caps) = re.captures(&body) + && let Some(m) = caps.get(1) + { + let country_code = m.as_str().trim(); + let emoji = country_code_to_emoji(country_code); + region = Some(format!("{emoji}{country_code}")); + status = "Yes"; + } + } + Err(e) => { + logging!( + error, + Type::Network, + "Failed to compile YouTube Premium regex: {}", + e + ); + } + } + } + + UnlockItem { + name: "Youtube Premium".to_string(), + status: status.to_string(), + region, + check_time: Some(get_local_date_string()), + } + } else { + UnlockItem { + name: "Youtube Premium".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + } + } + } + Err(_) => UnlockItem { + name: "Youtube Premium".to_string(), + status: "Failed".to_string(), + region: None, + check_time: Some(get_local_date_string()), + }, + } +} diff --git a/clash-verge-rev/src-tauri/src/cmd/mod.rs b/clash-verge-rev/src-tauri/src/cmd/mod.rs index 724a69f4c8..6c74868739 100644 --- a/clash-verge-rev/src-tauri/src/cmd/mod.rs +++ b/clash-verge-rev/src-tauri/src/cmd/mod.rs @@ -1,9 +1,11 @@ use anyhow::Result; +use smartstring::alias::String; pub type CmdResult = Result; // Command modules pub mod app; +pub mod backup; pub mod clash; pub mod lightweight; pub mod media_unlock_checker; @@ -21,6 +23,7 @@ pub mod webdav; // Re-export all command functions for backwards compatibility pub use app::*; +pub use backup::*; pub use clash::*; pub use lightweight::*; pub use media_unlock_checker::*; @@ -35,3 +38,27 @@ pub use uwp::*; pub use validate::*; pub use verge::*; pub use webdav::*; + +pub trait StringifyErr { + fn stringify_err(self) -> CmdResult; + fn stringify_err_log(self, log_fn: F) -> CmdResult + where + F: Fn(&str); +} + +impl StringifyErr for Result { + fn stringify_err(self) -> CmdResult { + self.map_err(|e| e.to_string().into()) + } + + fn stringify_err_log(self, log_fn: F) -> CmdResult + where + F: Fn(&str), + { + self.map_err(|e| { + let msg = String::from(e.to_string()); + log_fn(&msg); + msg + }) + } +} diff --git a/clash-verge-rev/src-tauri/src/cmd/network.rs b/clash-verge-rev/src-tauri/src/cmd/network.rs index 86ce8ed70c..be2fccff8f 100644 --- a/clash-verge-rev/src-tauri/src/cmd/network.rs +++ b/clash-verge-rev/src-tauri/src/cmd/network.rs @@ -1,14 +1,15 @@ use super::CmdResult; +use crate::cmd::StringifyErr as _; use crate::core::{EventDrivenProxyManager, async_proxy_query::AsyncProxyQuery}; use crate::process::AsyncHandler; -use crate::wrap_err; +use crate::{logging, utils::logging::Type}; use network_interface::NetworkInterface; use serde_yaml_ng::Mapping; /// get the system proxy #[tauri::command] pub async fn get_sys_proxy() -> CmdResult { - log::debug!(target: "app", "异步获取系统代理配置"); + logging!(debug, Type::Network, "异步获取系统代理配置"); let current = AsyncProxyQuery::get_system_proxy().await; @@ -20,14 +21,21 @@ pub async fn get_sys_proxy() -> CmdResult { ); map.insert("bypass".into(), current.bypass.into()); - log::debug!(target: "app", "返回系统代理配置: enable={}, {}:{}", current.enable, current.host, current.port); + logging!( + debug, + Type::Network, + "返回系统代理配置: enable={}, {}:{}", + current.enable, + current.host, + current.port + ); Ok(map) } /// 获取自动代理配置 #[tauri::command] pub async fn get_auto_proxy() -> CmdResult { - log::debug!(target: "app", "开始获取自动代理配置(事件驱动)"); + logging!(debug, Type::Network, "开始获取自动代理配置(事件驱动)"); let proxy_manager = EventDrivenProxyManager::global(); @@ -41,7 +49,13 @@ pub async fn get_auto_proxy() -> CmdResult { map.insert("enable".into(), current.enable.into()); map.insert("url".into(), current.url.clone().into()); - log::debug!(target: "app", "返回自动代理配置(缓存): enable={}, url={}", current.enable, current.url); + logging!( + debug, + Type::Network, + "返回自动代理配置(缓存): enable={}, url={}", + current.enable, + current.url + ); Ok(map) } @@ -79,10 +93,10 @@ pub fn get_network_interfaces() -> Vec { /// 获取网络接口详细信息 #[tauri::command] pub fn get_network_interfaces_info() -> CmdResult> { - use network_interface::{NetworkInterface, NetworkInterfaceConfig}; + use network_interface::{NetworkInterface, NetworkInterfaceConfig as _}; let names = get_network_interfaces(); - let interfaces = wrap_err!(NetworkInterface::show())?; + let interfaces = NetworkInterface::show().stringify_err()?; let mut result = Vec::new(); diff --git a/clash-verge-rev/src-tauri/src/cmd/profile.rs b/clash-verge-rev/src-tauri/src/cmd/profile.rs index d1d2b55b32..edb94795a9 100644 --- a/clash-verge-rev/src-tauri/src/cmd/profile.rs +++ b/clash-verge-rev/src-tauri/src/cmd/profile.rs @@ -1,4 +1,5 @@ use super::CmdResult; +use super::StringifyErr as _; use crate::{ config::{ Config, IProfiles, PrfItem, PrfOption, @@ -10,72 +11,24 @@ use crate::{ }, core::{CoreManager, handle, timer::Timer, tray::Tray}, feat, logging, + module::auto_backup::{AutoBackupManager, AutoBackupTrigger}, process::AsyncHandler, ret_err, utils::{dirs, help, logging::Type}, - wrap_err, }; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use scopeguard::defer; +use smartstring::alias::String; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; -// 全局请求序列号跟踪,用于避免队列化执行 -static CURRENT_REQUEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); - static CURRENT_SWITCHING_PROFILE: AtomicBool = AtomicBool::new(false); #[tauri::command] pub async fn get_profiles() -> CmdResult { - // 策略1: 尝试快速获取latest数据 - let latest_result = tokio::time::timeout(Duration::from_millis(500), async { - let profiles = Config::profiles().await; - let latest = profiles.latest_ref(); - IProfiles { - current: latest.current.clone(), - items: latest.items.clone(), - } - }) - .await; - - match latest_result { - Ok(profiles) => { - logging!(info, Type::Cmd, "快速获取配置列表成功"); - return Ok(profiles); - } - Err(_) => { - logging!(warn, Type::Cmd, "快速获取配置超时(500ms)"); - } - } - - // 策略2: 如果快速获取失败,尝试获取data() - let data_result = tokio::time::timeout(Duration::from_secs(2), async { - let profiles = Config::profiles().await; - let data = profiles.latest_ref(); - IProfiles { - current: data.current.clone(), - items: data.items.clone(), - } - }) - .await; - - match data_result { - Ok(profiles) => { - logging!(info, Type::Cmd, "获取draft配置列表成功"); - return Ok(profiles); - } - Err(join_err) => { - logging!( - error, - Type::Cmd, - "获取draft配置任务失败或超时: {}", - join_err - ); - } - } - - // 策略3: fallback,尝试重新创建配置 - logging!(warn, Type::Cmd, "所有获取配置策略都失败,尝试fallback"); - - Ok(IProfiles::new().await) + logging!(debug, Type::Cmd, "获取配置文件列表"); + let draft = Config::profiles().await; + let data = (**draft.data_arc()).clone(); + Ok(data) } /// 增强配置文件 @@ -84,8 +37,8 @@ pub async fn enhance_profiles() -> CmdResult { match feat::enhance_profiles().await { Ok(_) => {} Err(e) => { - log::error!(target: "app", "{}", e); - return Err(e.to_string()); + logging!(error, Type::Cmd, "{}", e); + return Err(e.to_string().into()); } } handle::Handle::refresh_clash(); @@ -94,96 +47,67 @@ pub async fn enhance_profiles() -> CmdResult { /// 导入配置文件 #[tauri::command] -pub async fn import_profile(url: String, option: Option) -> CmdResult { +pub async fn import_profile(url: std::string::String, option: Option) -> CmdResult { logging!(info, Type::Cmd, "[导入订阅] 开始导入: {}", url); - let import_result = tokio::time::timeout(Duration::from_secs(60), async { - let item = PrfItem::from_url(&url, None, None, option).await?; - logging!(info, Type::Cmd, "[导入订阅] 下载完成,开始保存配置"); - - let profiles = Config::profiles().await; - let pre_count = profiles - .latest_ref() - .items - .as_ref() - .map_or(0, |items| items.len()); - - let result = profiles_append_item_safe(item.clone()).await; - result?; - - let post_count = profiles - .latest_ref() - .items - .as_ref() - .map_or(0, |items| items.len()); - if post_count <= pre_count { - logging!(error, Type::Cmd, "[导入订阅] 配置未增加,导入可能失败"); - return Err(anyhow::anyhow!("配置导入后数量未增加")); + // 直接依赖 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, "[导入订阅] 下载完成,开始保存配置"); + it } - - logging!( - info, - Type::Cmd, - "[导入订阅] 配置保存成功,数量: {} -> {}", - pre_count, - post_count - ); - - // 立即发送配置变更通知 - if let Some(uid) = &item.uid { - logging!(info, Type::Cmd, "[导入订阅] 发送配置变更通知: {}", uid); - handle::Handle::notify_profile_changed(uid.clone()); + Err(e) => { + logging!(error, Type::Cmd, "[导入订阅] 下载失败: {}", e); + return Err(format!("导入订阅失败: {}", e).into()); } + }; - // 异步保存配置文件并发送全局通知 - let uid_clone = item.uid.clone(); - crate::process::AsyncHandler::spawn(move || async move { - // 使用Send-safe helper函数 - if let Err(e) = profiles_save_file_safe().await { - logging!(error, Type::Cmd, "[导入订阅] 保存配置文件失败: {}", e); - } else { + match profiles_append_item_safe(item).await { + Ok(_) => match profiles_save_file_safe().await { + Ok(_) => { logging!(info, Type::Cmd, "[导入订阅] 配置文件保存成功"); - - // 发送全局配置更新通知 - if let Some(uid) = uid_clone { - // 延迟发送,确保文件已完全写入 - tokio::time::sleep(Duration::from_millis(100)).await; - handle::Handle::notify_profile_changed(uid); - } } - }); - - Ok(()) - }) - .await; - - match import_result { - Ok(Ok(())) => { - logging!(info, Type::Cmd, "[导入订阅] 导入完成: {}", url); - Ok(()) - } - Ok(Err(e)) => { - logging!(error, Type::Cmd, "[导入订阅] 导入失败: {}", e); - Err(format!("导入订阅失败: {e}")) - } - Err(_) => { - logging!(error, Type::Cmd, "[导入订阅] 导入超时(60秒): {}", url); - Err("导入订阅超时,请检查网络连接".into()) + Err(e) => { + logging!(error, Type::Cmd, "[导入订阅] 保存配置文件失败: {}", e); + } + }, + Err(e) => { + logging!(error, Type::Cmd, "[导入订阅] 保存配置失败: {}", e); + return Err(format!("导入订阅失败: {}", e).into()); } } + // 立即发送配置变更通知 + if let Some(uid) = &item.uid { + logging!(info, Type::Cmd, "[导入订阅] 发送配置变更通知: {}", uid); + handle::Handle::notify_profile_changed(uid.clone()); + } + + // 异步保存配置文件并发送全局通知 + let uid_clone = item.uid.clone(); + if let Some(uid) = uid_clone { + // 延迟发送,确保文件已完全写入 + tokio::time::sleep(Duration::from_millis(100)).await; + handle::Handle::notify_profile_changed(uid); + } + + logging!(info, Type::Cmd, "[导入订阅] 导入完成: {}", url); + AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange); + 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 { + match profiles_reorder_safe(&active_id, &over_id).await { Ok(_) => { - log::info!(target: "app", "重新排序配置文件"); + logging!(info, Type::Cmd, "重新排序配置文件"); + Config::profiles().await.apply(); Ok(()) } Err(err) => { - log::error!(target: "app", "重新排序配置文件失败: {}", err); - Err(format!("重新排序配置文件失败: {}", err)) + Config::profiles().await.discard(); + logging!(error, Type::Cmd, "重新排序配置文件失败: {}", err); + Err(format!("重新排序配置文件失败: {}", err).into()) } } } @@ -192,30 +116,39 @@ pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult { /// 创建一个新的配置文件 #[tauri::command] pub async fn create_profile(item: PrfItem, file_data: Option) -> CmdResult { - match profiles_append_item_with_filedata_safe(item.clone(), file_data).await { + match profiles_append_item_with_filedata_safe(&item, file_data).await { Ok(_) => { // 发送配置变更通知 if let Some(uid) = &item.uid { logging!(info, Type::Cmd, "[创建订阅] 发送配置变更通知: {}", uid); handle::Handle::notify_profile_changed(uid.clone()); } + Config::profiles().await.apply(); + AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange); Ok(()) } - Err(err) => match err.to_string().as_str() { - "the file already exists" => Err("the file already exists".into()), - _ => Err(format!("add profile error: {err}")), - }, + Err(err) => { + Config::profiles().await.discard(); + match err.to_string().as_str() { + "the file already exists" => Err("the file already exists".into()), + _ => Err(format!("add profile error: {err}").into()), + } + } } } /// 更新配置文件 #[tauri::command] pub async fn update_profile(index: String, option: Option) -> CmdResult { - match feat::update_profile(index, option, Some(true)).await { - Ok(_) => Ok(()), + match feat::update_profile(&index, option.as_ref(), true, true).await { + Ok(_) => { + let _: () = Config::profiles().await.apply(); + Ok(()) + } Err(e) => { - log::error!(target: "app", "{}", e); - Err(e.to_string()) + Config::profiles().await.discard(); + logging!(error, Type::Cmd, "{}", e); + Err(e.to_string().into()) } } } @@ -224,366 +157,264 @@ pub async fn update_profile(index: String, option: Option) -> CmdResu #[tauri::command] pub async fn delete_profile(index: String) -> CmdResult { // 使用Send-safe helper函数 - let should_update = wrap_err!(profiles_delete_item_safe(index.clone()).await)?; - + let should_update = profiles_delete_item_safe(&index).await.stringify_err()?; + profiles_save_file_safe().await.stringify_err()?; if should_update { + Config::profiles().await.apply(); match CoreManager::global().update_config().await { Ok(_) => { handle::Handle::refresh_clash(); // 发送配置变更通知 logging!(info, Type::Cmd, "[删除订阅] 发送配置变更通知: {}", index); handle::Handle::notify_profile_changed(index); + AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange); } Err(e) => { - log::error!(target: "app", "{}", e); - return Err(e.to_string()); + logging!(error, Type::Cmd, "{}", e); + return Err(e.to_string().into()); } } } Ok(()) } -/// 修改profiles的配置 -#[tauri::command] -pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult { - if CURRENT_SWITCHING_PROFILE.load(Ordering::SeqCst) { - logging!(info, Type::Cmd, "当前正在切换配置,放弃请求"); - return Ok(false); - } - CURRENT_SWITCHING_PROFILE.store(true, Ordering::SeqCst); +/// 验证新配置文件的语法 +async fn validate_new_profile(new_profile: &String) -> Result<(), ()> { + logging!(info, Type::Cmd, "正在切换到新配置: {}", new_profile); - // 为当前请求分配序列号 - let current_sequence = CURRENT_REQUEST_SEQUENCE.fetch_add(1, Ordering::SeqCst) + 1; - let target_profile = profiles.current.clone(); - - logging!( - info, - Type::Cmd, - "开始修改配置文件,请求序列号: {}, 目标profile: {:?}", - current_sequence, - target_profile - ); - - let latest_sequence = CURRENT_REQUEST_SEQUENCE.load(Ordering::SeqCst); - if current_sequence < latest_sequence { - logging!( - info, - Type::Cmd, - "获取锁后发现更新的请求 (序列号: {} < {}),放弃当前请求", - current_sequence, - latest_sequence - ); - return Ok(false); - } - - // 保存当前配置,以便在验证失败时恢复 - let current_profile = Config::profiles().await.latest_ref().current.clone(); - logging!(info, Type::Cmd, "当前配置: {:?}", current_profile); - - // 如果要切换配置,先检查目标配置文件是否有语法错误 - if let Some(new_profile) = profiles.current.as_ref() - && current_profile.as_ref() != Some(new_profile) - { - logging!(info, Type::Cmd, "正在切换到新配置: {}", new_profile); - - // 获取目标配置文件路径 - let config_file_result = { - let profiles_config = Config::profiles().await; - let profiles_data = profiles_config.latest_ref(); - match profiles_data.get_item(new_profile) { - Ok(item) => { - if let Some(file) = &item.file { - let path = dirs::app_profiles_dir().map(|dir| dir.join(file)); - path.ok() - } else { - None - } - } - Err(e) => { - logging!(error, Type::Cmd, "获取目标配置信息失败: {}", e); + // 获取目标配置文件路径 + let config_file_result = { + let profiles_config = Config::profiles().await; + let profiles_data = profiles_config.latest_arc(); + match profiles_data.get_item(new_profile) { + Ok(item) => { + if let Some(file) = &item.file { + let path = dirs::app_profiles_dir().map(|dir| dir.join(file.as_str())); + path.ok() + } else { None } } - }; - - // 如果获取到文件路径,检查YAML语法 - if let Some(file_path) = config_file_result { - if !file_path.exists() { - logging!( - error, - Type::Cmd, - "目标配置文件不存在: {}", - file_path.display() - ); - handle::Handle::notice_message( - "config_validate::file_not_found", - format!("{}", file_path.display()), - ); - return Ok(false); - } - - // 超时保护 - let file_read_result = tokio::time::timeout( - Duration::from_secs(5), - tokio::fs::read_to_string(&file_path), - ) - .await; - - match file_read_result { - Ok(Ok(content)) => { - let yaml_parse_result = AsyncHandler::spawn_blocking(move || { - serde_yaml_ng::from_str::(&content) - }) - .await; - - match yaml_parse_result { - Ok(Ok(_)) => { - logging!(info, Type::Cmd, "目标配置文件语法正确"); - } - Ok(Err(err)) => { - let error_msg = format!(" {err}"); - logging!( - error, - Type::Cmd, - "目标配置文件存在YAML语法错误:{}", - error_msg - ); - handle::Handle::notice_message( - "config_validate::yaml_syntax_error", - &error_msg, - ); - return Ok(false); - } - Err(join_err) => { - let error_msg = format!("YAML解析任务失败: {join_err}"); - logging!(error, Type::Cmd, "{}", error_msg); - handle::Handle::notice_message( - "config_validate::yaml_parse_error", - &error_msg, - ); - return Ok(false); - } - } - } - Ok(Err(err)) => { - let error_msg = format!("无法读取目标配置文件: {err}"); - logging!(error, Type::Cmd, "{}", error_msg); - handle::Handle::notice_message("config_validate::file_read_error", &error_msg); - return Ok(false); - } - Err(_) => { - let error_msg = "读取配置文件超时(5秒)".to_string(); - logging!(error, Type::Cmd, "{}", error_msg); - handle::Handle::notice_message( - "config_validate::file_read_timeout", - &error_msg, - ); - return Ok(false); - } + Err(e) => { + logging!(error, Type::Cmd, "获取目标配置信息失败: {}", e); + None } } + }; + + // 如果获取到文件路径,检查YAML语法 + if let Some(file_path) = config_file_result { + if !file_path.exists() { + logging!( + error, + Type::Cmd, + "目标配置文件不存在: {}", + file_path.display() + ); + handle::Handle::notice_message( + "config_validate::file_not_found", + format!("{}", file_path.display()), + ); + return Err(()); + } + + // 超时保护 + let file_read_result = tokio::time::timeout( + Duration::from_secs(5), + tokio::fs::read_to_string(&file_path), + ) + .await; + + match file_read_result { + Ok(Ok(content)) => { + let yaml_parse_result = AsyncHandler::spawn_blocking(move || { + serde_yaml_ng::from_str::(&content) + }) + .await; + + match yaml_parse_result { + Ok(Ok(_)) => { + logging!(info, Type::Cmd, "目标配置文件语法正确"); + Ok(()) + } + Ok(Err(err)) => { + let error_msg = format!(" {err}"); + logging!( + error, + Type::Cmd, + "目标配置文件存在YAML语法错误:{}", + error_msg + ); + handle::Handle::notice_message( + "config_validate::yaml_syntax_error", + error_msg, + ); + Err(()) + } + Err(join_err) => { + let error_msg = format!("YAML解析任务失败: {join_err}"); + logging!(error, Type::Cmd, "{}", error_msg); + handle::Handle::notice_message( + "config_validate::yaml_parse_error", + error_msg, + ); + Err(()) + } + } + } + Ok(Err(err)) => { + let error_msg = format!("无法读取目标配置文件: {err}"); + logging!(error, Type::Cmd, "{}", error_msg); + handle::Handle::notice_message("config_validate::file_read_error", error_msg); + Err(()) + } + Err(_) => { + let error_msg = "读取配置文件超时(5秒)".to_string(); + logging!(error, Type::Cmd, "{}", error_msg); + handle::Handle::notice_message("config_validate::file_read_timeout", error_msg); + Err(()) + } + } + } else { + Ok(()) + } +} + +/// 执行配置更新并处理结果 +async fn restore_previous_profile(prev_profile: &String) -> CmdResult<()> { + logging!(info, Type::Cmd, "尝试恢复到之前的配置: {}", prev_profile); + let restore_profiles = IProfiles { + current: Some(prev_profile.to_owned()), + items: None, + }; + Config::profiles() + .await + .edit_draft(|d| d.patch_config(&restore_profiles)); + Config::profiles().await.apply(); + crate::process::AsyncHandler::spawn(|| async move { + if let Err(e) = profiles_save_file_safe().await { + logging!(warn, Type::Cmd, "Warning: 异步保存恢复配置文件失败: {e}"); + } + }); + logging!(info, Type::Cmd, "成功恢复到之前的配置"); + Ok(()) +} + +async fn handle_success(current_value: Option<&String>) -> CmdResult { + Config::profiles().await.apply(); + handle::Handle::refresh_clash(); + + if let Err(e) = Tray::global().update_tooltip().await { + logging!(warn, Type::Cmd, "Warning: 异步更新托盘提示失败: {e}"); } - // 检查请求有效性 - let latest_sequence = CURRENT_REQUEST_SEQUENCE.load(Ordering::SeqCst); - if current_sequence < latest_sequence { - logging!( - info, - Type::Cmd, - "在核心操作前发现更新的请求 (序列号: {} < {}),放弃当前请求", - current_sequence, - latest_sequence - ); - return Ok(false); + if let Err(e) = Tray::global().update_menu().await { + logging!(warn, Type::Cmd, "Warning: 异步更新托盘菜单失败: {e}"); } - // 更新profiles配置 - logging!( - info, - Type::Cmd, - "正在更新配置草稿,序列号: {}", - current_sequence - ); - - let current_value = profiles.current.clone(); - - let _ = Config::profiles().await.draft_mut().patch_config(profiles); - - // 在调用内核前再次验证请求有效性 - let latest_sequence = CURRENT_REQUEST_SEQUENCE.load(Ordering::SeqCst); - if current_sequence < latest_sequence { - logging!( - info, - Type::Cmd, - "在内核交互前发现更新的请求 (序列号: {} < {}),放弃当前请求", - current_sequence, - latest_sequence - ); - Config::profiles().await.discard(); - return Ok(false); + if let Err(e) = profiles_save_file_safe().await { + logging!(warn, Type::Cmd, "Warning: 异步保存配置文件失败: {e}"); } - // 为配置更新添加超时保护 - logging!( - info, - Type::Cmd, - "开始内核配置更新,序列号: {}", - current_sequence - ); + if let Some(current) = current_value { + logging!(info, Type::Cmd, "向前端发送配置变更事件: {}", current); + handle::Handle::notify_profile_changed(current.to_owned()); + } + + Ok(true) +} + +async fn handle_validation_failure( + error_msg: String, + current_profile: Option<&String>, +) -> CmdResult { + logging!(warn, Type::Cmd, "配置验证失败: {}", error_msg); + Config::profiles().await.discard(); + if let Some(prev_profile) = current_profile { + restore_previous_profile(prev_profile).await?; + } + handle::Handle::notice_message("config_validate::error", error_msg); + Ok(false) +} + +async fn handle_update_error(e: E) -> CmdResult { + logging!(warn, Type::Cmd, "更新过程发生错误: {}", e,); + Config::profiles().await.discard(); + handle::Handle::notice_message("config_validate::boot_error", e.to_string()); + Ok(false) +} + +async fn handle_timeout(current_profile: Option<&String>) -> CmdResult { + let timeout_msg = "配置更新超时(30秒),可能是配置验证或核心通信阻塞"; + logging!(error, Type::Cmd, "{}", timeout_msg); + Config::profiles().await.discard(); + if let Some(prev_profile) = current_profile { + restore_previous_profile(prev_profile).await?; + } + handle::Handle::notice_message("config_validate::timeout", timeout_msg); + Ok(false) +} + +async fn perform_config_update( + current_value: Option<&String>, + current_profile: Option<&String>, +) -> CmdResult { + defer! { + CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release); + } let update_result = tokio::time::timeout( - Duration::from_secs(30), // 30秒超时 + Duration::from_secs(30), CoreManager::global().update_config(), ) .await; - // 更新配置并进行验证 match update_result { - Ok(Ok((true, _))) => { - // 内核操作完成后再次检查请求有效性 - let latest_sequence = CURRENT_REQUEST_SEQUENCE.load(Ordering::SeqCst); - if current_sequence < latest_sequence { - logging!( - info, - Type::Cmd, - "内核操作后发现更新的请求 (序列号: {} < {}),忽略当前结果", - current_sequence, - latest_sequence - ); - Config::profiles().await.discard(); - return Ok(false); - } - - logging!( - info, - Type::Cmd, - "配置更新成功,序列号: {}", - current_sequence - ); - Config::profiles().await.apply(); - handle::Handle::refresh_clash(); - - // 强制刷新代理缓存,确保profile切换后立即获取最新节点数据 - // crate::process::AsyncHandler::spawn(|| async move { - // if let Err(e) = super::proxy::force_refresh_proxies().await { - // log::warn!(target: "app", "强制刷新代理缓存失败: {e}"); - // } - // }); - - if let Err(e) = Tray::global().update_tooltip().await { - log::warn!(target: "app", "异步更新托盘提示失败: {e}"); - } - - if let Err(e) = Tray::global().update_menu().await { - log::warn!(target: "app", "异步更新托盘菜单失败: {e}"); - } - - // 保存配置文件 - if let Err(e) = profiles_save_file_safe().await { - log::warn!(target: "app", "异步保存配置文件失败: {e}"); - } - - // 立即通知前端配置变更 - if let Some(current) = ¤t_value { - logging!( - info, - Type::Cmd, - "向前端发送配置变更事件: {}, 序列号: {}", - current, - current_sequence - ); - handle::Handle::notify_profile_changed(current.clone()); - } - - CURRENT_SWITCHING_PROFILE.store(false, Ordering::SeqCst); - Ok(true) - } - Ok(Ok((false, error_msg))) => { - logging!(warn, Type::Cmd, "配置验证失败: {}", error_msg); - Config::profiles().await.discard(); - // 如果验证失败,恢复到之前的配置 - if let Some(prev_profile) = current_profile { - logging!(info, Type::Cmd, "尝试恢复到之前的配置: {}", prev_profile); - let restore_profiles = IProfiles { - current: Some(prev_profile), - items: None, - }; - // 静默恢复,不触发验证 - wrap_err!({ - Config::profiles() - .await - .draft_mut() - .patch_config(restore_profiles) - })?; - Config::profiles().await.apply(); - - crate::process::AsyncHandler::spawn(|| async move { - if let Err(e) = profiles_save_file_safe().await { - log::warn!(target: "app", "异步保存恢复配置文件失败: {e}"); - } - }); - - logging!(info, Type::Cmd, "成功恢复到之前的配置"); - } - - // 发送验证错误通知 - handle::Handle::notice_message("config_validate::error", &error_msg); - CURRENT_SWITCHING_PROFILE.store(false, Ordering::SeqCst); - Ok(false) - } - Ok(Err(e)) => { - logging!( - warn, - Type::Cmd, - "更新过程发生错误: {}, 序列号: {}", - e, - current_sequence - ); - Config::profiles().await.discard(); - handle::Handle::notice_message("config_validate::boot_error", e.to_string()); - - CURRENT_SWITCHING_PROFILE.store(false, Ordering::SeqCst); - Ok(false) - } - Err(_) => { - // 超时处理 - let timeout_msg = "配置更新超时(30秒),可能是配置验证或核心通信阻塞"; - logging!( - error, - Type::Cmd, - "{}, 序列号: {}", - timeout_msg, - current_sequence - ); - Config::profiles().await.discard(); - - if let Some(prev_profile) = current_profile { - logging!( - info, - Type::Cmd, - "超时后尝试恢复到之前的配置: {}, 序列号: {}", - prev_profile, - current_sequence - ); - let restore_profiles = IProfiles { - current: Some(prev_profile), - items: None, - }; - wrap_err!({ - Config::profiles() - .await - .draft_mut() - .patch_config(restore_profiles) - })?; - Config::profiles().await.apply(); - } - - handle::Handle::notice_message("config_validate::timeout", timeout_msg); - CURRENT_SWITCHING_PROFILE.store(false, Ordering::SeqCst); - Ok(false) - } + Ok(Ok((true, _))) => handle_success(current_value).await, + Ok(Ok((false, error_msg))) => handle_validation_failure(error_msg, current_profile).await, + Ok(Err(e)) => handle_update_error(e).await, + Err(_) => handle_timeout(current_profile).await, } } +/// 修改profiles的配置 +#[tauri::command] +pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult { + if CURRENT_SWITCHING_PROFILE + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + logging!(info, Type::Cmd, "当前正在切换配置,放弃请求"); + return Ok(false); + } + + let target_profile = profiles.current.as_ref(); + + logging!( + info, + Type::Cmd, + "开始修改配置文件,目标profile: {:?}", + target_profile + ); + + // 保存当前配置,以便在验证失败时恢复 + let previous_profile = Config::profiles().await.data_arc().current.clone(); + logging!(info, Type::Cmd, "当前配置: {:?}", previous_profile); + + // 如果要切换配置,先检查目标配置文件是否有语法错误 + if let Some(switch_to_profile) = target_profile + && previous_profile.as_ref() != Some(switch_to_profile) + && validate_new_profile(switch_to_profile).await.is_err() + { + CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release); + return Ok(false); + } + Config::profiles() + .await + .edit_draft(|d| d.patch_config(&profiles)); + + perform_config_update(target_profile, previous_profile.as_ref()).await +} + /// 根据profile name修改profiles #[tauri::command] pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> CmdResult { @@ -601,31 +432,39 @@ pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> Cm pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult { // 保存修改前检查是否有更新 update_interval let profiles = Config::profiles().await; - let update_interval_changed = if let Ok(old_profile) = profiles.latest_ref().get_item(&index) { + let should_refresh_timer = if let Ok(old_profile) = profiles.latest_arc().get_item(&index) + && let Some(new_option) = profile.option.as_ref() + { let old_interval = old_profile.option.as_ref().and_then(|o| o.update_interval); - let new_interval = profile.option.as_ref().and_then(|o| o.update_interval); - old_interval != new_interval + let new_interval = new_option.update_interval; + let old_allow_auto_update = old_profile + .option + .as_ref() + .and_then(|o| o.allow_auto_update); + let new_allow_auto_update = new_option.allow_auto_update; + (old_interval != new_interval) || (old_allow_auto_update != new_allow_auto_update) } else { false }; - // 保存修改 - wrap_err!(profiles_patch_item_safe(index.clone(), profile).await)?; + profiles_patch_item_safe(&index, &profile) + .await + .stringify_err()?; - // 如果更新间隔变更,异步刷新定时器 - if update_interval_changed { - let index_clone = index.clone(); + // 如果更新间隔或允许自动更新变更,异步刷新定时器 + if should_refresh_timer { crate::process::AsyncHandler::spawn(move || async move { logging!(info, Type::Timer, "定时器更新间隔已变更,正在刷新定时器..."); if let Err(e) = crate::core::Timer::global().refresh().await { logging!(error, Type::Timer, "刷新定时器失败: {}", e); } else { // 刷新成功后发送自定义事件,不触发配置重载 - crate::core::handle::Handle::notify_timer_updated(index_clone); + crate::core::handle::Handle::notify_timer_updated(index); } }); } + AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange); Ok(()) } @@ -633,29 +472,36 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult { #[tauri::command] pub async fn view_profile(index: String) -> CmdResult { let profiles = Config::profiles().await; - let profiles_ref = profiles.latest_ref(); - let file = { - wrap_err!(profiles_ref.get_item(&index))? - .file - .clone() - .ok_or("the file field is null") - }?; + let profiles_ref = profiles.latest_arc(); + let file = profiles_ref + .get_item(&index) + .stringify_err()? + .file + .clone() + .ok_or("the file field is null")?; - let path = wrap_err!(dirs::app_profiles_dir())?.join(file); + let path = dirs::app_profiles_dir() + .stringify_err()? + .join(file.as_str()); if !path.exists() { ret_err!("the file not found"); } - wrap_err!(help::open_file(path)) + help::open_file(path).stringify_err() } /// 读取配置文件内容 #[tauri::command] pub async fn read_profile_file(index: String) -> CmdResult { - let profiles = Config::profiles().await; - let profiles_ref = profiles.latest_ref(); - let item = wrap_err!(profiles_ref.get_item(&index))?; - let data = wrap_err!(item.read_file())?; + let item = { + let profiles = Config::profiles().await; + let profiles_ref = profiles.latest_arc(); + PrfItem { + file: profiles_ref.get_item(&index).stringify_err()?.file.clone(), + ..Default::default() + } + }; + let data = item.read_file().await.stringify_err()?; Ok(data) } diff --git a/clash-verge-rev/src-tauri/src/cmd/proxy.rs b/clash-verge-rev/src-tauri/src/cmd/proxy.rs index a5b34cf46c..6f94f729cd 100644 --- a/clash-verge-rev/src-tauri/src/cmd/proxy.rs +++ b/clash-verge-rev/src-tauri/src/cmd/proxy.rs @@ -14,7 +14,7 @@ pub async fn sync_tray_proxy_selection() -> CmdResult<()> { } Err(e) => { logging!(error, Type::Cmd, "Failed to sync tray proxy selection: {e}"); - Err(e.to_string()) + Err(e.to_string().into()) } } } diff --git a/clash-verge-rev/src-tauri/src/cmd/runtime.rs b/clash-verge-rev/src-tauri/src/cmd/runtime.rs index 8e335b5787..d75e470d0d 100644 --- a/clash-verge-rev/src-tauri/src/cmd/runtime.rs +++ b/clash-verge-rev/src-tauri/src/cmd/runtime.rs @@ -1,53 +1,60 @@ use super::CmdResult; -use crate::{config::*, core::CoreManager, log_err, wrap_err}; -use anyhow::Context; +use crate::{ + cmd::StringifyErr as _, + config::{Config, ConfigType}, + core::CoreManager, + log_err, +}; +use anyhow::{Context as _, anyhow}; use serde_yaml_ng::Mapping; +use smartstring::alias::String; use std::collections::HashMap; /// 获取运行时配置 #[tauri::command] pub async fn get_runtime_config() -> CmdResult> { - Ok(Config::runtime().await.latest_ref().config.clone()) + Ok(Config::runtime().await.latest_arc().config.clone()) } /// 获取运行时YAML配置 #[tauri::command] pub async fn get_runtime_yaml() -> CmdResult { let runtime = Config::runtime().await; - let runtime = runtime.latest_ref(); + let runtime = runtime.latest_arc(); let config = runtime.config.as_ref(); - wrap_err!( - config - .ok_or(anyhow::anyhow!("failed to parse config to yaml file")) - .and_then(|config| serde_yaml_ng::to_string(config) - .context("failed to convert config to yaml")) - ) + config + .ok_or_else(|| anyhow!("failed to parse config to yaml file")) + .and_then(|config| { + serde_yaml_ng::to_string(config) + .context("failed to convert config to yaml") + .map(|s| s.into()) + }) + .stringify_err() } /// 获取运行时存在的键 #[tauri::command] pub async fn get_runtime_exists() -> CmdResult> { - Ok(Config::runtime().await.latest_ref().exists_keys.clone()) + Ok(Config::runtime().await.latest_arc().exists_keys.clone()) } /// 获取运行时日志 #[tauri::command] pub async fn get_runtime_logs() -> CmdResult>> { - Ok(Config::runtime().await.latest_ref().chain_logs.clone()) + Ok(Config::runtime().await.latest_arc().chain_logs.clone()) } #[tauri::command] pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> CmdResult { let runtime = Config::runtime().await; - let runtime = runtime.latest_ref(); + let runtime = runtime.latest_arc(); - let config = wrap_err!( - runtime - .config - .as_ref() - .ok_or(anyhow::anyhow!("failed to parse config to yaml file")) - )?; + let config = runtime + .config + .as_ref() + .ok_or_else(|| anyhow!("failed to parse config to yaml file")) + .stringify_err()?; if let Some(serde_yaml_ng::Value::Sequence(proxies)) = config.get("proxies") { let mut proxy_name = Some(Some(proxy_chain_exit_node.as_str())); @@ -78,13 +85,14 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm let mut config: HashMap> = HashMap::new(); - config.insert("proxies".to_string(), proxies_chain); + config.insert("proxies".into(), proxies_chain); - wrap_err!(serde_yaml_ng::to_string(&config).context("YAML generation failed")) + serde_yaml_ng::to_string(&config) + .context("YAML generation failed") + .map(|s| s.into()) + .stringify_err() } else { - wrap_err!(Err(anyhow::anyhow!( - "failed to get proxies or proxy-groups".to_string() - ))) + Err("failed to get proxies or proxy-groups".into()) } } @@ -95,14 +103,14 @@ pub async fn update_proxy_chain_config_in_runtime( ) -> CmdResult<()> { { let runtime = Config::runtime().await; - let mut draft = runtime.draft_mut(); - draft.update_proxy_chain_config(proxy_chain_config); - drop(draft); + runtime.edit_draft(|d| d.update_proxy_chain_config(proxy_chain_config)); runtime.apply(); } // 生成新的运行配置文件并通知 Clash 核心重新加载 - let run_path = wrap_err!(Config::generate_file(ConfigType::Run).await)?; + let run_path = Config::generate_file(ConfigType::Run) + .await + .stringify_err()?; log_err!(CoreManager::global().put_configs_force(run_path).await); Ok(()) diff --git a/clash-verge-rev/src-tauri/src/cmd/save_profile.rs b/clash-verge-rev/src-tauri/src/cmd/save_profile.rs index 7a1b39cd72..ed271504bd 100644 --- a/clash-verge-rev/src-tauri/src/cmd/save_profile.rs +++ b/clash-verge-rev/src-tauri/src/cmd/save_profile.rs @@ -1,38 +1,55 @@ use super::CmdResult; use crate::{ - config::*, - core::*, + cmd::StringifyErr as _, + config::{Config, PrfItem}, + core::{CoreManager, handle, validate::CoreConfigValidator}, logging, + module::auto_backup::{AutoBackupManager, AutoBackupTrigger}, utils::{dirs, logging::Type}, - wrap_err, }; +use smartstring::alias::String; use tokio::fs; /// 保存profiles的配置 #[tauri::command] pub async fn save_profile_file(index: String, file_data: Option) -> CmdResult { - if file_data.is_none() { - return Ok(()); - } - - // 在异步操作前完成所有文件操作 - let (file_path, original_content, is_merge_file) = { - let profiles = Config::profiles().await; - let profiles_guard = profiles.latest_ref(); - let item = wrap_err!(profiles_guard.get_item(&index))?; - // 确定是否为merge类型文件 - let is_merge = item.itype.as_ref().is_some_and(|t| t == "merge"); - let content = wrap_err!(item.read_file())?; - let path = item.file.clone().ok_or("file field is null")?; - let profiles_dir = wrap_err!(dirs::app_profiles_dir())?; - (profiles_dir.join(path), content, is_merge) + let file_data = match file_data { + Some(d) => d, + None => return Ok(()), }; - // 保存新的配置文件 - let file_data = file_data.ok_or("file_data is None")?; - wrap_err!(fs::write(&file_path, &file_data).await)?; + let backup_trigger = match index.as_str() { + "Merge" => Some(AutoBackupTrigger::GlobalMerge), + "Script" => Some(AutoBackupTrigger::GlobalScript), + _ => Some(AutoBackupTrigger::ProfileChange), + }; + // 在异步操作前获取必要元数据并释放锁 + let (rel_path, is_merge_file) = { + let profiles = Config::profiles().await; + let profiles_guard = profiles.latest_arc(); + let item = profiles_guard.get_item(&index).stringify_err()?; + let is_merge = item.itype.as_ref().is_some_and(|t| t == "merge"); + let path = item.file.clone().ok_or("file field is null")?; + (path, is_merge) + }; + + // 读取原始内容(在释放profiles_guard后进行) + let original_content = PrfItem { + file: Some(rel_path.clone()), + ..Default::default() + } + .read_file() + .await + .stringify_err()?; + + let profiles_dir = dirs::app_profiles_dir().stringify_err()?; + let file_path = profiles_dir.join(rel_path.as_str()); let file_path_str = file_path.to_string_lossy().to_string(); + + // 保存新的配置文件 + fs::write(&file_path, &file_data).await.stringify_err()?; + logging!( info, Type::Config, @@ -41,117 +58,127 @@ pub async fn save_profile_file(index: String, file_data: Option) -> CmdR is_merge_file ); - // 对于 merge 文件,只进行语法验证,不进行后续内核验证 - if is_merge_file { - logging!( - info, - Type::Config, - "[cmd配置save] 检测到merge文件,只进行语法验证" - ); - match CoreManager::global() - .validate_config_file(&file_path_str, Some(true)) - .await - { - Ok((true, _)) => { - logging!(info, Type::Config, "[cmd配置save] merge文件语法验证通过"); - // 成功后尝试更新整体配置 - match CoreManager::global().update_config().await { - Ok(_) => { - // 配置更新成功,刷新前端 - handle::Handle::refresh_clash(); - } - Err(e) => { - logging!( - warn, - Type::Config, - "[cmd配置save] 更新整体配置时发生错误: {}", - e - ); - } - } - return Ok(()); - } - Ok((false, error_msg)) => { + let changes_applied = if is_merge_file { + handle_merge_file(&file_path_str, &file_path, &original_content).await? + } else { + handle_full_validation(&file_path_str, &file_path, &original_content).await? + }; + + if changes_applied && let Some(trigger) = backup_trigger { + AutoBackupManager::trigger_backup(trigger); + } + + Ok(()) +} + +async fn restore_original( + file_path: &std::path::Path, + original_content: &str, +) -> Result<(), String> { + fs::write(file_path, original_content).await.stringify_err() +} + +fn is_script_error(err: &str, file_path_str: &str) -> bool { + file_path_str.ends_with(".js") + || err.contains("Script syntax error") + || err.contains("Script must contain a main function") + || err.contains("Failed to read script file") +} + +async fn handle_merge_file( + file_path_str: &str, + file_path: &std::path::Path, + original_content: &str, +) -> CmdResult { + logging!( + info, + Type::Config, + "[cmd配置save] 检测到merge文件,只进行语法验证" + ); + + match CoreConfigValidator::validate_config_file(file_path_str, Some(true)).await { + Ok((true, _)) => { + logging!(info, Type::Config, "[cmd配置save] merge文件语法验证通过"); + if let Err(e) = CoreManager::global().update_config().await { logging!( warn, Type::Config, - "[cmd配置save] merge文件语法验证失败: {}", - error_msg + "[cmd配置save] 更新整体配置时发生错误: {}", + e ); - // 恢复原始配置文件 - wrap_err!(fs::write(&file_path, original_content).await)?; - // 发送合并文件专用错误通知 - let result = (false, error_msg.clone()); - crate::cmd::validate::handle_yaml_validation_notice(&result, "合并配置文件"); - return Ok(()); - } - Err(e) => { - logging!(error, Type::Config, "[cmd配置save] 验证过程发生错误: {}", e); - // 恢复原始配置文件 - wrap_err!(fs::write(&file_path, original_content).await)?; - return Err(e.to_string()); + } else { + handle::Handle::refresh_clash(); } + Ok(true) + } + Ok((false, error_msg)) => { + logging!( + warn, + Type::Config, + "[cmd配置save] merge文件语法验证失败: {}", + error_msg + ); + restore_original(file_path, original_content).await?; + let result = (false, error_msg.clone()); + crate::cmd::validate::handle_yaml_validation_notice(&result, "合并配置文件"); + Ok(false) + } + Err(e) => { + logging!(error, Type::Config, "[cmd配置save] 验证过程发生错误: {}", e); + restore_original(file_path, original_content).await?; + Err(e.to_string().into()) } } +} - // 非merge文件使用完整验证流程 - match CoreManager::global() - .validate_config_file(&file_path_str, None) - .await - { +async fn handle_full_validation( + file_path_str: &str, + file_path: &std::path::Path, + original_content: &str, +) -> CmdResult { + match CoreConfigValidator::validate_config_file(file_path_str, None).await { Ok((true, _)) => { logging!(info, Type::Config, "[cmd配置save] 验证成功"); - Ok(()) + Ok(true) } Ok((false, error_msg)) => { logging!(warn, Type::Config, "[cmd配置save] 验证失败: {}", error_msg); - // 恢复原始配置文件 - wrap_err!(fs::write(&file_path, original_content).await)?; - - // 智能判断错误类型 - let is_script_error = file_path_str.ends_with(".js") - || error_msg.contains("Script syntax error") - || error_msg.contains("Script must contain a main function") - || error_msg.contains("Failed to read script file"); + restore_original(file_path, original_content).await?; if error_msg.contains("YAML syntax error") || error_msg.contains("Failed to read file:") - || (!file_path_str.ends_with(".js") && !is_script_error) + || (!file_path_str.ends_with(".js") && !is_script_error(&error_msg, file_path_str)) { - // 普通YAML错误使用YAML通知处理 logging!( info, Type::Config, "[cmd配置save] YAML配置文件验证失败,发送通知" ); - let result = (false, error_msg.clone()); + let result = (false, error_msg.to_owned()); crate::cmd::validate::handle_yaml_validation_notice(&result, "YAML配置文件"); - } else if is_script_error { - // 脚本错误使用专门的通知处理 + } else if is_script_error(&error_msg, file_path_str) { logging!( info, Type::Config, "[cmd配置save] 脚本文件验证失败,发送通知" ); - let result = (false, error_msg.clone()); + let result = (false, error_msg.to_owned()); crate::cmd::validate::handle_script_validation_notice(&result, "脚本文件"); } else { - // 普通配置错误使用一般通知 logging!( info, Type::Config, "[cmd配置save] 其他类型验证失败,发送一般通知" ); - handle::Handle::notice_message("config_validate::error", &error_msg); + handle::Handle::notice_message("config_validate::error", error_msg.to_owned()); } - Ok(()) + Ok(false) } Err(e) => { logging!(error, Type::Config, "[cmd配置save] 验证过程发生错误: {}", e); - // 恢复原始配置文件 - wrap_err!(fs::write(&file_path, original_content).await)?; - Err(e.to_string()) + restore_original(file_path, original_content).await?; + Err(e.to_string().into()) } } } diff --git a/clash-verge-rev/src-tauri/src/cmd/service.rs b/clash-verge-rev/src-tauri/src/cmd/service.rs index 01abdc8141..3f7b922738 100644 --- a/clash-verge-rev/src-tauri/src/cmd/service.rs +++ b/clash-verge-rev/src-tauri/src/cmd/service.rs @@ -1,8 +1,6 @@ -use super::CmdResult; -use crate::{ - core::service::{self, SERVICE_MANAGER, ServiceStatus}, - utils::i18n::t, -}; +use super::{CmdResult, StringifyErr as _}; +use crate::core::service::{self, SERVICE_MANAGER, ServiceStatus}; +use smartstring::SmartString; async fn execute_service_operation_sync(status: ServiceStatus, op_type: &str) -> CmdResult { if let Err(e) = SERVICE_MANAGER @@ -12,7 +10,7 @@ async fn execute_service_operation_sync(status: ServiceStatus, op_type: &str) -> .await { let emsg = format!("{} Service failed: {}", op_type, e); - return Err(t(emsg.as_str()).await); + return Err(SmartString::from(emsg)); } Ok(()) } @@ -39,8 +37,6 @@ pub async fn repair_service() -> CmdResult { #[tauri::command] pub async fn is_service_available() -> CmdResult { - service::is_service_available() - .await - .map(|_| true) - .map_err(|e| e.to_string()) + service::is_service_available().await.stringify_err()?; + Ok(true) } diff --git a/clash-verge-rev/src-tauri/src/cmd/system.rs b/clash-verge-rev/src-tauri/src/cmd/system.rs index 2ef6cf723a..27a4f5f6c4 100644 --- a/clash-verge-rev/src-tauri/src/cmd/system.rs +++ b/clash-verge-rev/src-tauri/src/cmd/system.rs @@ -1,26 +1,28 @@ +use std::sync::Arc; + use super::CmdResult; use crate::{ - core::{CoreManager, handle}, + core::{CoreManager, handle, manager::RunningMode}, logging, module::sysinfo::PlatformSpecification, utils::logging::Type, }; +#[cfg(target_os = "windows")] +use deelevate::{PrivilegeLevel, Token}; use once_cell::sync::Lazy; -use std::{ - sync::atomic::{AtomicI64, Ordering}, - time::{SystemTime, UNIX_EPOCH}, -}; -use tauri_plugin_clipboard_manager::ClipboardExt; +use tauri_plugin_clipboard_manager::ClipboardExt as _; +use tokio::time::Instant; // 存储应用启动时间的全局变量 -static APP_START_TIME: Lazy = Lazy::new(|| { - // 获取当前系统时间,转换为毫秒级时间戳 - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - - AtomicI64::new(now) +static APP_START_TIME: Lazy = Lazy::new(Instant::now); +#[cfg(not(target_os = "windows"))] +static APPS_RUN_AS_ADMIN: Lazy = Lazy::new(|| unsafe { libc::geteuid() } == 0); +#[cfg(target_os = "windows")] +static APPS_RUN_AS_ADMIN: Lazy = Lazy::new(|| { + Token::with_current_process() + .and_then(|token| token.privilege_level()) + .map(|level| level != PrivilegeLevel::NotPrivileged) + .unwrap_or(false) }); #[tauri::command] @@ -45,52 +47,18 @@ pub async fn get_system_info() -> CmdResult { /// 获取当前内核运行模式 #[tauri::command] -pub async fn get_running_mode() -> Result { - Ok(CoreManager::global().get_running_mode().to_string()) +pub async fn get_running_mode() -> Result, String> { + Ok(CoreManager::global().get_running_mode()) } /// 获取应用的运行时间(毫秒) #[tauri::command] -pub fn get_app_uptime() -> CmdResult { - let start_time = APP_START_TIME.load(Ordering::Relaxed); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - - Ok(now - start_time) +pub fn get_app_uptime() -> CmdResult { + Ok(APP_START_TIME.elapsed().as_millis()) } /// 检查应用是否以管理员身份运行 #[tauri::command] -#[cfg(target_os = "windows")] pub fn is_admin() -> CmdResult { - use deelevate::{PrivilegeLevel, Token}; - - let result = Token::with_current_process() - .and_then(|token| token.privilege_level()) - .map(|level| level != PrivilegeLevel::NotPrivileged) - .unwrap_or(false); - - Ok(result) -} - -/// 非Windows平台检测是否以管理员身份运行 -#[tauri::command] -#[cfg(not(target_os = "windows"))] -pub fn is_admin() -> CmdResult { - #[cfg(target_os = "macos")] - { - Ok(unsafe { libc::geteuid() } == 0) - } - - #[cfg(target_os = "linux")] - { - Ok(unsafe { libc::geteuid() } == 0) - } - - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - { - Ok(false) - } + Ok(*APPS_RUN_AS_ADMIN) } diff --git a/clash-verge-rev/src-tauri/src/cmd/uwp.rs b/clash-verge-rev/src-tauri/src/cmd/uwp.rs index dc2eb12015..779029583a 100644 --- a/clash-verge-rev/src-tauri/src/cmd/uwp.rs +++ b/clash-verge-rev/src-tauri/src/cmd/uwp.rs @@ -1,13 +1,14 @@ -use super::CmdResult; +use crate::cmd::CmdResult; /// Platform-specific implementation for UWP functionality #[cfg(windows)] mod platform { - use super::CmdResult; - use crate::{core::win_uwp, wrap_err}; + use crate::cmd::CmdResult; + use crate::cmd::StringifyErr as _; + use crate::core::win_uwp; pub fn invoke_uwp_tool() -> CmdResult { - wrap_err!(win_uwp::invoke_uwptools()) + win_uwp::invoke_uwptools().stringify_err() } } @@ -16,7 +17,7 @@ mod platform { mod platform { use super::CmdResult; - pub fn invoke_uwp_tool() -> CmdResult { + pub const fn invoke_uwp_tool() -> CmdResult { Ok(()) } } diff --git a/clash-verge-rev/src-tauri/src/cmd/validate.rs b/clash-verge-rev/src-tauri/src/cmd/validate.rs index d4ccfefae9..a0134f3403 100644 --- a/clash-verge-rev/src-tauri/src/cmd/validate.rs +++ b/clash-verge-rev/src-tauri/src/cmd/validate.rs @@ -1,10 +1,15 @@ use super::CmdResult; -use crate::{core::*, logging, utils::logging::Type}; +use crate::{ + core::{handle, validate::CoreConfigValidator}, + logging, + utils::logging::Type, +}; +use smartstring::alias::String; /// 发送脚本验证通知消息 #[tauri::command] pub async fn script_validate_notice(status: String, msg: String) -> CmdResult { - handle::Handle::notice_message(&status, &msg); + handle::Handle::notice_message(status.as_str(), msg.as_str()); Ok(()) } @@ -29,7 +34,7 @@ pub fn handle_script_validation_notice(result: &(bool, String), file_type: &str) }; logging!(warn, Type::Config, "{} 验证失败: {}", file_type, error_msg); - handle::Handle::notice_message(status, error_msg); + handle::Handle::notice_message(status, error_msg.to_owned()); } } @@ -38,10 +43,7 @@ pub fn handle_script_validation_notice(result: &(bool, String), file_type: &str) pub async fn validate_script_file(file_path: String) -> CmdResult { logging!(info, Type::Config, "验证脚本文件: {}", file_path); - match CoreManager::global() - .validate_config_file(&file_path, None) - .await - { + match CoreConfigValidator::validate_config_file(&file_path, None).await { Ok(result) => { handle_script_validation_notice(&result, "脚本文件"); Ok(result.0) // 返回验证结果布尔值 @@ -116,6 +118,6 @@ pub fn handle_yaml_validation_notice(result: &(bool, String), file_type: &str) { status, error_msg ); - handle::Handle::notice_message(status, error_msg); + handle::Handle::notice_message(status, error_msg.to_owned()); } } diff --git a/clash-verge-rev/src-tauri/src/cmd/verge.rs b/clash-verge-rev/src-tauri/src/cmd/verge.rs index 04f8a65195..943eb9db65 100644 --- a/clash-verge-rev/src-tauri/src/cmd/verge.rs +++ b/clash-verge-rev/src-tauri/src/cmd/verge.rs @@ -1,20 +1,14 @@ use super::CmdResult; -use crate::{config::*, feat, wrap_err}; +use crate::{cmd::StringifyErr as _, config::IVerge, feat, utils::draft::SharedBox}; /// 获取Verge配置 #[tauri::command] -pub async fn get_verge_config() -> CmdResult { - let verge = Config::verge().await; - let verge_data = { - let ref_data = verge.latest_ref(); - ref_data.clone() - }; - let verge_response = IVergeResponse::from(*verge_data); - Ok(verge_response) +pub async fn get_verge_config() -> CmdResult> { + feat::fetch_verge_config().await.stringify_err() } /// 修改Verge配置 #[tauri::command] pub async fn patch_verge_config(payload: IVerge) -> CmdResult { - wrap_err!(feat::patch_verge(payload, false).await) + feat::patch_verge(&payload, false).await.stringify_err() } diff --git a/clash-verge-rev/src-tauri/src/cmd/webdav.rs b/clash-verge-rev/src-tauri/src/cmd/webdav.rs index 7375ea5dbb..30e2465409 100644 --- a/clash-verge-rev/src-tauri/src/cmd/webdav.rs +++ b/clash-verge-rev/src-tauri/src/cmd/webdav.rs @@ -1,6 +1,11 @@ use super::CmdResult; -use crate::{config::*, core, feat, wrap_err}; +use crate::{ + cmd::StringifyErr as _, + config::{Config, IVerge}, + core, feat, +}; use reqwest_dav::list_cmd::ListFile; +use smartstring::alias::String; /// 保存 WebDAV 配置 #[tauri::command] @@ -11,18 +16,11 @@ pub async fn save_webdav_config(url: String, username: String, password: String) webdav_password: Some(password), ..IVerge::default() }; - Config::verge() - .await - .draft_mut() - .patch_config(patch.clone()); + Config::verge().await.edit_draft(|e| e.patch_config(&patch)); Config::verge().await.apply(); - // 分离数据获取和异步调用 - let verge_data = Config::verge().await.latest_ref().clone(); - verge_data - .save_file() - .await - .map_err(|err| err.to_string())?; + let verge_data = Config::verge().await.data_arc(); + verge_data.save_file().await.stringify_err()?; core::backup::WebDavClient::global().reset(); Ok(()) } @@ -30,23 +28,25 @@ pub async fn save_webdav_config(url: String, username: String, password: String) /// 创建 WebDAV 备份并上传 #[tauri::command] pub async fn create_webdav_backup() -> CmdResult<()> { - wrap_err!(feat::create_backup_and_upload_webdav().await) + feat::create_backup_and_upload_webdav() + .await + .stringify_err() } /// 列出 WebDAV 上的备份文件 #[tauri::command] pub async fn list_webdav_backup() -> CmdResult> { - wrap_err!(feat::list_wevdav_backup().await) + feat::list_wevdav_backup().await.stringify_err() } /// 删除 WebDAV 上的备份文件 #[tauri::command] pub async fn delete_webdav_backup(filename: String) -> CmdResult<()> { - wrap_err!(feat::delete_webdav_backup(filename).await) + feat::delete_webdav_backup(filename).await.stringify_err() } /// 从 WebDAV 恢复备份文件 #[tauri::command] pub async fn restore_webdav_backup(filename: String) -> CmdResult<()> { - wrap_err!(feat::restore_webdav_backup(filename).await) + feat::restore_webdav_backup(filename).await.stringify_err() } diff --git a/clash-verge-rev/src-tauri/src/config/clash.rs b/clash-verge-rev/src-tauri/src/config/clash.rs index f99fa3d8d8..095a0b8125 100644 --- a/clash-verge-rev/src-tauri/src/config/clash.rs +++ b/clash-verge-rev/src-tauri/src/config/clash.rs @@ -1,12 +1,14 @@ use crate::config::Config; +use crate::constants::{network, tun as tun_const}; use crate::utils::dirs::{ipc_path, path_to_str}; use crate::utils::{dirs, help}; +use crate::{logging, utils::logging::Type}; use anyhow::Result; use serde::{Deserialize, Serialize}; use serde_yaml_ng::{Mapping, Value}; use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, - str::FromStr, + str::FromStr as _, }; #[derive(Default, Debug, Clone)] @@ -35,12 +37,12 @@ impl IClashTemp { if let Some(Value::String(s)) = map.get_mut("secret") && s.is_empty() { - *s = "set-your-secret".to_string(); + *s = "set-your-secret".into(); } Self(Self::guard(map)) } Err(err) => { - log::error!(target: "app", "{err}"); + logging!(error, Type::Config, "{err}"); template } } @@ -48,29 +50,32 @@ impl IClashTemp { pub fn template() -> Self { let mut map = Mapping::new(); - let mut tun = Mapping::new(); + let mut tun_config = Mapping::new(); let mut cors_map = Mapping::new(); - tun.insert("enable".into(), false.into()); - #[cfg(target_os = "linux")] - tun.insert("stack".into(), "mixed".into()); - #[cfg(not(target_os = "linux"))] - tun.insert("stack".into(), "gvisor".into()); - tun.insert("auto-route".into(), true.into()); - tun.insert("strict-route".into(), false.into()); - tun.insert("auto-detect-interface".into(), true.into()); - tun.insert("dns-hijack".into(), vec!["any:53"].into()); + + tun_config.insert("enable".into(), false.into()); + tun_config.insert("stack".into(), tun_const::DEFAULT_STACK.into()); + tun_config.insert("auto-route".into(), true.into()); + tun_config.insert("strict-route".into(), false.into()); + tun_config.insert("auto-detect-interface".into(), true.into()); + tun_config.insert("dns-hijack".into(), tun_const::DNS_HIJACK.into()); + #[cfg(not(target_os = "windows"))] - map.insert("redir-port".into(), 7895.into()); + map.insert("redir-port".into(), network::ports::DEFAULT_REDIR.into()); #[cfg(target_os = "linux")] - map.insert("tproxy-port".into(), 7896.into()); - map.insert("mixed-port".into(), 7897.into()); - map.insert("socks-port".into(), 7898.into()); - map.insert("port".into(), 7899.into()); - map.insert("log-level".into(), "warning".into()); + map.insert("tproxy-port".into(), network::ports::DEFAULT_TPROXY.into()); + + map.insert("mixed-port".into(), network::ports::DEFAULT_MIXED.into()); + map.insert("socks-port".into(), network::ports::DEFAULT_SOCKS.into()); + map.insert("port".into(), network::ports::DEFAULT_HTTP.into()); + map.insert("log-level".into(), "info".into()); map.insert("allow-lan".into(), false.into()); map.insert("ipv6".into(), true.into()); map.insert("mode".into(), "rule".into()); - map.insert("external-controller".into(), "127.0.0.1:9097".into()); + map.insert( + "external-controller".into(), + network::DEFAULT_EXTERNAL_CONTROLLER.into(), + ); #[cfg(unix)] map.insert( "external-controller-unix".into(), @@ -81,6 +86,7 @@ impl IClashTemp { "external-controller-pipe".into(), Self::guard_external_controller_ipc().into(), ); + map.insert("tun".into(), tun_config.into()); cors_map.insert("allow-private-network".into(), true.into()); cors_map.insert( "allow-origins".into(), @@ -97,7 +103,6 @@ impl IClashTemp { .into(), ); map.insert("secret".into(), "set-your-secret".into()); - map.insert("tun".into(), tun.into()); map.insert("external-controller-cors".into(), cors_map.into()); map.insert("unified-delay".into(), true.into()); Self(map) @@ -209,9 +214,9 @@ impl IClashTemp { Value::Number(val_num) => val_num.as_u64().map(|u| u as u16), _ => None, }) - .unwrap_or(7896); + .unwrap_or(network::ports::DEFAULT_TPROXY); if port == 0 { - port = 7896; + port = network::ports::DEFAULT_TPROXY; } port } @@ -282,7 +287,7 @@ impl IClashTemp { } None => None, }) - .unwrap_or("127.0.0.1:9097".into()) + .unwrap_or_else(|| "127.0.0.1:9097".into()) } pub fn guard_external_controller(config: &Mapping) -> String { @@ -295,7 +300,7 @@ impl IClashTemp { // 检查 enable_external_controller 设置,用于运行时配置生成 let enable_external_controller = Config::verge() .await - .latest_ref() + .latest_arc() .enable_external_controller .unwrap_or(false); @@ -323,10 +328,10 @@ impl IClashTemp { // 总是使用当前的 IPC 路径,确保配置文件与运行时路径一致 ipc_path() .ok() - .and_then(|path| path_to_str(&path).ok().map(|s| s.to_string())) + .and_then(|path| path_to_str(&path).ok().map(|s| s.into())) .unwrap_or_else(|| { - log::error!(target: "app", "Failed to get IPC path, using default"); - "127.0.0.1:9090".to_string() + logging!(error, Type::Config, "Failed to get IPC path"); + crate::constants::network::DEFAULT_EXTERNAL_CONTROLLER.into() }) } } diff --git a/clash-verge-rev/src-tauri/src/config/config.rs b/clash-verge-rev/src-tauri/src/config/config.rs index 5416e542e7..ff49022626 100644 --- a/clash-verge-rev/src-tauri/src/config/config.rs +++ b/clash-verge-rev/src-tauri/src/config/config.rs @@ -1,78 +1,102 @@ -use super::{Draft, IClashTemp, IProfiles, IRuntime, IVerge}; +use super::{IClashTemp, IProfiles, IRuntime, IVerge}; use crate::{ + cmd, config::{PrfItem, profiles_append_item_safe}, - core::{CoreManager, handle}, - enhance, logging, - utils::{dirs, help, logging::Type}, + constants::{files, timing}, + core::{CoreManager, handle, service, tray, validate::CoreConfigValidator}, + enhance, logging, logging_error, + utils::{Draft, dirs, help, logging::Type}, }; use anyhow::{Result, anyhow}; use backoff::{Error as BackoffError, ExponentialBackoff}; +use smartstring::alias::String; use std::path::PathBuf; -use std::time::Duration; use tokio::sync::OnceCell; use tokio::time::sleep; -pub const RUNTIME_CONFIG: &str = "clash-verge.yaml"; -pub const CHECK_CONFIG: &str = "clash-verge-check.yaml"; - pub struct Config { - clash_config: Draft>, - verge_config: Draft>, - profiles_config: Draft>, - runtime_config: Draft>, + clash_config: Draft, + verge_config: Draft, + profiles_config: Draft, + runtime_config: Draft, } impl Config { - pub async fn global() -> &'static Config { + pub async fn global() -> &'static Self { static CONFIG: OnceCell = OnceCell::const_new(); CONFIG .get_or_init(|| async { - Config { - clash_config: Draft::from(Box::new(IClashTemp::new().await)), - verge_config: Draft::from(Box::new(IVerge::new().await)), - profiles_config: Draft::from(Box::new(IProfiles::new().await)), - runtime_config: Draft::from(Box::new(IRuntime::new())), + Self { + clash_config: Draft::new(IClashTemp::new().await), + verge_config: Draft::new(IVerge::new().await), + profiles_config: Draft::new(IProfiles::new().await), + runtime_config: Draft::new(IRuntime::new()), } }) .await } - pub async fn clash() -> Draft> { + pub async fn clash() -> Draft { Self::global().await.clash_config.clone() } - pub async fn verge() -> Draft> { + pub async fn verge() -> Draft { Self::global().await.verge_config.clone() } - pub async fn profiles() -> Draft> { + pub async fn profiles() -> Draft { Self::global().await.profiles_config.clone() } - pub async fn runtime() -> Draft> { + pub async fn runtime() -> Draft { Self::global().await.runtime_config.clone() } /// 初始化订阅 pub async fn init_config() -> Result<()> { - if Self::profiles() - .await - .latest_ref() - .get_item(&"Merge".to_string()) - .is_err() + Self::ensure_default_profile_items().await?; + + // init Tun mode + if !cmd::system::is_admin().unwrap_or_default() + && service::is_service_available().await.is_err() { - let merge_item = PrfItem::from_merge(Some("Merge".to_string()))?; - profiles_append_item_safe(merge_item.clone()).await?; + let verge = Self::verge().await; + verge.edit_draft(|d| { + d.enable_tun_mode = Some(false); + }); + verge.apply(); + let _ = tray::Tray::global().update_menu().await; + + // 分离数据获取和异步调用避免Send问题 + let verge_data = Self::verge().await.latest_arc(); + logging_error!(Type::Core, verge_data.save_file().await); } - if Self::profiles() - .await - .latest_ref() - .get_item(&"Script".to_string()) - .is_err() - { - let script_item = PrfItem::from_script(Some("Script".to_string()))?; - profiles_append_item_safe(script_item.clone()).await?; + + let validation_result = Self::generate_and_validate().await?; + + if let Some((msg_type, msg_content)) = validation_result { + sleep(timing::STARTUP_ERROR_DELAY).await; + handle::Handle::notice_message(msg_type, msg_content); } + + Ok(()) + } + + // Ensure "Merge" and "Script" profile items exist, adding them if missing. + async fn ensure_default_profile_items() -> Result<()> { + let profiles = Self::profiles().await; + if profiles.latest_arc().get_item("Merge").is_err() { + let merge_item = &mut PrfItem::from_merge(Some("Merge".into()))?; + profiles_append_item_safe(merge_item).await?; + } + if profiles.latest_arc().get_item("Script").is_err() { + let script_item = &mut PrfItem::from_script(Some("Script".into()))?; + profiles_append_item_safe(script_item).await?; + } + Ok(()) + } + + async fn generate_and_validate() -> Result> { // 生成运行时配置 if let Err(err) = Self::generate().await { logging!(error, Type::Config, "生成运行时配置失败: {}", err); @@ -83,11 +107,11 @@ impl Config { // 生成运行时配置文件并验证 let config_result = Self::generate_file(ConfigType::Run).await; - let validation_result = if config_result.is_ok() { + if config_result.is_ok() { // 验证配置文件 logging!(info, Type::Config, "开始验证配置"); - match CoreManager::global().validate_config().await { + match CoreConfigValidator::global().validate_config().await { Ok((is_valid, error_msg)) => { if !is_valid { logging!( @@ -99,12 +123,12 @@ impl Config { CoreManager::global() .use_default_config("config_validate::boot_error", &error_msg) .await?; - Some(("config_validate::boot_error", error_msg)) + Ok(Some(("config_validate::boot_error", error_msg))) } else { logging!(info, Type::Config, "配置验证成功"); // 前端没有必要知道验证成功的消息,也没有事件驱动 // Some(("config_validate::success", String::new())) - None + Ok(None) } } Err(err) => { @@ -112,7 +136,7 @@ impl Config { CoreManager::global() .use_default_config("config_validate::process_terminated", "") .await?; - Some(("config_validate::process_terminated", String::new())) + Ok(Some(("config_validate::process_terminated", String::new()))) } } } else { @@ -120,54 +144,42 @@ impl Config { CoreManager::global() .use_default_config("config_validate::error", "") .await?; - Some(("config_validate::error", String::new())) - }; - - // 在单独的任务中发送通知 - if let Some((msg_type, msg_content)) = validation_result { - sleep(Duration::from_secs(2)).await; - handle::Handle::notice_message(msg_type, &msg_content); + Ok(Some(("config_validate::error", String::new()))) } - - Ok(()) } - /// 将订阅丢到对应的文件中 pub async fn generate_file(typ: ConfigType) -> Result { let path = match typ { - ConfigType::Run => dirs::app_home_dir()?.join(RUNTIME_CONFIG), - ConfigType::Check => dirs::app_home_dir()?.join(CHECK_CONFIG), + ConfigType::Run => dirs::app_home_dir()?.join(files::RUNTIME_CONFIG), + ConfigType::Check => dirs::app_home_dir()?.join(files::CHECK_CONFIG), }; - let runtime = Config::runtime().await; - let config = runtime - .latest_ref() + let runtime = Self::runtime().await; + let runtime_arc = runtime.latest_arc(); + let config = runtime_arc .config .as_ref() - .ok_or(anyhow!("failed to get runtime config"))? - .clone(); - drop(runtime); // 显式释放锁 + .ok_or_else(|| anyhow!("failed to get runtime config"))?; - help::save_yaml(&path, &config, Some("# Generated by Clash Verge")).await?; + help::save_yaml(&path, config, Some("# Generated by Clash Verge")).await?; Ok(path) } - /// 生成订阅存好 pub async fn generate() -> Result<()> { let (config, exists_keys, logs) = enhance::enhance().await; - *Config::runtime().await.draft_mut() = Box::new(IRuntime { - config: Some(config), - exists_keys, - chain_logs: logs, + Self::runtime().await.edit_draft(|d| { + *d = IRuntime { + config: Some(config), + exists_keys, + chain_logs: logs, + } }); Ok(()) } pub async fn verify_config_initialization() { - logging!(info, Type::Setup, "Verifying config initialization..."); - let backoff_strategy = ExponentialBackoff { initial_interval: std::time::Duration::from_millis(100), max_interval: std::time::Duration::from_secs(2), @@ -177,49 +189,15 @@ impl Config { }; let operation = || async { - if Config::runtime().await.latest_ref().config.is_some() { - logging!( - info, - Type::Setup, - "Config initialization verified successfully" - ); + if Self::runtime().await.latest_arc().config.is_some() { return Ok::<(), BackoffError>(()); } - logging!( - warn, - Type::Setup, - "Runtime config not found, attempting to regenerate..." - ); - - match Config::generate().await { - Ok(_) => { - logging!(info, Type::Setup, "Config successfully regenerated"); - Ok(()) - } - Err(e) => { - logging!(warn, Type::Setup, "Failed to generate config: {}", e); - Err(BackoffError::transient(e)) - } - } + Self::generate().await.map_err(BackoffError::transient) }; - match backoff::future::retry(backoff_strategy, operation).await { - Ok(_) => { - logging!( - info, - Type::Setup, - "Config initialization verified with backoff retry" - ); - } - Err(e) => { - logging!( - error, - Type::Setup, - "Failed to verify config initialization after retries: {}", - e - ); - } + if let Err(e) = backoff::future::retry(backoff_strategy, operation).await { + logging!(error, Type::Setup, "Config init verification failed: {}", e); } } } @@ -238,8 +216,8 @@ mod tests { #[allow(unused_variables)] #[allow(clippy::expect_used)] fn test_prfitem_from_merge_size() { - let merge_item = PrfItem::from_merge(Some("Merge".to_string())) - .expect("Failed to create merge item in test"); + 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); @@ -252,7 +230,7 @@ mod tests { #[test] #[allow(unused_variables)] fn test_draft_size_non_boxed() { - let draft = Draft::from(IRuntime::new()); + let draft = Draft::new(IRuntime::new()); let iruntime_size = std::mem::size_of_val(&draft); assert_eq!(iruntime_size, std::mem::size_of::>()); } @@ -260,7 +238,7 @@ mod tests { #[test] #[allow(unused_variables)] fn test_draft_size_boxed() { - let draft = Draft::from(Box::new(IRuntime::new())); + let draft = Draft::new(Box::new(IRuntime::new())); let box_iruntime_size = std::mem::size_of_val(&draft); assert_eq!( box_iruntime_size, diff --git a/clash-verge-rev/src-tauri/src/config/draft.rs b/clash-verge-rev/src-tauri/src/config/draft.rs deleted file mode 100644 index ac82d685b9..0000000000 --- a/clash-verge-rev/src-tauri/src/config/draft.rs +++ /dev/null @@ -1,154 +0,0 @@ -use std::sync::Arc; - -use parking_lot::{ - MappedRwLockReadGuard, MappedRwLockWriteGuard, RwLock, RwLockReadGuard, - RwLockUpgradableReadGuard, RwLockWriteGuard, -}; - -#[derive(Debug, Clone)] -pub struct Draft { - inner: Arc)>>, -} - -impl From for Draft { - fn from(data: T) -> Self { - Self { - inner: Arc::new(RwLock::new((data, None))), - } - } -} - -/// Implements draft management for `Box`, allowing for safe concurrent editing and committing of draft data. -/// # Type Parameters -/// - `T`: The underlying data type, which must implement `Clone` and `ToOwned`. -/// -/// # Methods -/// - `data_mut`: Returns a mutable reference to the committed data. -/// - `data_ref`: Returns an immutable reference to the committed data. -/// - `draft_mut`: Creates or retrieves a mutable reference to the draft data, cloning the committed data if no draft exists. -/// - `latest_ref`: Returns an immutable reference to the draft data if it exists, otherwise to the committed data. -/// - `apply`: Commits the draft data, replacing the committed data and returning the old committed value if a draft existed. -/// - `discard`: Discards the draft data and returns it if it existed. -impl Draft> { - /// 可写正式数据 - pub fn data_mut(&self) -> MappedRwLockWriteGuard<'_, Box> { - RwLockWriteGuard::map(self.inner.write(), |inner| &mut inner.0) - } - - /// 返回正式数据的只读视图(不包含草稿) - pub fn data_ref(&self) -> MappedRwLockReadGuard<'_, Box> { - RwLockReadGuard::map(self.inner.read(), |inner| &inner.0) - } - - /// 创建或获取草稿并返回可写引用 - pub fn draft_mut(&self) -> MappedRwLockWriteGuard<'_, Box> { - let guard = self.inner.upgradable_read(); - if guard.1.is_none() { - let mut guard = RwLockUpgradableReadGuard::upgrade(guard); - guard.1 = Some(guard.0.clone()); - return RwLockWriteGuard::map(guard, |inner| { - inner.1.as_mut().unwrap_or_else(|| { - unreachable!("Draft was just created above, this should never fail") - }) - }); - } - // 已存在草稿,升级为写锁映射 - RwLockWriteGuard::map(RwLockUpgradableReadGuard::upgrade(guard), |inner| { - inner - .1 - .as_mut() - .unwrap_or_else(|| unreachable!("Draft should exist when guard.1.is_some()")) - }) - } - - /// 零拷贝只读视图:返回草稿(若存在)或正式值 - pub fn latest_ref(&self) -> MappedRwLockReadGuard<'_, Box> { - RwLockReadGuard::map(self.inner.read(), |inner| { - inner.1.as_ref().unwrap_or(&inner.0) - }) - } - - /// 提交草稿,返回旧正式数据 - pub fn apply(&self) -> Option> { - let mut inner = self.inner.write(); - inner - .1 - .take() - .map(|draft| std::mem::replace(&mut inner.0, draft)) - } - - /// 丢弃草稿,返回被丢弃的草稿 - pub fn discard(&self) -> Option> { - self.inner.write().1.take() - } -} - -#[test] -fn test_draft_box() { - use super::IVerge; - - // 1. 创建 Draft> - let verge = Box::new(IVerge { - enable_auto_launch: Some(true), - enable_tun_mode: Some(false), - ..IVerge::default() - }); - let draft = Draft::from(verge); - - // 2. 读取正式数据(data_mut) - { - let data = draft.data_mut(); - assert_eq!(data.enable_auto_launch, Some(true)); - assert_eq!(data.enable_tun_mode, Some(false)); - } - - // 3. 初次获取草稿(draft_mut 会自动 clone 一份) - { - let draft_view = draft.draft_mut(); - assert_eq!(draft_view.enable_auto_launch, Some(true)); - assert_eq!(draft_view.enable_tun_mode, Some(false)); - } - - // 4. 修改草稿 - { - let mut d = draft.draft_mut(); - d.enable_auto_launch = Some(false); - d.enable_tun_mode = Some(true); - } - - // 正式数据未变 - assert_eq!(draft.data_mut().enable_auto_launch, Some(true)); - assert_eq!(draft.data_mut().enable_tun_mode, Some(false)); - - // 草稿已变 - { - let latest = draft.latest_ref(); - assert_eq!(latest.enable_auto_launch, Some(false)); - assert_eq!(latest.enable_tun_mode, Some(true)); - } - - // 5. 提交草稿 - assert!(draft.apply().is_some()); // 第一次提交应有返回 - assert!(draft.apply().is_none()); // 第二次提交返回 None - - // 正式数据已更新 - { - let data = draft.data_mut(); - assert_eq!(data.enable_auto_launch, Some(false)); - assert_eq!(data.enable_tun_mode, Some(true)); - } - - // 6. 新建并修改下一轮草稿 - { - let mut d = draft.draft_mut(); - d.enable_auto_launch = Some(true); - } - assert_eq!(draft.draft_mut().enable_auto_launch, Some(true)); - - // 7. 丢弃草稿 - assert!(draft.discard().is_some()); // 第一次丢弃返回 Some - assert!(draft.discard().is_none()); // 再次丢弃返回 None - - // 8. 草稿已被丢弃,新的 draft_mut() 会重新 clone - assert_eq!(draft.draft_mut().enable_auto_launch, Some(false)); -} diff --git a/clash-verge-rev/src-tauri/src/config/encrypt.rs b/clash-verge-rev/src-tauri/src/config/encrypt.rs index 96ba577058..71743fae42 100644 --- a/clash-verge-rev/src-tauri/src/config/encrypt.rs +++ b/clash-verge-rev/src-tauri/src/config/encrypt.rs @@ -1,14 +1,22 @@ use crate::utils::dirs::get_encryption_key; use aes_gcm::{ Aes256Gcm, Key, - aead::{Aead, KeyInit}, + aead::{Aead as _, KeyInit as _}, }; -use base64::{Engine, engine::general_purpose::STANDARD}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::cell::Cell; +use std::future::Future; const NONCE_LENGTH: usize = 12; +// Use task-local context so the flag follows the async task across threads +tokio::task_local! { + static ENCRYPTION_ACTIVE: Cell; +} + /// Encrypt data +#[allow(deprecated)] pub fn encrypt_data(data: &str) -> Result> { let encryption_key = get_encryption_key()?; let key = Key::::from_slice(&encryption_key); @@ -30,6 +38,7 @@ pub fn encrypt_data(data: &str) -> Result> { } /// Decrypt data +#[allow(deprecated)] pub fn decrypt_data(encrypted: &str) -> Result> { let encryption_key = get_encryption_key()?; let key = Key::::from_slice(&encryption_key); @@ -57,39 +66,45 @@ where T: Serialize, S: Serializer, { - // 如果序列化失败,返回 None - let json = match serde_json::to_string(value) { - Ok(j) => j, - Err(_) => return serializer.serialize_none(), - }; - - // 如果加密失败,返回 None - match encrypt_data(&json) { - Ok(encrypted) => serializer.serialize_str(&encrypted), - Err(_) => serializer.serialize_none(), + if is_encryption_active() { + let json = serde_json::to_string(value).map_err(serde::ser::Error::custom)?; + let encrypted = encrypt_data(&json).map_err(serde::ser::Error::custom)?; + serializer.serialize_str(&encrypted) + } else { + value.serialize(serializer) } } /// Deserialize decrypted function -pub fn deserialize_encrypted<'a, T, D>(deserializer: D) -> Result +pub fn deserialize_encrypted<'a, D, T>(deserializer: D) -> Result where T: for<'de> Deserialize<'de> + Default, D: Deserializer<'a>, { - // 如果反序列化字符串失败,返回默认值 - let encrypted = match String::deserialize(deserializer) { - Ok(s) => s, - Err(_) => return Ok(T::default()), - }; + if is_encryption_active() { + let encrypted_opt: Option = Option::deserialize(deserializer)?; - // 如果解密失败,返回默认值 - let decrypted_string = match decrypt_data(&encrypted) { - Ok(data) => data, - Err(_) => return Ok(T::default()), - }; - // 如果 JSON 解析失败,返回默认值 - match serde_json::from_str(&decrypted_string) { - Ok(value) => Ok(value), - Err(_) => Ok(T::default()), + match encrypted_opt { + Some(encrypted) if !encrypted.is_empty() => { + let decrypted_string = + decrypt_data(&encrypted).map_err(serde::de::Error::custom)?; + serde_json::from_str(&decrypted_string).map_err(serde::de::Error::custom) + } + _ => Ok(T::default()), + } + } else { + T::deserialize(deserializer) } } + +pub async fn with_encryption(f: F) -> R +where + F: FnOnce() -> Fut, + Fut: Future, +{ + ENCRYPTION_ACTIVE.scope(Cell::new(true), f()).await +} + +fn is_encryption_active() -> bool { + ENCRYPTION_ACTIVE.try_with(|c| c.get()).unwrap_or(false) +} diff --git a/clash-verge-rev/src-tauri/src/config/mod.rs b/clash-verge-rev/src-tauri/src/config/mod.rs index 5116875711..342c11f47d 100644 --- a/clash-verge-rev/src-tauri/src/config/mod.rs +++ b/clash-verge-rev/src-tauri/src/config/mod.rs @@ -1,16 +1,13 @@ mod clash; #[allow(clippy::module_inception)] mod config; -mod draft; mod encrypt; mod prfitem; pub mod profiles; mod runtime; mod verge; -pub use self::{ - clash::*, config::*, draft::*, encrypt::*, prfitem::*, profiles::*, runtime::*, verge::*, -}; +pub use self::{clash::*, config::*, encrypt::*, prfitem::*, profiles::*, runtime::*, verge::*}; pub const DEFAULT_PAC: &str = r#"function FindProxyForURL(url, host) { return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;"; diff --git a/clash-verge-rev/src-tauri/src/config/prfitem.rs b/clash-verge-rev/src-tauri/src/config/prfitem.rs index bb748501a6..bdbac01837 100644 --- a/clash-verge-rev/src-tauri/src/config/prfitem.rs +++ b/clash-verge-rev/src-tauri/src/config/prfitem.rs @@ -1,12 +1,17 @@ -use crate::utils::{ - dirs, help, - network::{NetworkManager, ProxyType}, - tmpl, +use crate::{ + config::profiles, + utils::{ + dirs, help, + network::{NetworkManager, ProxyType}, + tmpl, + }, }; -use anyhow::{Context, Result, bail}; +use anyhow::{Context as _, Result, bail}; use serde::{Deserialize, Serialize}; use serde_yaml_ng::Mapping; -use std::{fs, time::Duration}; +use smartstring::alias::String; +use std::time::Duration; +use tokio::fs; #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct PrfItem { @@ -101,6 +106,10 @@ pub struct PrfOption { #[serde(skip_serializing_if = "Option::is_none")] pub danger_accept_invalid_certs: Option, + #[serde(default = "default_allow_auto_update")] + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_auto_update: Option, + pub merge: Option, pub script: Option, @@ -113,25 +122,29 @@ pub struct PrfOption { } impl PrfOption { - pub fn merge(one: Option, other: Option) -> Option { + pub fn merge(one: Option<&Self>, other: Option<&Self>) -> Option { match (one, other) { - (Some(mut a), Some(b)) => { - a.user_agent = b.user_agent.or(a.user_agent); - a.with_proxy = b.with_proxy.or(a.with_proxy); - a.self_proxy = b.self_proxy.or(a.self_proxy); - a.danger_accept_invalid_certs = b + (Some(a_ref), Some(b_ref)) => { + let mut result = a_ref.clone(); + result.user_agent = b_ref.user_agent.clone().or(result.user_agent); + result.with_proxy = b_ref.with_proxy.or(result.with_proxy); + result.self_proxy = b_ref.self_proxy.or(result.self_proxy); + result.danger_accept_invalid_certs = b_ref .danger_accept_invalid_certs - .or(a.danger_accept_invalid_certs); - a.update_interval = b.update_interval.or(a.update_interval); - a.merge = b.merge.or(a.merge); - a.script = b.script.or(a.script); - a.rules = b.rules.or(a.rules); - a.proxies = b.proxies.or(a.proxies); - a.groups = b.groups.or(a.groups); - a.timeout_seconds = b.timeout_seconds.or(a.timeout_seconds); - Some(a) + .or(result.danger_accept_invalid_certs); + result.allow_auto_update = b_ref.allow_auto_update.or(result.allow_auto_update); + result.update_interval = b_ref.update_interval.or(result.update_interval); + result.merge = b_ref.merge.clone().or(result.merge); + result.script = b_ref.script.clone().or(result.script); + result.rules = b_ref.rules.clone().or(result.rules); + result.proxies = b_ref.proxies.clone().or(result.proxies); + result.groups = b_ref.groups.clone().or(result.groups); + result.timeout_seconds = b_ref.timeout_seconds.or(result.timeout_seconds); + Some(result) } - t => t.0.or(t.1), + (Some(a_ref), None) => Some(a_ref.clone()), + (None, Some(b_ref)) => Some(b_ref.clone()), + (None, None) => None, } } } @@ -139,13 +152,14 @@ impl PrfOption { impl PrfItem { /// From partial item /// must contain `itype` - pub async fn from(item: PrfItem, file_data: Option) -> Result { + pub async fn from(item: &Self, file_data: Option) -> Result { if item.itype.is_none() { bail!("type should not be null"); } let itype = item .itype + .as_ref() .ok_or_else(|| anyhow::anyhow!("type should not be null"))?; match itype.as_str() { "remote" => { @@ -153,14 +167,16 @@ impl PrfItem { .url .as_ref() .ok_or_else(|| anyhow::anyhow!("url should not be null"))?; - let name = item.name; - let desc = item.desc; - PrfItem::from_url(url, name, desc, item.option).await + let name = item.name.as_ref(); + let desc = item.desc.as_ref(); + let option = item.option.as_ref(); + Self::from_url(url, name, desc, option).await } "local" => { - let name = item.name.unwrap_or("Local File".into()); - let desc = item.desc.unwrap_or("".into()); - PrfItem::from_local(name, desc, file_data, item.option).await + let name = item.name.clone().unwrap_or_else(|| "Local File".into()); + let desc = item.desc.clone().unwrap_or_else(|| "".into()); + let option = item.option.as_ref(); + Self::from_local(name, desc, file_data, option).await } typ => bail!("invalid profile item type \"{typ}\""), } @@ -172,10 +188,10 @@ impl PrfItem { name: String, desc: String, file_data: Option, - option: Option, - ) -> Result { - let uid = help::get_uid("L"); - let file = format!("{uid}.yaml"); + option: Option<&PrfOption>, + ) -> Result { + let uid = help::get_uid("L").into(); + let file = format!("{uid}.yaml").into(); let opt_ref = option.as_ref(); let update_interval = opt_ref.and_then(|o| o.update_interval); let mut merge = opt_ref.and_then(|o| o.merge.clone()); @@ -185,31 +201,31 @@ impl PrfItem { let mut groups = opt_ref.and_then(|o| o.groups.clone()); if merge.is_none() { - let merge_item = PrfItem::from_merge(None)?; - crate::config::profiles::profiles_append_item_safe(merge_item.clone()).await?; - merge = merge_item.uid; + let merge_item = &mut Self::from_merge(None)?; + profiles::profiles_append_item_safe(merge_item).await?; + merge = merge_item.uid.clone(); } if script.is_none() { - let script_item = PrfItem::from_script(None)?; - crate::config::profiles::profiles_append_item_safe(script_item.clone()).await?; - script = script_item.uid; + let script_item = &mut Self::from_script(None)?; + profiles::profiles_append_item_safe(script_item).await?; + script = script_item.uid.clone(); } if rules.is_none() { - let rules_item = PrfItem::from_rules()?; - crate::config::profiles::profiles_append_item_safe(rules_item.clone()).await?; - rules = rules_item.uid; + let rules_item = &mut Self::from_rules()?; + profiles::profiles_append_item_safe(rules_item).await?; + rules = rules_item.uid.clone(); } if proxies.is_none() { - let proxies_item = PrfItem::from_proxies()?; - crate::config::profiles::profiles_append_item_safe(proxies_item.clone()).await?; - proxies = proxies_item.uid; + let proxies_item = &mut Self::from_proxies()?; + profiles::profiles_append_item_safe(proxies_item).await?; + proxies = proxies_item.uid.clone(); } if groups.is_none() { - let groups_item = PrfItem::from_groups()?; - crate::config::profiles::profiles_append_item_safe(groups_item.clone()).await?; - groups = groups_item.uid; + let groups_item = &mut Self::from_groups()?; + profiles::profiles_append_item_safe(groups_item).await?; + groups = groups_item.uid.clone(); } - Ok(PrfItem { + Ok(Self { uid: Some(uid), itype: Some("local".into()), name: Some(name), @@ -229,7 +245,7 @@ impl PrfItem { }), home: None, updated: Some(chrono::Local::now().timestamp() as usize), - file_data: Some(file_data.unwrap_or(tmpl::ITEM_LOCAL.into())), + file_data: Some(file_data.unwrap_or_else(|| tmpl::ITEM_LOCAL.into())), }) } @@ -237,23 +253,23 @@ impl PrfItem { /// create a new item from url pub async fn from_url( url: &str, - name: Option, - desc: Option, - option: Option, - ) -> Result { - let opt_ref = option.as_ref(); - let with_proxy = opt_ref.is_some_and(|o| o.with_proxy.unwrap_or(false)); - let self_proxy = opt_ref.is_some_and(|o| o.self_proxy.unwrap_or(false)); + name: Option<&String>, + desc: Option<&String>, + option: Option<&PrfOption>, + ) -> Result { + let with_proxy = option.is_some_and(|o| o.with_proxy.unwrap_or(false)); + let self_proxy = option.is_some_and(|o| o.self_proxy.unwrap_or(false)); let accept_invalid_certs = - opt_ref.is_some_and(|o| o.danger_accept_invalid_certs.unwrap_or(false)); - let user_agent = opt_ref.and_then(|o| o.user_agent.clone()); - let update_interval = opt_ref.and_then(|o| o.update_interval); - let timeout = opt_ref.and_then(|o| o.timeout_seconds).unwrap_or(20); - let mut merge = opt_ref.and_then(|o| o.merge.clone()); - let mut script = opt_ref.and_then(|o| o.script.clone()); - let mut rules = opt_ref.and_then(|o| o.rules.clone()); - let mut proxies = opt_ref.and_then(|o| o.proxies.clone()); - let mut groups = opt_ref.and_then(|o| o.groups.clone()); + option.is_some_and(|o| o.danger_accept_invalid_certs.unwrap_or(false)); + let allow_auto_update = option.map(|o| o.allow_auto_update.unwrap_or(true)); + let user_agent = option.and_then(|o| o.user_agent.clone()); + let update_interval = option.and_then(|o| o.update_interval); + let timeout = option.and_then(|o| o.timeout_seconds).unwrap_or(20); + let mut merge = option.and_then(|o| o.merge.clone()); + let mut script = option.and_then(|o| o.script.clone()); + let mut rules = option.and_then(|o| o.rules.clone()); + 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 { @@ -290,18 +306,27 @@ impl PrfItem { let header = resp.headers(); // parse the Subscription UserInfo - let extra = match header.get("Subscription-Userinfo") { - Some(value) => { - let sub_info = value.to_str().unwrap_or(""); - Some(PrfExtra { - upload: help::parse_str(sub_info, "upload").unwrap_or(0), - download: help::parse_str(sub_info, "download").unwrap_or(0), - total: help::parse_str(sub_info, "total").unwrap_or(0), - expire: help::parse_str(sub_info, "expire").unwrap_or(0), - }) + let extra; + 'extra: { + for (k, v) in header.iter() { + let key_lower = k.as_str().to_ascii_lowercase(); + // Accept standard custom-metadata prefixes (x-amz-meta-, x-obs-meta-, x-cos-meta-, etc.). + if key_lower + .strip_suffix("subscription-userinfo") + .is_some_and(|prefix| prefix.is_empty() || prefix.ends_with('-')) + { + let sub_info = v.to_str().unwrap_or(""); + extra = Some(PrfExtra { + upload: help::parse_str(sub_info, "upload").unwrap_or(0), + download: help::parse_str(sub_info, "download").unwrap_or(0), + total: help::parse_str(sub_info, "total").unwrap_or(0), + expire: help::parse_str(sub_info, "expire").unwrap_or(0), + }); + break 'extra; + } } - None => None, - }; + extra = None; + } // parse the Content-Disposition let filename = match header.get("Content-Disposition") { @@ -312,19 +337,20 @@ impl PrfItem { Some(filename) => { let iter = percent_encoding::percent_decode(filename.as_bytes()); let filename = iter.decode_utf8().unwrap_or_default(); - filename.split("''").last().map(|s| s.to_string()) + filename.split("''").last().map(|s| s.into()) } None => match help::parse_str::(filename, "filename") { Some(filename) => { let filename = filename.trim_matches('"'); - Some(filename.to_string()) + Some(filename.into()) } None => None, }, } } None => Some( - crate::utils::help::get_last_part_and_decode(url).unwrap_or("Remote File".into()), + crate::utils::help::get_last_part_and_decode(url) + .unwrap_or_else(|| "Remote File".into()), ), }; let update_interval = match update_interval { @@ -341,14 +367,18 @@ impl PrfItem { let home = match header.get("profile-web-page-url") { Some(value) => { let str_value = value.to_str().unwrap_or(""); - Some(str_value.to_string()) + Some(str_value.into()) } None => None, }; - let uid = help::get_uid("R"); - let file = format!("{uid}.yaml"); - let name = name.unwrap_or(filename.unwrap_or("Remote File".into())); + let uid = help::get_uid("R").into(); + let file = format!("{uid}.yaml").into(); + let name = name.map(|s| s.to_owned()).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" @@ -363,36 +393,36 @@ impl PrfItem { } if merge.is_none() { - let merge_item = PrfItem::from_merge(None)?; - crate::config::profiles::profiles_append_item_safe(merge_item.clone()).await?; - merge = merge_item.uid; + let merge_item = &mut Self::from_merge(None)?; + profiles::profiles_append_item_safe(merge_item).await?; + merge = merge_item.uid.clone(); } if script.is_none() { - let script_item = PrfItem::from_script(None)?; - crate::config::profiles::profiles_append_item_safe(script_item.clone()).await?; - script = script_item.uid; + let script_item = &mut Self::from_script(None)?; + profiles::profiles_append_item_safe(script_item).await?; + script = script_item.uid.clone(); } if rules.is_none() { - let rules_item = PrfItem::from_rules()?; - crate::config::profiles::profiles_append_item_safe(rules_item.clone()).await?; - rules = rules_item.uid; + let rules_item = &mut Self::from_rules()?; + profiles::profiles_append_item_safe(rules_item).await?; + rules = rules_item.uid.clone(); } if proxies.is_none() { - let proxies_item = PrfItem::from_proxies()?; - crate::config::profiles::profiles_append_item_safe(proxies_item.clone()).await?; - proxies = proxies_item.uid; + let proxies_item = &mut Self::from_proxies()?; + profiles::profiles_append_item_safe(proxies_item).await?; + proxies = proxies_item.uid.clone(); } if groups.is_none() { - let groups_item = PrfItem::from_groups()?; - crate::config::profiles::profiles_append_item_safe(groups_item.clone()).await?; - groups = groups_item.uid; + let groups_item = &mut Self::from_groups()?; + profiles::profiles_append_item_safe(groups_item).await?; + groups = groups_item.uid.clone(); } - Ok(PrfItem { + Ok(Self { uid: Some(uid), itype: Some("remote".into()), name: Some(name), - desc, + desc: desc.cloned(), file: Some(file), url: Some(url.into()), selected: None, @@ -404,6 +434,7 @@ impl PrfItem { rules, proxies, groups, + allow_auto_update, ..PrfOption::default() }), home, @@ -414,136 +445,142 @@ impl PrfItem { /// ## Merge type (enhance) /// create the enhanced item by using `merge` rule - pub fn from_merge(uid: Option) -> Result { - let mut id = help::get_uid("m"); - let mut template = tmpl::ITEM_MERGE_EMPTY.into(); - if let Some(uid) = uid { - id = uid; - template = tmpl::ITEM_MERGE.into(); - } - let file = format!("{id}.yaml"); + pub fn from_merge(uid: Option) -> Result { + let (id, template) = if let Some(uid) = uid { + (uid, tmpl::ITEM_MERGE.into()) + } else { + (help::get_uid("m").into(), tmpl::ITEM_MERGE_EMPTY.into()) + }; + let file = format!("{id}.yaml").into(); - Ok(PrfItem { + Ok(Self { uid: Some(id), itype: Some("merge".into()), - name: None, - desc: None, file: Some(file), - url: None, - selected: None, - extra: None, - option: None, - home: None, updated: Some(chrono::Local::now().timestamp() as usize), file_data: Some(template), + ..Default::default() }) } /// ## Script type (enhance) /// create the enhanced item by using javascript quick.js - pub fn from_script(uid: Option) -> Result { - let mut id = help::get_uid("s"); - if let Some(uid) = uid { - id = uid; - } - let file = format!("{id}.js"); // js ext - - Ok(PrfItem { + pub fn from_script(uid: Option) -> Result { + let id = if let Some(uid) = uid { + uid + } else { + help::get_uid("s").into() + }; + let file = format!("{id}.js").into(); // js ext + Ok(Self { uid: Some(id), itype: Some("script".into()), - name: None, - desc: None, file: Some(file), - url: None, - home: None, - selected: None, - extra: None, - option: None, updated: Some(chrono::Local::now().timestamp() as usize), file_data: Some(tmpl::ITEM_SCRIPT.into()), + ..Default::default() }) } /// ## Rules type (enhance) - pub fn from_rules() -> Result { - let uid = help::get_uid("r"); - let file = format!("{uid}.yaml"); // yaml ext + pub fn from_rules() -> Result { + let uid = help::get_uid("r").into(); + let file = format!("{uid}.yaml").into(); // yaml ext - Ok(PrfItem { + Ok(Self { uid: Some(uid), itype: Some("rules".into()), - name: None, - desc: None, file: Some(file), - url: None, - home: None, - selected: None, - extra: None, - option: None, updated: Some(chrono::Local::now().timestamp() as usize), file_data: Some(tmpl::ITEM_RULES.into()), + ..Default::default() }) } /// ## Proxies type (enhance) - pub fn from_proxies() -> Result { - let uid = help::get_uid("p"); - let file = format!("{uid}.yaml"); // yaml ext + pub fn from_proxies() -> Result { + let uid = help::get_uid("p").into(); + let file = format!("{uid}.yaml").into(); // yaml ext - Ok(PrfItem { + Ok(Self { uid: Some(uid), itype: Some("proxies".into()), - name: None, - desc: None, file: Some(file), - url: None, - home: None, - selected: None, - extra: None, - option: None, updated: Some(chrono::Local::now().timestamp() as usize), file_data: Some(tmpl::ITEM_PROXIES.into()), + ..Default::default() }) } /// ## Groups type (enhance) - pub fn from_groups() -> Result { - let uid = help::get_uid("g"); - let file = format!("{uid}.yaml"); // yaml ext + pub fn from_groups() -> Result { + let uid = help::get_uid("g").into(); + let file = format!("{uid}.yaml").into(); // yaml ext - Ok(PrfItem { + Ok(Self { uid: Some(uid), itype: Some("groups".into()), - name: None, - desc: None, file: Some(file), - url: None, - home: None, - selected: None, - extra: None, - option: None, updated: Some(chrono::Local::now().timestamp() as usize), file_data: Some(tmpl::ITEM_GROUPS.into()), + ..Default::default() }) } /// get the file data - pub fn read_file(&self) -> Result { + pub async fn read_file(&self) -> Result { let file = self .file - .clone() + .as_ref() .ok_or_else(|| anyhow::anyhow!("could not find the file"))?; - let path = dirs::app_profiles_dir()?.join(file); - fs::read_to_string(path).context("failed to read the file") + let path = dirs::app_profiles_dir()?.join(file.as_str()); + let content = fs::read_to_string(path) + .await + .context("failed to read the file")?; + Ok(content.into()) } /// save the file data - pub fn save_file(&self, data: String) -> Result<()> { + pub async fn save_file(&self, data: String) -> Result<()> { let file = self .file - .clone() + .as_ref() .ok_or_else(|| anyhow::anyhow!("could not find the file"))?; - let path = dirs::app_profiles_dir()?.join(file); - fs::write(path, data.as_bytes()).context("failed to save the file") + let path = dirs::app_profiles_dir()?.join(file.as_str()); + fs::write(path, data.as_bytes()) + .await + .context("failed to save the file") } } + +impl PrfItem { + /// 获取current指向的订阅的merge + pub fn current_merge(&self) -> Option { + self.option.as_ref().and_then(|o| o.merge.clone()) + } + + /// 获取current指向的订阅的script + pub fn current_script(&self) -> Option { + self.option.as_ref().and_then(|o| o.script.clone()) + } + + /// 获取current指向的订阅的rules + pub fn current_rules(&self) -> Option { + self.option.as_ref().and_then(|o| o.rules.clone()) + } + + /// 获取current指向的订阅的proxies + pub fn current_proxies(&self) -> Option { + self.option.as_ref().and_then(|o| o.proxies.clone()) + } + + /// 获取current指向的订阅的groups + pub fn current_groups(&self) -> Option { + self.option.as_ref().and_then(|o| o.groups.clone()) + } +} + +// 向前兼容,默认为订阅启用自动更新 +const fn default_allow_auto_update() -> Option { + Some(true) +} diff --git a/clash-verge-rev/src-tauri/src/config/profiles.rs b/clash-verge-rev/src-tauri/src/config/profiles.rs index 2faab96f99..0f8a836716 100644 --- a/clash-verge-rev/src-tauri/src/config/profiles.rs +++ b/clash-verge-rev/src-tauri/src/config/profiles.rs @@ -1,13 +1,14 @@ use super::{PrfOption, prfitem::PrfItem}; -use crate::{ - logging_error, - process::AsyncHandler, - utils::{dirs, help, logging::Type}, +use crate::utils::{ + dirs::{self, PathBufExec as _}, + help, }; -use anyhow::{Context, Result, bail}; +use crate::{logging, utils::logging::Type}; +use anyhow::{Context as _, Result, bail}; use serde::{Deserialize, Serialize}; use serde_yaml_ng::Mapping; -use std::collections::HashSet; +use smartstring::alias::String; +use std::{collections::HashSet, sync::Arc}; use tokio::fs; /// Define the `profiles.yaml` schema @@ -31,45 +32,48 @@ pub struct CleanupResult { macro_rules! patch { ($lv: expr, $rv: expr, $key: tt) => { if ($rv.$key).is_some() { - $lv.$key = $rv.$key; + $lv.$key = $rv.$key.to_owned(); } }; } impl IProfiles { - pub async fn new() -> Self { - match dirs::profiles_path() { - Ok(path) => match help::read_yaml::(&path).await { - Ok(mut profiles) => { - if profiles.items.is_none() { - profiles.items = Some(vec![]); - } - // compatible with the old old old version - if let Some(items) = profiles.items.as_mut() { - for item in items.iter_mut() { - if item.uid.is_none() { - item.uid = Some(help::get_uid("d")); - } - } - } - profiles - } - Err(err) => { - log::error!(target: "app", "{err}"); - Self::template() - } - }, - Err(err) => { - log::error!(target: "app", "{err}"); - Self::template() + // 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, + ) -> Option { + for (i, _) in items.iter().enumerate() { + if items[i].uid == target_uid { + return items.remove(i).file; } } + None } - pub fn template() -> Self { - Self { - items: Some(vec![]), - ..Self::default() + pub async fn new() -> Self { + let path = match dirs::profiles_path() { + Ok(p) => p, + Err(err) => { + logging!(error, Type::Config, "{err}"); + return Self::default(); + } + }; + + match help::read_yaml::(&path).await { + Ok(mut profiles) => { + let items = profiles.items.get_or_insert_with(Vec::new); + for item in items.iter_mut() { + if item.uid.is_none() { + item.uid = Some(help::get_uid("d").into()); + } + } + profiles + } + Err(err) => { + logging!(error, Type::Config, "{err}"); + Self::default() + } } } @@ -83,55 +87,64 @@ impl IProfiles { } /// 只修改current,valid和chain - pub fn patch_config(&mut self, patch: IProfiles) -> Result<()> { + pub fn patch_config(&mut self, patch: &Self) { if self.items.is_none() { self.items = Some(vec![]); } - if let Some(current) = patch.current + if let Some(current) = &patch.current && let Some(items) = self.items.as_ref() { let some_uid = Some(current); - if items.iter().any(|e| e.uid == some_uid) { - self.current = some_uid; + if items.iter().any(|e| e.uid.as_ref() == some_uid) { + self.current = some_uid.cloned(); } } - - Ok(()) } - pub fn get_current(&self) -> Option { - self.current.clone() + pub const fn get_current(&self) -> Option<&String> { + self.current.as_ref() } /// get items ref - pub fn get_items(&self) -> Option<&Vec> { + pub const fn get_items(&self) -> Option<&Vec> { self.items.as_ref() } /// find the item by the uid - pub fn get_item(&self, uid: &String) -> Result<&PrfItem> { - if let Some(items) = self.items.as_ref() { - let some_uid = Some(uid.clone()); + pub fn get_item(&self, uid: impl AsRef) -> Result<&PrfItem> { + let uid_str = uid.as_ref(); + if let Some(items) = self.items.as_ref() { for each in items.iter() { - if each.uid == some_uid { + if let Some(uid_val) = &each.uid + && uid_val.as_str() == uid_str + { return Ok(each); } } } - bail!("failed to get the profile item \"uid:{uid}\""); + bail!("failed to get the profile item \"uid:{}\"", uid_str); + } + + pub fn get_item_arc(&self, uid: &str) -> Option> { + self.items.as_ref().and_then(|items| { + items + .iter() + .find(|it| it.uid.as_deref() == Some(uid)) + .map(|it| Arc::new(it.clone())) + }) } /// append new item /// if the file_data is some /// then should save the data to file - pub async fn append_item(&mut self, mut item: PrfItem) -> Result<()> { - if item.uid.is_none() { + 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"); } - let uid = item.uid.clone(); // save the file data // move the field value after save @@ -143,7 +156,7 @@ impl IProfiles { let file = item.file.clone().ok_or_else(|| { anyhow::anyhow!("file field is required when file_data is provided") })?; - let path = dirs::app_profiles_dir()?.join(&file); + let path = dirs::app_profiles_dir()?.join(file.as_str()); fs::write(&path, file_data.as_bytes()) .await @@ -151,9 +164,9 @@ impl IProfiles { } if self.current.is_none() - && (item.itype == Some("remote".to_string()) || item.itype == Some("local".to_string())) + && (item.itype == Some("remote".into()) || item.itype == Some("local".into())) { - self.current = uid; + self.current = uid.to_owned(); } if self.items.is_none() { @@ -161,23 +174,23 @@ impl IProfiles { } if let Some(items) = self.items.as_mut() { - items.push(item) + items.push(item.to_owned()); } - self.save_file().await + Ok(()) } /// reorder items - pub async fn reorder(&mut self, active_id: String, over_id: String) -> Result<()> { + 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; let mut new_index = None; for (i, _) in items.iter().enumerate() { - if items[i].uid == Some(active_id.clone()) { + if items[i].uid.as_ref() == Some(active_id) { old_index = Some(i); } - if items[i].uid == Some(over_id.clone()) { + if items[i].uid.as_ref() == Some(over_id) { new_index = Some(i); } } @@ -193,11 +206,11 @@ impl IProfiles { } /// update the item value - pub async fn patch_item(&mut self, uid: String, item: PrfItem) -> Result<()> { + pub async fn patch_item(&mut self, uid: &String, item: &PrfItem) -> Result<()> { let mut items = self.items.take().unwrap_or_default(); for each in items.iter_mut() { - if each.uid == Some(uid.clone()) { + if each.uid.as_ref() == Some(uid) { patch!(each, item, itype); patch!(each, item, name); patch!(each, item, desc); @@ -219,13 +232,13 @@ impl IProfiles { /// be used to update the remote item /// only patch `updated` `extra` `file_data` - pub async fn update_item(&mut self, uid: String, mut item: PrfItem) -> Result<()> { + 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)?; + let _ = self.get_item(uid)?; if let Some(items) = self.items.as_mut() { let some_uid = Some(uid.clone()); @@ -234,19 +247,22 @@ impl IProfiles { if each.uid == some_uid { each.extra = item.extra; each.updated = item.updated; - each.home = item.home; - each.option = PrfOption::merge(each.option.clone(), item.option); + 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(item.file.take().unwrap_or(format!("{}.yaml", &uid))); + 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); + let path = dirs::app_profiles_dir()?.join(file.as_str()); fs::write(&path, file_data.as_bytes()) .await @@ -263,180 +279,61 @@ impl IProfiles { /// delete item /// if delete the current then return true - pub async fn delete_item(&mut self, uid: String) -> Result { - let current = self.current.as_ref().unwrap_or(&uid); + pub async fn delete_item(&mut self, uid: &String) -> Result { + let current = self.current.as_ref().unwrap_or(uid); let current = current.clone(); - let item = self.get_item(&uid)?; + let item = self.get_item(uid)?; let merge_uid = item.option.as_ref().and_then(|e| e.merge.clone()); let script_uid = item.option.as_ref().and_then(|e| e.script.clone()); let rules_uid = item.option.as_ref().and_then(|e| e.rules.clone()); let proxies_uid = item.option.as_ref().and_then(|e| e.proxies.clone()); let groups_uid = item.option.as_ref().and_then(|e| e.groups.clone()); let mut items = self.items.take().unwrap_or_default(); - let mut index = None; - let mut merge_index = None; - let mut script_index = None; - let mut rules_index = None; - let mut proxies_index = None; - let mut groups_index = None; - // get the index - for (i, _) in items.iter().enumerate() { - if items[i].uid == Some(uid.clone()) { - index = Some(i); - break; - } + // remove the main item (if exists) and delete its file + if let Some(file) = Self::take_item_file_by_uid(&mut items, Some(uid.clone())) { + let _ = dirs::app_profiles_dir()? + .join(file.as_str()) + .remove_if_exists() + .await; } - if let Some(index) = index - && let Some(file) = items.remove(index).file - { - let _ = dirs::app_profiles_dir().map(async move |path| { - let path = path.join(file); - if path.exists() { - let result = fs::remove_file(path.clone()).await; - if let Err(err) = result { - logging_error!( - Type::Config, - "[配置文件删除] 删除文件 {} 失败: {}", - path.display(), - err - ); - } - } - }); + + // remove related extension items (merge, script, rules, proxies, groups) + if let Some(file) = Self::take_item_file_by_uid(&mut items, merge_uid.clone()) { + let _ = dirs::app_profiles_dir()? + .join(file.as_str()) + .remove_if_exists() + .await; } - // get the merge index - for (i, _) in items.iter().enumerate() { - if items[i].uid == merge_uid { - merge_index = Some(i); - break; - } + if let Some(file) = Self::take_item_file_by_uid(&mut items, script_uid.clone()) { + let _ = dirs::app_profiles_dir()? + .join(file.as_str()) + .remove_if_exists() + .await; } - if let Some(index) = merge_index - && let Some(file) = items.remove(index).file - { - let _ = dirs::app_profiles_dir().map(async move |path| { - let path = path.join(file); - if path.exists() { - let result = fs::remove_file(path.clone()).await; - if let Err(err) = result { - logging_error!( - Type::Config, - "[配置文件删除] 删除文件 {} 失败: {}", - path.display(), - err - ); - } - } - }); + if let Some(file) = Self::take_item_file_by_uid(&mut items, rules_uid.clone()) { + let _ = dirs::app_profiles_dir()? + .join(file.as_str()) + .remove_if_exists() + .await; } - // get the script index - for (i, _) in items.iter().enumerate() { - if items[i].uid == script_uid { - script_index = Some(i); - break; - } + if let Some(file) = Self::take_item_file_by_uid(&mut items, proxies_uid.clone()) { + let _ = dirs::app_profiles_dir()? + .join(file.as_str()) + .remove_if_exists() + .await; } - if let Some(index) = script_index - && let Some(file) = items.remove(index).file - { - let _ = dirs::app_profiles_dir().map(async move |path| { - let path = path.join(file); - if path.exists() { - let result = fs::remove_file(path.clone()).await; - if let Err(err) = result { - logging_error!( - Type::Config, - "[配置文件删除] 删除文件 {} 失败: {}", - path.display(), - err - ); - } - } - }); - } - // get the rules index - for (i, _) in items.iter().enumerate() { - if items[i].uid == rules_uid { - rules_index = Some(i); - break; - } - } - if let Some(index) = rules_index - && let Some(file) = items.remove(index).file - { - let _ = dirs::app_profiles_dir().map(async move |path| { - let path = path.join(file); - if path.exists() { - let result = fs::remove_file(path.clone()).await; - if let Err(err) = result { - logging_error!( - Type::Config, - "[配置文件删除] 删除文件 {} 失败: {}", - path.display(), - err - ); - } - } - }); - } - // get the proxies index - for (i, _) in items.iter().enumerate() { - if items[i].uid == proxies_uid { - proxies_index = Some(i); - break; - } - } - if let Some(index) = proxies_index - && let Some(file) = items.remove(index).file - { - let _ = dirs::app_profiles_dir().map(async move |path| { - let path = path.join(file); - if path.exists() { - let result = fs::remove_file(path.clone()).await; - if let Err(err) = result { - logging_error!( - Type::Config, - "[配置文件删除] 删除文件 {} 失败: {}", - path.display(), - err - ); - } - } - }); - } - // get the groups index - for (i, _) in items.iter().enumerate() { - if items[i].uid == groups_uid { - groups_index = Some(i); - break; - } - } - if let Some(index) = groups_index - && let Some(file) = items.remove(index).file - { - let _ = dirs::app_profiles_dir().map(async move |path| { - let path = path.join(file); - if path.exists() { - let result = fs::remove_file(path.clone()).await; - if let Err(err) = result { - logging_error!( - Type::Config, - "[配置文件删除] 删除文件 {} 失败: {}", - path.display(), - err - ); - } - } - }); + if let Some(file) = Self::take_item_file_by_uid(&mut items, groups_uid.clone()) { + let _ = dirs::app_profiles_dir()? + .join(file.as_str()) + .remove_if_exists() + .await; } // delete the original uid - if current == uid { + if current == *uid { self.current = None; for item in items.iter() { - if item.itype == Some("remote".to_string()) - || item.itype == Some("local".to_string()) - { + if item.itype == Some("remote".into()) || item.itype == Some("local".into()) { self.current = item.uid.clone(); break; } @@ -445,7 +342,7 @@ impl IProfiles { self.items = Some(items); self.save_file().await?; - Ok(current == uid) + Ok(current == *uid) } /// 获取current指向的订阅内容 @@ -454,7 +351,7 @@ impl IProfiles { (Some(current), Some(items)) => { if let Some(item) = items.iter().find(|e| e.uid.as_ref() == Some(current)) { let file_path = match item.file.as_ref() { - Some(file) => dirs::app_profiles_dir()?.join(file), + Some(file) => dirs::app_profiles_dir()?.join(file.as_str()), None => bail!("failed to get the file field"), }; return help::read_mapping(&file_path).await; @@ -465,88 +362,18 @@ impl IProfiles { } } - /// 获取current指向的订阅的merge - pub fn current_merge(&self) -> Option { - match (self.current.as_ref(), self.items.as_ref()) { - (Some(current), Some(items)) => { - if let Some(item) = items.iter().find(|e| e.uid.as_ref() == Some(current)) { - let merge = item.option.as_ref().and_then(|e| e.merge.clone()); - return merge; - } - None - } - _ => None, - } - } - - /// 获取current指向的订阅的script - pub fn current_script(&self) -> Option { - match (self.current.as_ref(), self.items.as_ref()) { - (Some(current), Some(items)) => { - if let Some(item) = items.iter().find(|e| e.uid.as_ref() == Some(current)) { - let script = item.option.as_ref().and_then(|e| e.script.clone()); - return script; - } - None - } - _ => None, - } - } - - /// 获取current指向的订阅的rules - pub fn current_rules(&self) -> Option { - match (self.current.as_ref(), self.items.as_ref()) { - (Some(current), Some(items)) => { - if let Some(item) = items.iter().find(|e| e.uid.as_ref() == Some(current)) { - let rules = item.option.as_ref().and_then(|e| e.rules.clone()); - return rules; - } - None - } - _ => None, - } - } - - /// 获取current指向的订阅的proxies - pub fn current_proxies(&self) -> Option { - match (self.current.as_ref(), self.items.as_ref()) { - (Some(current), Some(items)) => { - if let Some(item) = items.iter().find(|e| e.uid.as_ref() == Some(current)) { - let proxies = item.option.as_ref().and_then(|e| e.proxies.clone()); - return proxies; - } - None - } - _ => None, - } - } - - /// 获取current指向的订阅的groups - pub fn current_groups(&self) -> Option { - match (self.current.as_ref(), self.items.as_ref()) { - (Some(current), Some(items)) => { - if let Some(item) = items.iter().find(|e| e.uid.as_ref() == Some(current)) { - let groups = item.option.as_ref().and_then(|e| e.groups.clone()); - return groups; - } - None - } - _ => None, - } - } - /// 判断profile是否是current指向的 - pub fn is_current_profile_index(&self, index: String) -> bool { - self.current == Some(index) + pub fn is_current_profile_index(&self, index: &String) -> bool { + self.current.as_ref() == Some(index) } /// 获取所有的profiles(uid,名称) - pub fn all_profile_uid_and_name(&self) -> Option> { + pub fn all_profile_uid_and_name(&self) -> Option> { self.items.as_ref().map(|items| { items .iter() .filter_map(|e| { - if let (Some(uid), Some(name)) = (e.uid.clone(), e.name.clone()) { + if let (Some(uid), Some(name)) = (e.uid.as_ref(), e.name.as_ref()) { Some((uid, name)) } else { None @@ -556,8 +383,20 @@ impl IProfiles { }) } + /// 通过 uid 获取名称 + pub fn get_name_by_uid(&self, uid: &String) -> Option<&String> { + if let Some(items) = &self.items { + for item in items { + if item.uid.as_ref() == Some(uid) { + return item.name.as_ref(); + } + } + } + None + } + /// 以 app 中的 profile 列表为准,删除不再需要的文件 - pub fn cleanup_orphaned_files(&self) -> Result { + pub async fn cleanup_orphaned_files(&self) -> Result { let profiles_dir = dirs::app_profiles_dir()?; if !profiles_dir.exists() { @@ -594,20 +433,24 @@ impl IProfiles { { // 检查是否为全局扩展文件 if protected_files.contains(file_name) { - log::debug!(target: "app", "保护全局扩展配置文件: {file_name}"); + logging!(debug, Type::Config, "保护全局扩展配置文件: {file_name}"); continue; } // 检查是否为活跃文件 if !active_files.contains(file_name) { - match std::fs::remove_file(&path) { + match path.to_path_buf().remove_if_exists().await { Ok(_) => { - deleted_files.push(file_name.to_string()); - log::info!(target: "app", "已清理冗余文件: {file_name}"); + deleted_files.push(file_name.into()); + logging!(info, Type::Config, "已清理冗余文件: {file_name}"); } Err(e) => { - failed_deletions.push(format!("{file_name}: {e}")); - log::warn!(target: "app", "清理文件失败: {file_name} - {e}"); + failed_deletions.push(format!("{file_name}: {e}").into()); + logging!( + warn, + Type::Config, + "Warning: 清理文件失败: {file_name} - {e}" + ); } } } @@ -620,8 +463,9 @@ impl IProfiles { failed_deletions, }; - log::info!( - target: "app", + logging!( + info, + Type::Config, "Profile 文件清理完成: 总文件数={}, 删除文件数={}, 失败数={}", result.total_files, result.deleted_files.len(), @@ -635,21 +479,21 @@ impl IProfiles { fn get_protected_global_files(&self) -> HashSet { let mut protected_files = HashSet::new(); - protected_files.insert("Merge.yaml".to_string()); - protected_files.insert("Script.js".to_string()); + protected_files.insert("Merge.yaml".into()); + protected_files.insert("Script.js".into()); protected_files } /// 获取所有 active profile 关联的文件名 - fn get_all_active_files(&self) -> HashSet { - let mut active_files = HashSet::new(); + 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.clone()); + active_files.insert(file); } // 对于主 profile 类型(remote/local),还需要收集其关联的扩展文件 @@ -662,35 +506,35 @@ impl IProfiles { && let Ok(merge_item) = self.get_item(merge_uid) && let Some(file) = &merge_item.file { - active_files.insert(file.clone()); + active_files.insert(file); } if let Some(script_uid) = &option.script && let Ok(script_item) = self.get_item(script_uid) && let Some(file) = &script_item.file { - active_files.insert(file.clone()); + active_files.insert(file); } if let Some(rules_uid) = &option.rules && let Ok(rules_item) = self.get_item(rules_uid) && let Some(file) = &rules_item.file { - active_files.insert(file.clone()); + active_files.insert(file); } if let Some(proxies_uid) = &option.proxies && let Ok(proxies_item) = self.get_item(proxies_uid) && let Some(file) = &proxies_item.file { - active_files.insert(file.clone()); + active_files.insert(file); } if let Some(groups_uid) = &option.groups && let Ok(groups_item) = self.get_item(groups_uid) && let Some(file) = &groups_item.file { - active_files.insert(file.clone()); + active_files.insert(file); } } } @@ -729,89 +573,69 @@ impl IProfiles { use crate::config::Config; pub async fn profiles_append_item_with_filedata_safe( - item: PrfItem, + item: &PrfItem, file_data: Option, ) -> Result<()> { - AsyncHandler::spawn_blocking(move || { - AsyncHandler::handle().block_on(async { - let item = PrfItem::from(item, file_data).await?; - let profiles = Config::profiles().await; - let mut profiles_guard = profiles.data_mut(); - profiles_guard.append_item(item).await - }) - }) - .await - .map_err(|e| anyhow::anyhow!("Task join error: {}", e))? + let item = &mut PrfItem::from(item, file_data).await?; + profiles_append_item_safe(item).await } -pub async fn profiles_append_item_safe(item: PrfItem) -> Result<()> { - AsyncHandler::spawn_blocking(move || { - AsyncHandler::handle().block_on(async { - let profiles = Config::profiles().await; - let mut profiles_guard = profiles.data_mut(); - profiles_guard.append_item(item).await +pub async fn profiles_append_item_safe(item: &mut PrfItem) -> Result<()> { + Config::profiles() + .await + .with_data_modify(|mut profiles| async move { + profiles.append_item(item).await?; + Ok((profiles, ())) }) - }) - .await - .map_err(|e| anyhow::anyhow!("Task join error: {}", e))? + .await } -pub async fn profiles_patch_item_safe(index: String, item: PrfItem) -> Result<()> { - AsyncHandler::spawn_blocking(move || { - AsyncHandler::handle().block_on(async { - let profiles = Config::profiles().await; - let mut profiles_guard = profiles.data_mut(); - profiles_guard.patch_item(index, item).await +pub async fn profiles_patch_item_safe(index: &String, item: &PrfItem) -> Result<()> { + Config::profiles() + .await + .with_data_modify(|mut profiles| async move { + profiles.patch_item(index, item).await?; + Ok((profiles, ())) }) - }) - .await - .map_err(|e| anyhow::anyhow!("Task join error: {}", e))? + .await } -pub async fn profiles_delete_item_safe(index: String) -> Result { - AsyncHandler::spawn_blocking(move || { - AsyncHandler::handle().block_on(async { - let profiles = Config::profiles().await; - let mut profiles_guard = profiles.data_mut(); - profiles_guard.delete_item(index).await +pub async fn profiles_delete_item_safe(index: &String) -> Result { + Config::profiles() + .await + .with_data_modify(|mut profiles| async move { + let deleted = profiles.delete_item(index).await?; + Ok((profiles, deleted)) }) - }) - .await - .map_err(|e| anyhow::anyhow!("Task join error: {}", e))? + .await } -pub async fn profiles_reorder_safe(active_id: String, over_id: String) -> Result<()> { - AsyncHandler::spawn_blocking(move || { - AsyncHandler::handle().block_on(async { - let profiles = Config::profiles().await; - let mut profiles_guard = profiles.data_mut(); - profiles_guard.reorder(active_id, over_id).await +pub async fn profiles_reorder_safe(active_id: &String, over_id: &String) -> Result<()> { + Config::profiles() + .await + .with_data_modify(|mut profiles| async move { + profiles.reorder(active_id, over_id).await?; + Ok((profiles, ())) }) - }) - .await - .map_err(|e| anyhow::anyhow!("Task join error: {}", e))? + .await } pub async fn profiles_save_file_safe() -> Result<()> { - AsyncHandler::spawn_blocking(move || { - AsyncHandler::handle().block_on(async { - let profiles = Config::profiles().await; - let profiles_guard = profiles.data_mut(); - profiles_guard.save_file().await + Config::profiles() + .await + .with_data_modify(|profiles| async move { + profiles.save_file().await?; + Ok((profiles, ())) }) - }) - .await - .map_err(|e| anyhow::anyhow!("Task join error: {}", e))? + .await } -pub async fn profiles_draft_update_item_safe(index: String, item: PrfItem) -> Result<()> { - AsyncHandler::spawn_blocking(move || { - AsyncHandler::handle().block_on(async { - let profiles = Config::profiles().await; - let mut profiles_guard = profiles.draft_mut(); - profiles_guard.update_item(index, item).await +pub async fn profiles_draft_update_item_safe(index: &String, item: &mut PrfItem) -> Result<()> { + Config::profiles() + .await + .with_data_modify(|mut profiles| async move { + profiles.update_item(index, item).await?; + Ok((profiles, ())) }) - }) - .await - .map_err(|e| anyhow::anyhow!("Task join error: {}", e))? + .await } diff --git a/clash-verge-rev/src-tauri/src/config/runtime.rs b/clash-verge-rev/src-tauri/src/config/runtime.rs index 7113402b4e..e394d5d680 100644 --- a/clash-verge-rev/src-tauri/src/config/runtime.rs +++ b/clash-verge-rev/src-tauri/src/config/runtime.rs @@ -1,7 +1,9 @@ use crate::enhance::field::use_keys; use serde::{Deserialize, Serialize}; use serde_yaml_ng::{Mapping, Value}; +use smartstring::alias::String; use std::collections::HashMap; + #[derive(Default, Debug, Clone, Deserialize, Serialize)] pub struct IRuntime { pub config: Option, @@ -30,15 +32,15 @@ impl IRuntime { let patch_tun = patch.get("tun"); if patch_tun.is_some() { let tun = config.get("tun"); - let mut tun = tun.map_or(Mapping::new(), |val| { - val.as_mapping().cloned().unwrap_or(Mapping::new()) + let mut tun: Mapping = tun.map_or_else(Mapping::new, |val| { + val.as_mapping().cloned().unwrap_or_else(Mapping::new) }); - let patch_tun = patch_tun.map_or(Mapping::new(), |val| { - val.as_mapping().cloned().unwrap_or(Mapping::new()) + let patch_tun = patch_tun.map_or_else(Mapping::new, |val| { + val.as_mapping().cloned().unwrap_or_else(Mapping::new) }); use_keys(&patch_tun).into_iter().for_each(|key| { - if let Some(value) = patch_tun.get(&key).to_owned() { - tun.insert(key.into(), value.clone()); + if let Some(value) = patch_tun.get(key.as_str()) { + tun.insert(Value::from(key.as_str()), value.clone()); } }); diff --git a/clash-verge-rev/src-tauri/src/config/verge.rs b/clash-verge-rev/src-tauri/src/config/verge.rs index 7d312acdff..70ad8964de 100644 --- a/clash-verge-rev/src-tauri/src/config/verge.rs +++ b/clash-verge-rev/src-tauri/src/config/verge.rs @@ -1,3 +1,4 @@ +use crate::config::Config; use crate::{ config::{DEFAULT_PAC, deserialize_encrypted, serialize_encrypted}, logging, @@ -6,6 +7,7 @@ use crate::{ use anyhow::Result; use log::LevelFilter; use serde::{Deserialize, Serialize}; +use smartstring::alias::String; /// ### `verge.yaml` schema #[derive(Default, Debug, Clone, Deserialize, Serialize)] @@ -44,18 +46,26 @@ pub struct IVerge { pub enable_memory_usage: Option, /// enable group icon + #[serde(skip_serializing_if = "Option::is_none")] pub enable_group_icon: 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>, + /// sysproxy tray icon pub sysproxy_tray_icon: Option, @@ -110,6 +120,7 @@ pub struct IVerge { /// hotkey map /// format: {func},{key} + #[serde(skip_serializing_if = "Option::is_none")] pub hotkeys: Option>, /// enable global hotkey @@ -129,13 +140,16 @@ pub struct IVerge { pub default_latency_test: Option, /// 默认的延迟测试超时时间 - pub default_latency_timeout: Option, + pub default_latency_timeout: Option, + + /// 是否自动检测当前节点延迟 + pub enable_auto_delay_detection: Option, /// 是否使用内部的脚本支持,默认为真 pub enable_builtin_enhanced: Option, /// proxy 页面布局 列数 - pub proxy_layout_column: Option, + pub proxy_layout_column: Option, /// 测试站列表 pub test_list: Option>, @@ -144,6 +158,15 @@ pub struct IVerge { /// 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 #[cfg(not(target_os = "windows"))] pub verge_redir_port: Option, @@ -194,9 +217,12 @@ pub struct IVerge { )] pub webdav_password: Option, + #[serde(skip)] pub enable_tray_speed: Option, - pub enable_tray_icon: Option, + // pub enable_tray_icon: Option, + /// show proxy groups directly on tray root menu + pub tray_inline_proxy_groups: Option, /// 自动进入轻量模式 pub enable_auto_light_weight_mode: Option, @@ -207,6 +233,9 @@ pub struct IVerge { /// 启用代理页面自动滚动 pub enable_hover_jump_navigator: Option, + /// 代理页面自动滚动延迟(毫秒) + pub hover_jump_navigator_delay: Option, + /// 启用外部控制器 pub enable_external_controller: Option, } @@ -242,7 +271,7 @@ impl IVerge { /// 验证并修正配置文件中的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 { + let mut config = match help::read_yaml::(&config_path).await { Ok(config) => config, Err(_) => Self::template(), }; @@ -258,7 +287,7 @@ impl IVerge { "启动时发现无效的clash_core配置: '{}', 将自动修正为 'verge-mihomo'", core ); - config.clash_core = Some("verge-mihomo".to_string()); + config.clash_core = Some("verge-mihomo".into()); needs_fix = true; } } else { @@ -267,7 +296,7 @@ impl IVerge { Type::Config, "启动时发现未配置clash_core, 将设置为默认值 'verge-mihomo'" ); - config.clash_core = Some("verge-mihomo".to_string()); + config.clash_core = Some("verge-mihomo".into()); needs_fix = true; } @@ -291,39 +320,39 @@ impl IVerge { } /// 配置修正后重新加载配置 - async fn reload_config_after_fix(updated_config: IVerge) -> Result<()> { - use crate::config::Config; - - let config_draft = Config::verge().await; - *config_draft.draft_mut() = Box::new(updated_config.clone()); - config_draft.apply(); - + async fn reload_config_after_fix(updated_config: Self) -> Result<()> { logging!( info, Type::Config, "内存配置已强制更新,新的clash_core: {:?}", - updated_config.clash_core + &updated_config.clash_core ); + let config_draft = Config::verge().await; + config_draft.edit_draft(|d| { + *d = updated_config; + }); + config_draft.apply(); + Ok(()) } pub fn get_valid_clash_core(&self) -> String { self.clash_core .clone() - .unwrap_or_else(|| "verge-mihomo".to_string()) + .unwrap_or_else(|| "verge-mihomo".into()) } fn get_system_language() -> String { let sys_lang = sys_locale::get_locale() - .unwrap_or_else(|| String::from("en")) + .unwrap_or_else(|| "en".into()) .to_lowercase(); let lang_code = sys_lang.split(['_', '-']).next().unwrap_or("en"); let supported_languages = i18n::get_supported_languages(); - if supported_languages.contains(&lang_code.to_string()) { - lang_code.to_string() + if supported_languages.contains(&lang_code.into()) { + lang_code.into() } else { String::from("en") } @@ -331,15 +360,23 @@ impl IVerge { pub async fn new() -> Self { match dirs::verge_path() { - Ok(path) => match help::read_yaml::(&path).await { - Ok(config) => config, + Ok(path) => match help::read_yaml::(&path).await { + Ok(mut config) => { + // compatibility + if let Some(start_page) = config.start_page.clone() + && start_page == "/home" + { + config.start_page = Some(String::from("/")); + } + config + } Err(err) => { - log::error!(target: "app", "{err}"); + logging!(error, Type::Config, "{err}"); Self::template() } }, Err(err) => { - log::error!(target: "app", "{err}"); + logging!(error, Type::Config, "{err}"); Self::template() } } @@ -356,7 +393,7 @@ impl IVerge { env_type: Some("bash".into()), #[cfg(target_os = "windows")] env_type: Some("powershell".into()), - start_page: Some("/home".into()), + start_page: Some("/".into()), traffic_graph: Some(true), enable_memory_usage: Some(true), enable_group_icon: Some(true), @@ -369,6 +406,7 @@ impl IVerge { enable_auto_launch: Some(false), enable_silent_start: Some(false), enable_hover_jump_navigator: Some(true), + hover_jump_navigator_delay: Some(280), enable_system_proxy: Some(false), proxy_auto_config: Some(false), pac_file_content: Some(DEFAULT_PAC.into()), @@ -393,11 +431,15 @@ impl IVerge { auto_check_update: Some(true), enable_builtin_enhanced: Some(true), auto_log_clean: Some(2), // 1: 1天, 2: 7天, 3: 30天, 4: 90天 + enable_auto_backup_schedule: Some(false), + auto_backup_interval_hours: Some(24), + auto_backup_on_change: Some(true), webdav_url: None, webdav_username: None, webdav_password: None, enable_tray_speed: Some(false), - enable_tray_icon: Some(true), + // enable_tray_icon: Some(true), + tray_inline_proxy_groups: Some(true), enable_global_hotkey: Some(true), enable_auto_light_weight_mode: Some(false), auto_light_weight_minutes: Some(10), @@ -415,11 +457,12 @@ impl IVerge { /// patch verge config /// only save to file - pub fn patch_config(&mut self, patch: IVerge) { + #[allow(clippy::cognitive_complexity)] + pub fn patch_config(&mut self, patch: &Self) { macro_rules! patch { ($key: tt) => { if patch.$key.is_some() { - self.$key = patch.$key; + self.$key = patch.$key.clone(); } }; } @@ -440,6 +483,7 @@ impl IVerge { #[cfg(target_os = "macos")] patch!(tray_icon); patch!(menu_icon); + patch!(menu_order); patch!(common_tray_icon); patch!(sysproxy_tray_icon); patch!(tun_tray_icon); @@ -448,6 +492,7 @@ impl IVerge { patch!(enable_auto_launch); patch!(enable_silent_start); patch!(enable_hover_jump_navigator); + patch!(hover_jump_navigator_delay); #[cfg(not(target_os = "windows"))] patch!(verge_redir_port); #[cfg(not(target_os = "windows"))] @@ -479,16 +524,21 @@ impl IVerge { patch!(auto_check_update); patch!(default_latency_test); patch!(default_latency_timeout); + patch!(enable_auto_delay_detection); patch!(enable_builtin_enhanced); patch!(proxy_layout_column); patch!(test_list); patch!(auto_log_clean); + patch!(enable_auto_backup_schedule); + patch!(auto_backup_interval_hours); + patch!(auto_backup_on_change); patch!(webdav_url); patch!(webdav_username); patch!(webdav_password); patch!(enable_tray_speed); - patch!(enable_tray_icon); + // patch!(enable_tray_icon); + patch!(tray_inline_proxy_groups); patch!(enable_auto_light_weight_mode); patch!(auto_light_weight_minutes); patch!(enable_dns_settings); @@ -496,13 +546,8 @@ impl IVerge { patch!(enable_external_controller); } - /// 在初始化前尝试拿到单例端口的值 - pub fn get_singleton_port() -> u16 { - #[cfg(not(feature = "verge-dev"))] - const SERVER_PORT: u16 = 33331; - #[cfg(feature = "verge-dev")] - const SERVER_PORT: u16 = 11233; - SERVER_PORT + pub const fn get_singleton_port() -> u16 { + crate::constants::network::ports::SINGLETON_SERVER } /// 获取日志等级 @@ -522,148 +567,3 @@ impl IVerge { } } } - -#[derive(Debug, Clone, Serialize)] -pub struct IVergeResponse { - pub app_log_level: Option, - pub app_log_max_size: Option, - pub app_log_max_count: Option, - pub language: Option, - pub theme_mode: Option, - pub tray_event: Option, - pub env_type: Option, - pub start_page: Option, - pub startup_script: Option, - pub traffic_graph: Option, - pub enable_memory_usage: Option, - pub enable_group_icon: Option, - pub common_tray_icon: Option, - #[cfg(target_os = "macos")] - pub tray_icon: Option, - pub menu_icon: Option, - pub sysproxy_tray_icon: Option, - pub tun_tray_icon: Option, - pub enable_tun_mode: Option, - pub enable_auto_launch: Option, - pub enable_silent_start: Option, - pub enable_system_proxy: Option, - pub enable_proxy_guard: Option, - pub enable_global_hotkey: Option, - pub use_default_bypass: Option, - pub system_proxy_bypass: Option, - pub proxy_guard_duration: Option, - pub proxy_auto_config: Option, - pub pac_file_content: Option, - pub proxy_host: Option, - pub theme_setting: Option, - pub web_ui_list: Option>, - pub clash_core: Option, - pub hotkeys: Option>, - pub auto_close_connection: Option, - pub auto_check_update: Option, - pub default_latency_test: Option, - pub default_latency_timeout: Option, - pub enable_builtin_enhanced: Option, - pub proxy_layout_column: Option, - pub test_list: Option>, - pub auto_log_clean: Option, - #[cfg(not(target_os = "windows"))] - pub verge_redir_port: Option, - #[cfg(not(target_os = "windows"))] - pub verge_redir_enabled: Option, - #[cfg(target_os = "linux")] - pub verge_tproxy_port: Option, - #[cfg(target_os = "linux")] - pub verge_tproxy_enabled: Option, - pub verge_mixed_port: Option, - pub verge_socks_port: Option, - pub verge_socks_enabled: Option, - pub verge_port: Option, - pub verge_http_enabled: Option, - pub webdav_url: Option, - pub webdav_username: Option, - pub webdav_password: Option, - pub enable_tray_speed: Option, - pub enable_tray_icon: Option, - pub enable_auto_light_weight_mode: Option, - pub auto_light_weight_minutes: Option, - pub enable_dns_settings: Option, - pub home_cards: Option, - pub enable_hover_jump_navigator: Option, - pub enable_external_controller: Option, -} - -impl From for IVergeResponse { - fn from(verge: IVerge) -> Self { - // 先获取验证后的clash_core值,避免后续借用冲突 - let valid_clash_core = verge.get_valid_clash_core(); - Self { - app_log_level: verge.app_log_level, - app_log_max_size: verge.app_log_max_size, - app_log_max_count: verge.app_log_max_count, - language: verge.language, - theme_mode: verge.theme_mode, - tray_event: verge.tray_event, - env_type: verge.env_type, - start_page: verge.start_page, - startup_script: verge.startup_script, - traffic_graph: verge.traffic_graph, - enable_memory_usage: verge.enable_memory_usage, - enable_group_icon: verge.enable_group_icon, - common_tray_icon: verge.common_tray_icon, - #[cfg(target_os = "macos")] - tray_icon: verge.tray_icon, - menu_icon: verge.menu_icon, - sysproxy_tray_icon: verge.sysproxy_tray_icon, - tun_tray_icon: verge.tun_tray_icon, - enable_tun_mode: verge.enable_tun_mode, - enable_auto_launch: verge.enable_auto_launch, - enable_silent_start: verge.enable_silent_start, - enable_system_proxy: verge.enable_system_proxy, - enable_proxy_guard: verge.enable_proxy_guard, - enable_global_hotkey: verge.enable_global_hotkey, - use_default_bypass: verge.use_default_bypass, - system_proxy_bypass: verge.system_proxy_bypass, - proxy_guard_duration: verge.proxy_guard_duration, - proxy_auto_config: verge.proxy_auto_config, - pac_file_content: verge.pac_file_content, - proxy_host: verge.proxy_host, - theme_setting: verge.theme_setting, - web_ui_list: verge.web_ui_list, - clash_core: Some(valid_clash_core), - hotkeys: verge.hotkeys, - auto_close_connection: verge.auto_close_connection, - auto_check_update: verge.auto_check_update, - default_latency_test: verge.default_latency_test, - default_latency_timeout: verge.default_latency_timeout, - enable_builtin_enhanced: verge.enable_builtin_enhanced, - proxy_layout_column: verge.proxy_layout_column, - test_list: verge.test_list, - auto_log_clean: verge.auto_log_clean, - #[cfg(not(target_os = "windows"))] - verge_redir_port: verge.verge_redir_port, - #[cfg(not(target_os = "windows"))] - verge_redir_enabled: verge.verge_redir_enabled, - #[cfg(target_os = "linux")] - verge_tproxy_port: verge.verge_tproxy_port, - #[cfg(target_os = "linux")] - verge_tproxy_enabled: verge.verge_tproxy_enabled, - verge_mixed_port: verge.verge_mixed_port, - verge_socks_port: verge.verge_socks_port, - verge_socks_enabled: verge.verge_socks_enabled, - verge_port: verge.verge_port, - verge_http_enabled: verge.verge_http_enabled, - webdav_url: verge.webdav_url, - webdav_username: verge.webdav_username, - webdav_password: verge.webdav_password, - enable_tray_speed: verge.enable_tray_speed, - enable_tray_icon: verge.enable_tray_icon, - enable_auto_light_weight_mode: verge.enable_auto_light_weight_mode, - auto_light_weight_minutes: verge.auto_light_weight_minutes, - enable_dns_settings: verge.enable_dns_settings, - home_cards: verge.home_cards, - enable_hover_jump_navigator: verge.enable_hover_jump_navigator, - enable_external_controller: verge.enable_external_controller, - } - } -} diff --git a/clash-verge-rev/src-tauri/src/constants.rs b/clash-verge-rev/src-tauri/src/constants.rs new file mode 100644 index 0000000000..a07da7c156 --- /dev/null +++ b/clash-verge-rev/src-tauri/src/constants.rs @@ -0,0 +1,74 @@ +use std::time::Duration; + +pub mod network { + pub const DEFAULT_PROXY_HOST: &str = "127.0.0.1"; + pub const DEFAULT_EXTERNAL_CONTROLLER: &str = "127.0.0.1:9097"; + + pub mod ports { + #[cfg(not(target_os = "windows"))] + pub const DEFAULT_REDIR: u16 = 7895; + #[cfg(target_os = "linux")] + pub const DEFAULT_TPROXY: u16 = 7896; + pub const DEFAULT_MIXED: u16 = 7897; + pub const DEFAULT_SOCKS: u16 = 7898; + pub const DEFAULT_HTTP: u16 = 7899; + + #[cfg(not(feature = "verge-dev"))] + pub const SINGLETON_SERVER: u16 = 33331; + #[cfg(feature = "verge-dev")] + pub const SINGLETON_SERVER: u16 = 11233; + } +} + +pub mod bypass { + #[cfg(target_os = "windows")] + pub const DEFAULT: &str = "localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;"; + + #[cfg(target_os = "linux")] + pub const DEFAULT: &str = + "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,172.29.0.0/16,::1"; + + #[cfg(target_os = "macos")] + pub const DEFAULT: &str = "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,172.29.0.0/16,localhost,*.local,*.crashlytics.com,"; +} + +pub mod timing { + use super::Duration; + + pub const CONFIG_UPDATE_DEBOUNCE: Duration = Duration::from_millis(500); + pub const CONFIG_RELOAD_DELAY: Duration = Duration::from_millis(300); + pub const EVENT_EMIT_DELAY: Duration = Duration::from_millis(20); + pub const STARTUP_ERROR_DELAY: Duration = Duration::from_secs(2); + pub const ERROR_BATCH_DELAY: Duration = Duration::from_millis(300); + + #[cfg(target_os = "windows")] + pub const SERVICE_WAIT_MAX: Duration = Duration::from_millis(3000); + #[cfg(target_os = "windows")] + pub const SERVICE_WAIT_INTERVAL: Duration = Duration::from_millis(200); +} + +pub mod retry { + pub const EVENT_EMIT_THRESHOLD: u64 = 10; +} + +pub mod files { + pub const RUNTIME_CONFIG: &str = "clash-verge.yaml"; + pub const CHECK_CONFIG: &str = "clash-verge-check.yaml"; + pub const DNS_CONFIG: &str = "dns_config.yaml"; + pub const WINDOW_STATE: &str = "window_state.json"; +} + +pub mod error_patterns { + pub const CONNECTION_ERRORS: &[&str] = &[ + "Failed to create connection", + "The system cannot find the file specified", + "operation timed out", + "connection refused", + ]; +} + +pub mod tun { + pub const DEFAULT_STACK: &str = "gvisor"; + + pub const DNS_HIJACK: &[&str] = &["any:53"]; +} diff --git a/clash-verge-rev/src-tauri/src/core/async_proxy_query.rs b/clash-verge-rev/src-tauri/src/core/async_proxy_query.rs index 304d91d3fc..f0e579f6f7 100644 --- a/clash-verge-rev/src-tauri/src/core/async_proxy_query.rs +++ b/clash-verge-rev/src-tauri/src/core/async_proxy_query.rs @@ -1,5 +1,6 @@ #[cfg(target_os = "windows")] use crate::process::AsyncHandler; +use crate::{logging, utils::logging::Type}; use anyhow::Result; use serde::{Deserialize, Serialize}; use tokio::time::{Duration, timeout}; @@ -27,7 +28,7 @@ impl Default for AsyncSysproxy { fn default() -> Self { Self { enable: false, - host: "127.0.0.1".to_string(), + host: "127.0.0.1".into(), port: 7897, bypass: String::new(), } @@ -41,15 +42,21 @@ impl AsyncProxyQuery { pub async fn get_auto_proxy() -> AsyncAutoproxy { match timeout(Duration::from_secs(3), Self::get_auto_proxy_impl()).await { Ok(Ok(proxy)) => { - log::debug!(target: "app", "异步获取自动代理成功: enable={}, url={}", proxy.enable, proxy.url); + logging!( + debug, + Type::Network, + "异步获取自动代理成功: enable={}, url={}", + proxy.enable, + proxy.url + ); proxy } Ok(Err(e)) => { - log::warn!(target: "app", "异步获取自动代理失败: {e}"); + logging!(warn, Type::Network, "Warning: 异步获取自动代理失败: {e}"); AsyncAutoproxy::default() } Err(_) => { - log::warn!(target: "app", "异步获取自动代理超时"); + logging!(warn, Type::Network, "Warning: 异步获取自动代理超时"); AsyncAutoproxy::default() } } @@ -59,15 +66,22 @@ impl AsyncProxyQuery { pub async fn get_system_proxy() -> AsyncSysproxy { match timeout(Duration::from_secs(3), Self::get_system_proxy_impl()).await { Ok(Ok(proxy)) => { - log::debug!(target: "app", "异步获取系统代理成功: enable={}, {}:{}", proxy.enable, proxy.host, proxy.port); + logging!( + debug, + Type::Network, + "异步获取系统代理成功: enable={}, {}:{}", + proxy.enable, + proxy.host, + proxy.port + ); proxy } Ok(Err(e)) => { - log::warn!(target: "app", "异步获取系统代理失败: {e}"); + logging!(warn, Type::Network, "Warning: 异步获取系统代理失败: {e}"); AsyncSysproxy::default() } Err(_) => { - log::warn!(target: "app", "异步获取系统代理超时"); + logging!(warn, Type::Network, "Warning: 异步获取系统代理超时"); AsyncSysproxy::default() } } @@ -99,7 +113,7 @@ impl AsyncProxyQuery { RegOpenKeyExW(HKEY_CURRENT_USER, key_path.as_ptr(), 0, KEY_READ, &mut hkey); if result != 0 { - log::debug!(target: "app", "无法打开注册表项"); + logging!(debug, Type::Network, "无法打开注册表项"); return Ok(AsyncAutoproxy::default()); } @@ -125,7 +139,7 @@ impl AsyncProxyQuery { .position(|&x| x == 0) .unwrap_or(url_buffer.len()); pac_url = String::from_utf16_lossy(&url_buffer[..end_pos]); - log::debug!(target: "app", "从注册表读取到PAC URL: {pac_url}"); + logging!(debug, Type::Network, "从注册表读取到PAC URL: {pac_url}"); } // 2. 检查自动检测设置是否启用 @@ -150,10 +164,14 @@ impl AsyncProxyQuery { || (detect_query_result == 0 && detect_value_type == REG_DWORD && auto_detect != 0); if pac_enabled { - log::debug!(target: "app", "PAC配置启用: URL={pac_url}, AutoDetect={auto_detect}"); + logging!( + debug, + Type::Network, + "PAC配置启用: URL={pac_url}, AutoDetect={auto_detect}" + ); if pac_url.is_empty() && auto_detect != 0 { - pac_url = "auto-detect".to_string(); + pac_url = "auto-detect".into(); } Ok(AsyncAutoproxy { @@ -161,7 +179,7 @@ impl AsyncProxyQuery { url: pac_url, }) } else { - log::debug!(target: "app", "PAC配置未启用"); + logging!(debug, Type::Network, "PAC配置未启用"); Ok(AsyncAutoproxy::default()) } } @@ -177,7 +195,11 @@ impl AsyncProxyQuery { } let stdout = String::from_utf8_lossy(&output.stdout); - log::debug!(target: "app", "scutil output: {stdout}"); + crate::logging!( + debug, + crate::utils::logging::Type::Network, + "scutil output: {stdout}" + ); let mut pac_enabled = false; let mut pac_url = String::new(); @@ -191,12 +213,16 @@ impl AsyncProxyQuery { // 正确解析包含冒号的URL // 格式: "ProxyAutoConfigURLString : http://127.0.0.1:11233/commands/pac" if let Some(colon_pos) = line.find(" : ") { - pac_url = line[colon_pos + 3..].trim().to_string(); + pac_url = line[colon_pos + 3..].trim().into(); } } } - log::debug!(target: "app", "解析结果: pac_enabled={pac_enabled}, pac_url={pac_url}"); + crate::logging!( + debug, + crate::utils::logging::Type::Network, + "解析结果: pac_enabled={pac_enabled}, pac_url={pac_url}" + ); Ok(AsyncAutoproxy { enable: pac_enabled && !pac_url.is_empty(), @@ -227,7 +253,7 @@ impl AsyncProxyQuery { if let Ok(output) = output && output.status.success() { - let mode = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let mode: String = String::from_utf8_lossy(&output.stdout).trim().into(); if mode.contains("auto") { // 获取 PAC URL let pac_output = Command::new("gsettings") @@ -238,11 +264,11 @@ impl AsyncProxyQuery { if let Ok(pac_output) = pac_output && pac_output.status.success() { - let pac_url = String::from_utf8_lossy(&pac_output.stdout) + let pac_url: String = String::from_utf8_lossy(&pac_output.stdout) .trim() .trim_matches('\'') .trim_matches('"') - .to_string(); + .into(); if !pac_url.is_empty() { return Ok(AsyncAutoproxy { @@ -321,11 +347,12 @@ impl AsyncProxyQuery { &mut buffer_size, ); - let mut proxy_server = String::new(); - if server_result == 0 && value_type == REG_SZ && buffer_size > 0 { + let proxy_server = if server_result == 0 && value_type == REG_SZ && buffer_size > 0 { let end_pos = buffer.iter().position(|&x| x == 0).unwrap_or(buffer.len()); - proxy_server = String::from_utf16_lossy(&buffer[..end_pos]); - } + String::from_utf16_lossy(&buffer[..end_pos]) + } else { + String::new() + }; // 读取代理绕过列表 let proxy_override_name = "ProxyOverride\0".encode_utf16().collect::>(); @@ -342,28 +369,34 @@ impl AsyncProxyQuery { &mut bypass_buffer_size, ); - let mut bypass_list = String::new(); - if override_result == 0 && bypass_value_type == REG_SZ && bypass_buffer_size > 0 { - let end_pos = bypass_buffer - .iter() - .position(|&x| x == 0) - .unwrap_or(bypass_buffer.len()); - bypass_list = String::from_utf16_lossy(&bypass_buffer[..end_pos]); - } + let bypass_list = + if override_result == 0 && bypass_value_type == REG_SZ && bypass_buffer_size > 0 { + let end_pos = bypass_buffer + .iter() + .position(|&x| x == 0) + .unwrap_or(bypass_buffer.len()); + String::from_utf16_lossy(&bypass_buffer[..end_pos]) + } else { + String::new() + }; RegCloseKey(hkey); if !proxy_server.is_empty() { // 解析服务器地址和端口 let (host, port) = if let Some(colon_pos) = proxy_server.rfind(':') { - let host = proxy_server[..colon_pos].to_string(); + let host = proxy_server[..colon_pos].into(); let port = proxy_server[colon_pos + 1..].parse::().unwrap_or(8080); (host, port) } else { (proxy_server, 8080) }; - log::debug!(target: "app", "从注册表读取到代理设置: {host}:{port}, bypass: {bypass_list}"); + logging!( + debug, + Type::Network, + "从注册表读取到代理设置: {host}:{port}, bypass: {bypass_list}" + ); Ok(AsyncSysproxy { enable: true, @@ -386,12 +419,12 @@ impl AsyncProxyQuery { } let stdout = String::from_utf8_lossy(&output.stdout); - log::debug!(target: "app", "scutil proxy output: {stdout}"); + logging!(debug, Type::Network, "scutil proxy output: {stdout}"); let mut http_enabled = false; let mut http_host = String::new(); let mut http_port = 8080u16; - let mut exceptions = Vec::new(); + let mut exceptions: Vec = Vec::new(); for line in stdout.lines() { let line = line.trim(); @@ -399,7 +432,7 @@ impl AsyncProxyQuery { http_enabled = true; } else if line.contains("HTTPProxy") && !line.contains("Port") { if let Some(host_part) = line.split(':').nth(1) { - http_host = host_part.trim().to_string(); + http_host = host_part.trim().into(); } } else if line.contains("HTTPPort") { if let Some(port_part) = line.split(':').nth(1) @@ -412,7 +445,7 @@ impl AsyncProxyQuery { if let Some(list_part) = line.split(':').nth(1) { let list = list_part.trim(); if !list.is_empty() { - exceptions.push(list.to_string()); + exceptions.push(list.into()); } } } @@ -452,9 +485,7 @@ impl AsyncProxyQuery { if let Ok(mode_output) = mode_output && mode_output.status.success() { - let mode = String::from_utf8_lossy(&mode_output.stdout) - .trim() - .to_string(); + let mode: String = String::from_utf8_lossy(&mode_output.stdout).trim().into(); if mode.contains("manual") { // 获取HTTP代理设置 let host_result = Command::new("gsettings") @@ -471,11 +502,11 @@ impl AsyncProxyQuery { && host_output.status.success() && port_output.status.success() { - let host = String::from_utf8_lossy(&host_output.stdout) + let host: String = String::from_utf8_lossy(&host_output.stdout) .trim() .trim_matches('\'') .trim_matches('"') - .to_string(); + .into(); let port = String::from_utf8_lossy(&port_output.stdout) .trim() @@ -513,11 +544,11 @@ impl AsyncProxyQuery { // 解析主机和端口 let (host, port) = if let Some(colon_pos) = url.rfind(':') { - let host = url[..colon_pos].to_string(); + let host: String = url[..colon_pos].into(); let port = url[colon_pos + 1..].parse::().unwrap_or(8080); (host, port) } else { - (url.to_string(), 8080) + (url.into(), 8080) }; if host.is_empty() { diff --git a/clash-verge-rev/src-tauri/src/core/backup.rs b/clash-verge-rev/src-tauri/src/core/backup.rs index 5f5555cd01..ad3168546e 100644 --- a/clash-verge-rev/src-tauri/src/core/backup.rs +++ b/clash-verge-rev/src-tauri/src/core/backup.rs @@ -1,18 +1,24 @@ -use crate::{config::Config, utils::dirs}; +use crate::constants::files::DNS_CONFIG; +use crate::{ + config::Config, + logging, + process::AsyncHandler, + utils::{dirs, logging::Type}, +}; use anyhow::Error; +use arc_swap::{ArcSwap, ArcSwapOption}; use once_cell::sync::OnceCell; -use parking_lot::Mutex; use reqwest_dav::list_cmd::{ListEntity, ListFile}; +use smartstring::alias::String; use std::{ collections::HashMap, env::{consts::OS, temp_dir}, - fs, - io::Write, + io::Write as _, path::PathBuf, sync::Arc, time::Duration, }; -use tokio::time::timeout; +use tokio::{fs, time::timeout}; use zip::write::SimpleFileOptions; // 应用版本常量,来自 tauri.conf.json @@ -39,35 +45,35 @@ enum Operation { } impl Operation { - fn timeout(&self) -> u64 { + const fn timeout(&self) -> u64 { match self { - Operation::Upload => TIMEOUT_UPLOAD, - Operation::Download => TIMEOUT_DOWNLOAD, - Operation::List => TIMEOUT_LIST, - Operation::Delete => TIMEOUT_DELETE, + Self::Upload => TIMEOUT_UPLOAD, + Self::Download => TIMEOUT_DOWNLOAD, + Self::List => TIMEOUT_LIST, + Self::Delete => TIMEOUT_DELETE, } } } pub struct WebDavClient { - config: Arc>>, - clients: Arc>>, + config: Arc>, + clients: Arc>>, } impl WebDavClient { - pub fn global() -> &'static WebDavClient { + pub fn global() -> &'static Self { static WEBDAV_CLIENT: OnceCell = OnceCell::new(); - WEBDAV_CLIENT.get_or_init(|| WebDavClient { - config: Arc::new(Mutex::new(None)), - clients: Arc::new(Mutex::new(HashMap::new())), + WEBDAV_CLIENT.get_or_init(|| Self { + config: Arc::new(ArcSwapOption::new(None)), + clients: Arc::new(ArcSwap::new(Arc::new(HashMap::new()))), }) } async fn get_client(&self, op: Operation) -> Result { // 先尝试从缓存获取 { - let clients = self.clients.lock(); - if let Some(client) = clients.get(&op) { + let clients_map = self.clients.load(); + if let Some(client) = clients_map.get(&op) { return Ok(client.clone()); } } @@ -75,33 +81,35 @@ impl WebDavClient { // 获取或创建配置 let config = { // 首先检查是否已有配置 - let existing_config = self.config.lock().as_ref().cloned(); + let existing_config = self.config.load(); - if let Some(cfg) = existing_config { - cfg + if let Some(cfg_arc) = existing_config.clone() { + (*cfg_arc).clone() } else { // 释放锁后获取异步配置 - let verge = Config::verge().await.latest_ref().clone(); + let verge = Config::verge().await.data_arc(); if verge.webdav_url.is_none() || verge.webdav_username.is_none() || verge.webdav_password.is_none() { - let msg = "Unable to create web dav client, please make sure the webdav config is correct".to_string(); + let msg: String = + "Unable to create web dav client, please make sure the webdav config is correct".into(); return Err(anyhow::Error::msg(msg)); } let config = WebDavConfig { url: verge .webdav_url + .clone() .unwrap_or_default() .trim_end_matches('/') - .to_string(), - username: verge.webdav_username.unwrap_or_default(), - password: verge.webdav_password.unwrap_or_default(), + .into(), + username: verge.webdav_username.clone().unwrap_or_default(), + password: verge.webdav_password.clone().unwrap_or_default(), }; - // 重新获取锁并存储配置 - *self.config.lock() = Some(config.clone()); + // 存储配置到 ArcSwapOption + self.config.store(Some(Arc::new(config.clone()))); config } }; @@ -123,8 +131,11 @@ impl WebDavClient { })) .build()?, ) - .set_host(config.url) - .set_auth(reqwest_dav::Auth::Basic(config.username, config.password)) + .set_host(config.url.into()) + .set_auth(reqwest_dav::Auth::Basic( + config.username.into(), + config.password.into(), + )) .build()?; // 尝试检查目录是否存在,如果不存在尝试创建 @@ -134,9 +145,14 @@ impl WebDavClient { .is_err() { match client.mkcol(dirs::BACKUP_DIR).await { - Ok(_) => log::info!("Successfully created backup directory"), + Ok(_) => logging!(info, Type::Backup, "Successfully created backup directory"), Err(e) => { - log::warn!("Failed to create backup directory: {}", e); + logging!( + warn, + Type::Backup, + "Warning: Failed to create backup directory: {}", + e + ); // 清除缓存,强制下次重新尝试 self.reset(); return Err(anyhow::Error::msg(format!( @@ -147,26 +163,27 @@ impl WebDavClient { } } - // 缓存客户端 + // 缓存客户端(替换 Arc>> 的写法) { - let mut clients = self.clients.lock(); - clients.insert(op, client.clone()); + let mut map = (**self.clients.load()).clone(); + map.insert(op, client.clone()); + self.clients.store(map.into()); } Ok(client) } pub fn reset(&self) { - *self.config.lock() = None; - self.clients.lock().clear(); + self.config.store(None); + self.clients.store(Arc::new(HashMap::new())); } pub async fn upload(&self, file_path: PathBuf, file_name: String) -> Result<(), Error> { let client = self.get_client(Operation::Upload).await?; - let webdav_path: String = format!("{}/{}", dirs::BACKUP_DIR, file_name); + let webdav_path: String = format!("{}/{}", dirs::BACKUP_DIR, file_name).into(); // 读取文件并上传,如果失败尝试一次重试 - let file_content = fs::read(&file_path)?; + let file_content = fs::read(&file_path).await?; // 添加超时保护 let upload_result = timeout( @@ -177,7 +194,11 @@ impl WebDavClient { match upload_result { Err(_) => { - log::warn!("Upload timed out, retrying once"); + logging!( + warn, + Type::Backup, + "Warning: Upload timed out, retrying once" + ); tokio::time::sleep(Duration::from_millis(500)).await; timeout( Duration::from_secs(TIMEOUT_UPLOAD), @@ -188,7 +209,11 @@ impl WebDavClient { } Ok(Err(e)) => { - log::warn!("Upload failed, retrying once: {e}"); + logging!( + warn, + Type::Backup, + "Warning: Upload failed, retrying once: {e}" + ); tokio::time::sleep(Duration::from_millis(500)).await; timeout( Duration::from_secs(TIMEOUT_UPLOAD), @@ -208,7 +233,7 @@ impl WebDavClient { let fut = async { let response = client.get(path.as_str()).await?; let content = response.bytes().await?; - fs::write(&storage_path, &content)?; + fs::write(&storage_path, &content).await?; Ok::<(), Error>(()) }; @@ -246,18 +271,19 @@ impl WebDavClient { } } -pub fn create_backup() -> Result<(String, PathBuf), Error> { +pub async fn create_backup() -> Result<(String, PathBuf), Error> { let now = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S").to_string(); - let zip_file_name = format!("{OS}-backup-{now}.zip"); - let zip_path = temp_dir().join(&zip_file_name); + let zip_file_name: String = format!("{OS}-backup-{now}.zip").into(); + let zip_path = temp_dir().join(zip_file_name.as_str()); - let file = fs::File::create(&zip_path)?; + let value = zip_path.clone(); + let file = AsyncHandler::spawn_blocking(move || std::fs::File::create(&value)).await??; let mut zip = zip::ZipWriter::new(file); zip.add_directory("profiles/", SimpleFileOptions::default())?; let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); - if let Ok(entries) = fs::read_dir(dirs::app_profiles_dir()?) { - for entry in entries { - let entry = entry?; + + if let Ok(mut entries) = fs::read_dir(dirs::app_profiles_dir()?).await { + while let Some(entry) = entries.next_entry().await? { let path = entry.path(); if path.is_file() { let file_name_os = entry.file_name(); @@ -266,16 +292,16 @@ pub fn create_backup() -> Result<(String, PathBuf), Error> { .ok_or_else(|| anyhow::Error::msg("Invalid file name encoding"))?; let backup_path = format!("profiles/{}", file_name); zip.start_file(backup_path, options)?; - let file_content = fs::read(&path)?; + let file_content = fs::read(&path).await?; zip.write_all(&file_content)?; } } } zip.start_file(dirs::CLASH_CONFIG, options)?; - zip.write_all(fs::read(dirs::clash_path()?)?.as_slice())?; + zip.write_all(fs::read(dirs::clash_path()?).await?.as_slice())?; - let mut verge_config: serde_json::Value = - serde_yaml_ng::from_str(&fs::read_to_string(dirs::verge_path()?)?)?; + let verge_text = fs::read_to_string(dirs::verge_path()?).await?; + let mut verge_config: serde_json::Value = serde_yaml_ng::from_str(&verge_text)?; if let Some(obj) = verge_config.as_object_mut() { obj.remove("webdav_username"); obj.remove("webdav_password"); @@ -284,8 +310,14 @@ pub fn create_backup() -> Result<(String, PathBuf), Error> { zip.start_file(dirs::VERGE_CONFIG, options)?; zip.write_all(serde_yaml_ng::to_string(&verge_config)?.as_bytes())?; + let dns_config_path = dirs::app_home_dir()?.join(DNS_CONFIG); + if dns_config_path.exists() { + zip.start_file(DNS_CONFIG, options)?; + zip.write_all(fs::read(&dns_config_path).await?.as_slice())?; + } + zip.start_file(dirs::PROFILE_YAML, options)?; - zip.write_all(fs::read(dirs::profiles_path()?)?.as_slice())?; + zip.write_all(fs::read(dirs::profiles_path()?).await?.as_slice())?; zip.finish()?; Ok((zip_file_name, zip_path)) } diff --git a/clash-verge-rev/src-tauri/src/core/core.rs b/clash-verge-rev/src-tauri/src/core/core.rs deleted file mode 100644 index fe81e1ee1d..0000000000 --- a/clash-verge-rev/src-tauri/src/core/core.rs +++ /dev/null @@ -1,959 +0,0 @@ -use crate::AsyncHandler; -use crate::core::logger::Logger; -use crate::process::CommandChildGuard; -use crate::utils::init::sidecar_writer; -use crate::utils::logging::SharedWriter; -use crate::{ - config::*, - core::{ - handle, - service::{self, SERVICE_MANAGER, ServiceStatus}, - }, - logging, logging_error, singleton_lazy, - utils::{ - dirs, - help::{self}, - logging::Type, - }, -}; -use anyhow::Result; -use flexi_logger::DeferredNow; -use flexi_logger::writers::LogWriter; -use log::Record; -use parking_lot::Mutex; -use std::{fmt, path::PathBuf, sync::Arc}; -use tauri_plugin_shell::ShellExt; - -// TODO: -// - 重构,提升模式切换速度 -// - 内核启动添加启动 IPC 启动参数, `-ext-ctl-unix` / `-ext-ctl-pipe`, 运行时配置需要删除相关配置项 - -#[derive(Debug)] -pub struct CoreManager { - running: Arc>, - child_sidecar: Arc>>, -} - -/// 内核运行模式 -#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] -pub enum RunningMode { - /// 服务模式运行 - Service, - /// Sidecar 模式运行 - Sidecar, - /// 未运行 - NotRunning, -} - -impl fmt::Display for RunningMode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - RunningMode::Service => write!(f, "Service"), - RunningMode::Sidecar => write!(f, "Sidecar"), - RunningMode::NotRunning => write!(f, "NotRunning"), - } - } -} - -use crate::config::IVerge; - -fn write_sidecar_log( - writer: &dyn LogWriter, - now: &mut DeferredNow, - level: log::Level, - message: String, -) -> String { - let boxed = message.into_boxed_str(); - let leaked: &'static mut str = Box::leak(boxed); - let leaked_ptr = leaked as *mut str; - { - let _ = writer.write( - now, - &Record::builder() - .args(format_args!("{}", &*leaked)) - .level(level) - .target("sidecar") - .build(), - ); - } - // SAFETY: `leaked` originated from `Box::leak` above; reboxing frees it immediately after use. - unsafe { String::from(Box::from_raw(leaked_ptr)) } -} - -impl CoreManager { - /// 检查文件是否为脚本文件 - fn is_script_file(&self, path: &str) -> Result { - // 1. 先通过扩展名快速判断 - if path.ends_with(".yaml") || path.ends_with(".yml") { - return Ok(false); // YAML文件不是脚本文件 - } else if path.ends_with(".js") { - return Ok(true); // JS文件是脚本文件 - } - - // 2. 读取文件内容 - let content = match std::fs::read_to_string(path) { - Ok(content) => content, - Err(err) => { - logging!( - warn, - Type::Config, - "无法读取文件以检测类型: {}, 错误: {}", - path, - err - ); - return Err(anyhow::anyhow!( - "Failed to read file to detect type: {}", - err - )); - } - }; - - // 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 ") - || content.contains("var ") - || content.contains("//") - || content.contains("/*") - || content.contains("*/") - || 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") - { - return Ok(true); - } - - // 检查冒号后是否有空格(YAML的典型特征) - let yaml_pattern_count = content.lines().filter(|line| line.contains(": ")).count(); - - if yaml_pattern_count > 2 { - return Ok(false); // 多个键值对格式,更可能是YAML - } - } - - // 默认情况:无法确定时,假设为非脚本文件(更安全) - logging!( - debug, - Type::Config, - "无法确定文件类型,默认当作YAML处理: {}", - path - ); - Ok(false) - } - /// 使用默认配置 - pub async fn use_default_config(&self, msg_type: &str, msg_content: &str) -> Result<()> { - let runtime_path = dirs::app_home_dir()?.join(RUNTIME_CONFIG); - - // Extract clash config before async operations - let clash_config = Config::clash().await.latest_ref().0.clone(); - - *Config::runtime().await.draft_mut() = Box::new(IRuntime { - config: Some(clash_config.clone()), - exists_keys: vec![], - chain_logs: Default::default(), - }); - help::save_yaml(&runtime_path, &clash_config, Some("# Clash Verge Runtime")).await?; - handle::Handle::notice_message(msg_type, msg_content); - Ok(()) - } - /// 验证运行时配置 - pub async fn validate_config(&self) -> Result<(bool, String)> { - logging!(info, Type::Config, "生成临时配置文件用于验证"); - let config_path = Config::generate_file(ConfigType::Check).await?; - let config_path = dirs::path_to_str(&config_path)?; - self.validate_config_internal(config_path).await - } - /// 验证指定的配置文件 - pub async fn validate_config_file( - &self, - config_path: &str, - is_merge_file: Option, - ) -> Result<(bool, String)> { - // 检查程序是否正在退出,如果是则跳过验证 - if handle::Handle::global().is_exiting() { - logging!(info, Type::Core, "应用正在退出,跳过验证"); - return Ok((true, String::new())); - } - - // 检查文件是否存在 - if !std::path::Path::new(config_path).exists() { - let error_msg = format!("File not found: {config_path}"); - //handle::Handle::notice_message("config_validate::file_not_found", &error_msg); - return Ok((false, error_msg)); - } - - // 如果是合并文件且不是强制验证,执行语法检查但不进行完整验证 - if is_merge_file.unwrap_or(false) { - logging!( - info, - Type::Config, - "检测到Merge文件,仅进行语法检查: {}", - config_path - ); - return self.validate_file_syntax(config_path); - } - - // 检查是否为脚本文件 - let is_script = if config_path.ends_with(".js") { - true - } else { - match self.is_script_file(config_path) { - Ok(result) => result, - Err(err) => { - // 如果无法确定文件类型,尝试使用Clash内核验证 - logging!( - warn, - Type::Config, - "无法确定文件类型: {}, 错误: {}", - config_path, - err - ); - return self.validate_config_internal(config_path).await; - } - } - }; - - if is_script { - logging!( - info, - Type::Config, - "检测到脚本文件,使用JavaScript验证: {}", - config_path - ); - return self.validate_script_file(config_path); - } - - // 对YAML配置文件使用Clash内核验证 - logging!( - info, - Type::Config, - "使用Clash内核验证配置文件: {}", - config_path - ); - self.validate_config_internal(config_path).await - } - /// 内部验证配置文件的实现 - async fn validate_config_internal(&self, config_path: &str) -> Result<(bool, String)> { - // 检查程序是否正在退出,如果是则跳过验证 - if handle::Handle::global().is_exiting() { - logging!(info, Type::Core, "应用正在退出,跳过验证"); - return Ok((true, String::new())); - } - - logging!(info, Type::Config, "开始验证配置文件: {}", config_path); - - let clash_core = Config::verge().await.latest_ref().get_valid_clash_core(); - logging!(info, Type::Config, "使用内核: {}", clash_core); - - let app_handle = handle::Handle::app_handle(); - let app_dir = dirs::app_home_dir()?; - let app_dir_str = dirs::path_to_str(&app_dir)?; - logging!(info, Type::Config, "验证目录: {}", app_dir_str); - - // 使用子进程运行clash验证配置 - let output = app_handle - .shell() - .sidecar(clash_core)? - .args(["-t", "-d", app_dir_str, "-f", config_path]) - .output() - .await?; - - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - - // 检查进程退出状态和错误输出 - let error_keywords = ["FATA", "fatal", "Parse config error", "level=fatal"]; - let has_error = - !output.status.success() || error_keywords.iter().any(|&kw| stderr.contains(kw)); - - logging!(info, Type::Config, "-------- 验证结果 --------"); - - if !stderr.is_empty() { - logging!(info, Type::Config, "stderr输出:\n{}", stderr); - } - - if has_error { - logging!(info, Type::Config, "发现错误,开始处理错误信息"); - let error_msg = if !stdout.is_empty() { - stdout.to_string() - } else if !stderr.is_empty() { - stderr.to_string() - } else if let Some(code) = output.status.code() { - format!("验证进程异常退出,退出码: {code}") - } else { - "验证进程被终止".to_string() - }; - - logging!(info, Type::Config, "-------- 验证结束 --------"); - Ok((false, error_msg)) // 返回错误消息给调用者处理 - } else { - logging!(info, Type::Config, "验证成功"); - logging!(info, Type::Config, "-------- 验证结束 --------"); - Ok((true, String::new())) - } - } - /// 只进行文件语法检查,不进行完整验证 - fn validate_file_syntax(&self, config_path: &str) -> Result<(bool, String)> { - logging!(info, Type::Config, "开始检查文件: {}", config_path); - - // 读取文件内容 - let content = match std::fs::read_to_string(config_path) { - Ok(content) => content, - Err(err) => { - let error_msg = format!("Failed to read file: {err}"); - logging!(error, Type::Config, "无法读取文件: {}", error_msg); - return Ok((false, error_msg)); - } - }; - // 对YAML文件尝试解析,只检查语法正确性 - logging!(info, Type::Config, "进行YAML语法检查"); - match serde_yaml_ng::from_str::(&content) { - Ok(_) => { - logging!(info, Type::Config, "YAML语法检查通过"); - Ok((true, String::new())) - } - Err(err) => { - // 使用标准化的前缀,以便错误处理函数能正确识别 - let error_msg = format!("YAML syntax error: {err}"); - logging!(error, Type::Config, "YAML语法错误: {}", error_msg); - Ok((false, error_msg)) - } - } - } - /// 验证脚本文件语法 - fn validate_script_file(&self, path: &str) -> Result<(bool, String)> { - // 读取脚本内容 - let content = match std::fs::read_to_string(path) { - Ok(content) => content, - Err(err) => { - let error_msg = format!("Failed to read script file: {err}"); - logging!(warn, Type::Config, "脚本语法错误: {}", err); - //handle::Handle::notice_message("config_validate::script_syntax_error", &error_msg); - return Ok((false, error_msg)); - } - }; - - logging!(debug, Type::Config, "验证脚本文件: {}", path); - - // 使用boa引擎进行基本语法检查 - use boa_engine::{Context, Source}; - - let mut context = Context::default(); - let result = context.eval(Source::from_bytes(&content)); - - match result { - Ok(_) => { - logging!(debug, Type::Config, "脚本语法验证通过: {}", path); - - // 检查脚本是否包含main函数 - if !content.contains("function main") - && !content.contains("const main") - && !content.contains("let main") - { - let error_msg = "Script must contain a main function"; - logging!(warn, Type::Config, "脚本缺少main函数: {}", path); - //handle::Handle::notice_message("config_validate::script_missing_main", error_msg); - return Ok((false, error_msg.to_string())); - } - - Ok((true, String::new())) - } - Err(err) => { - let error_msg = format!("Script syntax error: {err}"); - logging!(warn, Type::Config, "脚本语法错误: {}", err); - //handle::Handle::notice_message("config_validate::script_syntax_error", &error_msg); - Ok((false, error_msg)) - } - } - } - /// 更新proxies等配置 - pub async fn update_config(&self) -> Result<(bool, String)> { - // 检查程序是否正在退出,如果是则跳过完整验证流程 - if handle::Handle::global().is_exiting() { - logging!(info, Type::Config, "应用正在退出,跳过验证"); - return Ok((true, String::new())); - } - - // 1. 先生成新的配置内容 - logging!(info, Type::Config, "生成新的配置内容"); - Config::generate().await?; - - // 2. 验证配置 - match self.validate_config().await { - Ok((true, _)) => { - // 4. 验证通过后,生成正式的运行时配置 - logging!(info, Type::Config, "配置验证通过, 生成运行时配置"); - let run_path = Config::generate_file(ConfigType::Run).await?; - logging_error!(Type::Config, self.put_configs_force(run_path).await); - Ok((true, "something".into())) - } - Ok((false, error_msg)) => { - logging!(warn, Type::Config, "配置验证失败: {}", error_msg); - Config::runtime().await.discard(); - Ok((false, error_msg)) - } - Err(e) => { - logging!(warn, Type::Config, "验证过程发生错误: {}", e); - Config::runtime().await.discard(); - Err(e) - } - } - } - pub async fn put_configs_force(&self, path_buf: PathBuf) -> Result<(), String> { - let run_path_str = dirs::path_to_str(&path_buf).map_err(|e| { - let msg = e.to_string(); - logging_error!(Type::Core, "{}", msg); - msg - }); - match handle::Handle::mihomo() - .await - .reload_config(true, run_path_str?) - .await - { - Ok(_) => { - Config::runtime().await.apply(); - logging!(info, Type::Core, "Configuration updated successfully"); - Ok(()) - } - Err(e) => { - let msg = e.to_string(); - Config::runtime().await.discard(); - logging_error!(Type::Core, "Failed to update configuration: {}", msg); - Err(msg) - } - } - } -} - -impl CoreManager { - /// 清理多余的 mihomo 进程 - async fn cleanup_orphaned_mihomo_processes(&self) -> Result<()> { - logging!(info, Type::Core, "开始清理多余的 mihomo 进程"); - - // 获取当前管理的进程 PID - let current_pid = { - let child_guard = self.child_sidecar.lock(); - child_guard.as_ref().map(|child| child.pid()) - }; - - let target_processes = ["verge-mihomo", "verge-mihomo-alpha"]; - - // 并行查找所有目标进程 - let mut process_futures = Vec::new(); - for &target in &target_processes { - let process_name = if cfg!(windows) { - format!("{target}.exe") - } else { - target.to_string() - }; - process_futures.push(self.find_processes_by_name(process_name, target)); - } - - let process_results = futures::future::join_all(process_futures).await; - - // 收集所有需要终止的进程PID - let mut pids_to_kill = Vec::new(); - for result in process_results { - match result { - Ok((pids, process_name)) => { - for pid in pids { - // 跳过当前管理的进程 - if let Some(current) = current_pid - && Some(pid) == current - { - logging!( - debug, - Type::Core, - "跳过当前管理的进程: {} (PID: {})", - process_name, - pid - ); - continue; - } - pids_to_kill.push((pid, process_name.clone())); - } - } - Err(e) => { - logging!(debug, Type::Core, "查找进程时发生错误: {}", e); - } - } - } - - if pids_to_kill.is_empty() { - logging!(debug, Type::Core, "未发现多余的 mihomo 进程"); - return Ok(()); - } - - let mut kill_futures = Vec::new(); - for (pid, process_name) in &pids_to_kill { - kill_futures.push(self.kill_process_with_verification(*pid, process_name.clone())); - } - - let kill_results = futures::future::join_all(kill_futures).await; - - let killed_count = kill_results.into_iter().filter(|&success| success).count(); - - if killed_count > 0 { - logging!( - info, - Type::Core, - "清理完成,共终止了 {} 个多余的 mihomo 进程", - killed_count - ); - } - - Ok(()) - } - - /// 根据进程名查找进程PID列 - async fn find_processes_by_name( - &self, - process_name: String, - _target: &str, - ) -> Result<(Vec, String)> { - #[cfg(windows)] - { - use std::mem; - use winapi::um::handleapi::CloseHandle; - use winapi::um::tlhelp32::{ - CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, - TH32CS_SNAPPROCESS, - }; - use winapi::um::winnt::HANDLE; - - let process_name_clone = process_name.clone(); - let pids = AsyncHandler::spawn_blocking(move || -> Result> { - let mut pids = Vec::new(); - - unsafe { - // 创建进程快照 - let snapshot: HANDLE = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - if snapshot == winapi::um::handleapi::INVALID_HANDLE_VALUE { - return Err(anyhow::anyhow!("Failed to create process snapshot")); - } - - let mut pe32: PROCESSENTRY32W = mem::zeroed(); - pe32.dwSize = mem::size_of::() as u32; - - // 获取第一个进程 - if Process32FirstW(snapshot, &mut pe32) != 0 { - loop { - // 将宽字符转换为String - let end_pos = pe32 - .szExeFile - .iter() - .position(|&x| x == 0) - .unwrap_or(pe32.szExeFile.len()); - let exe_file = String::from_utf16_lossy(&pe32.szExeFile[..end_pos]); - - // 检查进程名是否匹配 - if exe_file.eq_ignore_ascii_case(&process_name_clone) { - pids.push(pe32.th32ProcessID); - } - if Process32NextW(snapshot, &mut pe32) == 0 { - break; - } - } - } - - // 关闭句柄 - CloseHandle(snapshot); - } - - Ok(pids) - }) - .await??; - - Ok((pids, process_name)) - } - - #[cfg(not(windows))] - { - let output = if cfg!(target_os = "macos") { - tokio::process::Command::new("pgrep") - .arg(&process_name) - .output() - .await? - } else { - // Linux - tokio::process::Command::new("pidof") - .arg(&process_name) - .output() - .await? - }; - - if !output.status.success() { - return Ok((Vec::new(), process_name)); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let mut pids = Vec::new(); - - // Unix系统直接解析PID列表 - for pid_str in stdout.split_whitespace() { - if let Ok(pid) = pid_str.parse::() { - pids.push(pid); - } - } - - Ok((pids, process_name)) - } - } - - /// 终止进程并验证结果 - 使用Windows API直接终止,更优雅高效 - async fn kill_process_with_verification(&self, pid: u32, process_name: String) -> bool { - logging!( - info, - Type::Core, - "尝试终止进程: {} (PID: {})", - process_name, - pid - ); - - #[cfg(windows)] - let success = { - use winapi::um::handleapi::CloseHandle; - use winapi::um::processthreadsapi::{OpenProcess, TerminateProcess}; - use winapi::um::winnt::{HANDLE, PROCESS_TERMINATE}; - - AsyncHandler::spawn_blocking(move || -> bool { - unsafe { - let process_handle: HANDLE = OpenProcess(PROCESS_TERMINATE, 0, pid); - if process_handle.is_null() { - return false; - } - let result = TerminateProcess(process_handle, 1); - CloseHandle(process_handle); - - result != 0 - } - }) - .await - .unwrap_or(false) - }; - - #[cfg(not(windows))] - let success = { - tokio::process::Command::new("kill") - .args(["-9", &pid.to_string()]) - .output() - .await - .map(|output| output.status.success()) - .unwrap_or(false) - }; - - if success { - // 短暂等待并验证进程是否真正终止 - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - let still_running = self.is_process_running(pid).await.unwrap_or(false); - if still_running { - logging!( - warn, - Type::Core, - "进程 {} (PID: {}) 终止命令成功但进程仍在运行", - process_name, - pid - ); - false - } else { - logging!( - info, - Type::Core, - "成功终止进程: {} (PID: {})", - process_name, - pid - ); - true - } - } else { - logging!( - warn, - Type::Core, - "无法终止进程: {} (PID: {})", - process_name, - pid - ); - false - } - } - - /// Windows API检查进程 - async fn is_process_running(&self, pid: u32) -> Result { - #[cfg(windows)] - { - use winapi::shared::minwindef::DWORD; - use winapi::um::handleapi::CloseHandle; - use winapi::um::processthreadsapi::GetExitCodeProcess; - use winapi::um::processthreadsapi::OpenProcess; - use winapi::um::winnt::{HANDLE, PROCESS_QUERY_INFORMATION}; - - AsyncHandler::spawn_blocking(move || -> Result { - unsafe { - let process_handle: HANDLE = OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid); - if process_handle.is_null() { - return Ok(false); - } - let mut exit_code: DWORD = 0; - let result = GetExitCodeProcess(process_handle, &mut exit_code); - CloseHandle(process_handle); - - if result == 0 { - return Ok(false); - } - Ok(exit_code == 259) - } - }) - .await? - } - - #[cfg(not(windows))] - { - let output = tokio::process::Command::new("ps") - .args(["-p", &pid.to_string()]) - .output() - .await?; - - Ok(output.status.success() && !output.stdout.is_empty()) - } - } - - async fn start_core_by_sidecar(&self) -> Result<()> { - logging!(info, Type::Core, "Running core by sidecar"); - - let config_file = &Config::generate_file(ConfigType::Run).await?; - let app_handle = handle::Handle::app_handle(); - let clash_core = Config::verge().await.latest_ref().get_valid_clash_core(); - let config_dir = dirs::app_home_dir()?; - - let (mut rx, child) = app_handle - .shell() - .sidecar(&clash_core)? - .args([ - "-d", - dirs::path_to_str(&config_dir)?, - "-f", - dirs::path_to_str(config_file)?, - ]) - .spawn()?; - - let pid = child.pid(); - logging!(trace, Type::Core, "Started core by sidecar pid: {}", pid); - *self.child_sidecar.lock() = Some(CommandChildGuard::new(child)); - self.set_running_mode(RunningMode::Sidecar); - - let shared_writer: SharedWriter = - Arc::new(tokio::sync::Mutex::new(sidecar_writer().await?)); - - AsyncHandler::spawn(|| async move { - while let Some(event) = rx.recv().await { - let w = shared_writer.lock().await; - match event { - tauri_plugin_shell::process::CommandEvent::Stdout(line) => { - let mut now = DeferredNow::default(); - let message = String::from_utf8_lossy(&line).into_owned(); - let message = write_sidecar_log(&*w, &mut now, log::Level::Error, message); - Logger::global().append_log(message); - } - tauri_plugin_shell::process::CommandEvent::Stderr(line) => { - let mut now = DeferredNow::default(); - let message = String::from_utf8_lossy(&line).into_owned(); - let message = write_sidecar_log(&*w, &mut now, log::Level::Error, message); - Logger::global().append_log(message); - } - tauri_plugin_shell::process::CommandEvent::Terminated(term) => { - let mut now = DeferredNow::default(); - let message = if let Some(code) = term.code { - format!("Process terminated with code: {}", code) - } else if let Some(signal) = term.signal { - format!("Process terminated by signal: {}", signal) - } else { - "Process terminated".to_string() - }; - write_sidecar_log(&*w, &mut now, log::Level::Info, message); - break; - } - _ => {} - } - } - }); - - Ok(()) - } - fn stop_core_by_sidecar(&self) -> Result<()> { - logging!(info, Type::Core, "Stopping core by sidecar"); - - if let Some(child) = self.child_sidecar.lock().take() { - let pid = child.pid(); - drop(child); - logging!(trace, Type::Core, "Stopped core by sidecar pid: {:?}", pid); - } - self.set_running_mode(RunningMode::NotRunning); - Ok(()) - } -} - -impl CoreManager { - async fn start_core_by_service(&self) -> Result<()> { - logging!(info, Type::Core, "Running core by service"); - let config_file = &Config::generate_file(ConfigType::Run).await?; - service::run_core_by_service(config_file).await?; - self.set_running_mode(RunningMode::Service); - Ok(()) - } - async fn stop_core_by_service(&self) -> Result<()> { - logging!(info, Type::Core, "Stopping core by service"); - service::stop_core_by_service().await?; - self.set_running_mode(RunningMode::NotRunning); - Ok(()) - } -} - -impl Default for CoreManager { - fn default() -> Self { - CoreManager { - running: Arc::new(Mutex::new(RunningMode::NotRunning)), - child_sidecar: Arc::new(Mutex::new(None)), - } - } -} - -impl CoreManager { - pub async fn init(&self) -> Result<()> { - logging!(info, Type::Core, "Initializing core"); - - // 应用启动时先清理任何遗留的 mihomo 进程 - if let Err(e) = self.cleanup_orphaned_mihomo_processes().await { - logging!( - warn, - Type::Core, - "应用初始化时清理多余 mihomo 进程失败: {}", - e - ); - } - - // 使用简化的启动流程 - logging!(info, Type::Core, "开始核心初始化"); - self.start_core().await?; - - logging!(info, Type::Core, "核心初始化完成"); - Ok(()) - } - - pub fn set_running_mode(&self, mode: RunningMode) { - let mut guard = self.running.lock(); - *guard = mode; - } - - pub fn get_running_mode(&self) -> RunningMode { - let guard = self.running.lock(); - (*guard).clone() - } - - pub async fn prestart_core(&self) -> Result<()> { - match SERVICE_MANAGER.lock().await.current() { - ServiceStatus::Ready => { - self.set_running_mode(RunningMode::Service); - } - _ => { - self.set_running_mode(RunningMode::Sidecar); - } - } - Ok(()) - } - - /// 启动核心 - pub async fn start_core(&self) -> Result<()> { - self.prestart_core().await?; - - match self.get_running_mode() { - RunningMode::Service => { - logging_error!(Type::Core, self.start_core_by_service().await); - } - RunningMode::NotRunning | RunningMode::Sidecar => { - logging_error!(Type::Core, self.start_core_by_sidecar().await); - } - }; - - Ok(()) - } - - /// 停止核心运行 - pub async fn stop_core(&self) -> Result<()> { - Logger::global().clear_logs(); - match self.get_running_mode() { - RunningMode::Service => self.stop_core_by_service().await, - RunningMode::Sidecar => self.stop_core_by_sidecar(), - RunningMode::NotRunning => Ok(()), - } - } - - /// 重启内核 - pub async fn restart_core(&self) -> Result<()> { - logging!(info, Type::Core, "Restarting core"); - self.stop_core().await?; - if SERVICE_MANAGER.lock().await.init().await.is_ok() { - logging_error!(Type::Setup, SERVICE_MANAGER.lock().await.refresh().await); - } - self.start_core().await?; - Ok(()) - } - - /// 切换核心 - pub async fn change_core(&self, clash_core: Option) -> Result<(), String> { - if clash_core.is_none() { - let error_message = "Clash core should not be Null"; - logging!(error, Type::Core, "{}", error_message); - return Err(error_message.to_string()); - } - let core = clash_core.as_ref().ok_or_else(|| { - let msg = "Clash core should not be None"; - logging!(error, Type::Core, "{}", msg); - msg.to_string() - })?; - if !IVerge::VALID_CLASH_CORES.contains(&core.as_str()) { - let error_message = format!("Clash core invalid name: {core}"); - logging!(error, Type::Core, "{}", error_message); - return Err(error_message); - } - - Config::verge().await.draft_mut().clash_core = clash_core.clone(); - Config::verge().await.apply(); - - // 分离数据获取和异步调用避免Send问题 - let verge_data = Config::verge().await.latest_ref().clone(); - logging_error!(Type::Core, verge_data.save_file().await); - - let run_path = Config::generate_file(ConfigType::Run).await.map_err(|e| { - let msg = e.to_string(); - logging_error!(Type::Core, "{}", msg); - msg - })?; - - self.put_configs_force(run_path).await?; - - Ok(()) - } -} - -// Use simplified singleton_lazy macro -singleton_lazy!(CoreManager, CORE_MANAGER, CoreManager::default); diff --git a/clash-verge-rev/src-tauri/src/core/event_driven_proxy.rs b/clash-verge-rev/src-tauri/src/core/event_driven_proxy.rs index 43efc3dac8..e03e9eecab 100644 --- a/clash-verge-rev/src-tauri/src/core/event_driven_proxy.rs +++ b/clash-verge-rev/src-tauri/src/core/event_driven_proxy.rs @@ -2,39 +2,23 @@ use std::sync::Arc; use tokio::sync::RwLock; use tokio::sync::{mpsc, oneshot}; use tokio::time::{Duration, sleep, timeout}; -use tokio_stream::{StreamExt, wrappers::UnboundedReceiverStream}; +use tokio_stream::{StreamExt as _, wrappers::UnboundedReceiverStream}; use crate::config::{Config, IVerge}; use crate::core::{async_proxy_query::AsyncProxyQuery, handle}; -use crate::logging_error; use crate::process::AsyncHandler; -use crate::utils::logging::Type; +use crate::{logging, utils::logging::Type}; use once_cell::sync::Lazy; +use smartstring::alias::String; use sysproxy::{Autoproxy, Sysproxy}; #[derive(Debug, Clone)] pub enum ProxyEvent { /// 配置变更事件 ConfigChanged, - /// 强制检查代理状态 - #[allow(dead_code)] - ForceCheck, - /// 启用系统代理 - #[allow(dead_code)] - EnableProxy, - /// 禁用系统代理 - #[allow(dead_code)] - DisableProxy, - /// 切换到PAC模式 - #[allow(dead_code)] - SwitchToPac, - /// 切换到HTTP代理模式 - #[allow(dead_code)] - SwitchToHttp, /// 应用启动事件 AppStarted, /// 应用关闭事件 - #[allow(dead_code)] AppStopping, } @@ -55,13 +39,13 @@ impl Default for ProxyState { pac_enabled: false, auto_proxy: Autoproxy { enable: false, - url: "".to_string(), + url: "".into(), }, sys_proxy: Sysproxy { enable: false, - host: "127.0.0.1".to_string(), + host: "127.0.0.1".into(), port: 7897, - bypass: "".to_string(), + bypass: "".into(), }, last_updated: std::time::Instant::now(), is_healthy: true, @@ -91,7 +75,7 @@ struct ProxyConfig { static PROXY_MANAGER: Lazy = Lazy::new(EventDrivenProxyManager::new); impl EventDrivenProxyManager { - pub fn global() -> &'static EventDrivenProxyManager { + pub fn global() -> &'static Self { &PROXY_MANAGER } @@ -121,14 +105,14 @@ impl EventDrivenProxyManager { let query = QueryRequest { response_tx: tx }; if self.query_sender.send(query).is_err() { - log::error!(target: "app", "发送查询请求失败,返回缓存数据"); + logging!(error, Type::Network, "发送查询请求失败,返回缓存数据"); return self.get_auto_proxy_cached().await; } match timeout(Duration::from_secs(5), rx).await { Ok(Ok(result)) => result, _ => { - log::warn!(target: "app", "查询超时,返回缓存数据"); + logging!(warn, Type::Network, "Warning: 查询超时,返回缓存数据"); self.get_auto_proxy_cached().await } } @@ -145,32 +129,13 @@ impl EventDrivenProxyManager { } /// 通知应用即将关闭 - #[allow(dead_code)] pub fn notify_app_stopping(&self) { self.send_event(ProxyEvent::AppStopping); } - /// 启用系统代理 - #[allow(dead_code)] - pub fn enable_proxy(&self) { - self.send_event(ProxyEvent::EnableProxy); - } - - /// 禁用系统代理 - #[allow(dead_code)] - pub fn disable_proxy(&self) { - self.send_event(ProxyEvent::DisableProxy); - } - - /// 强制检查代理状态 - #[allow(dead_code)] - pub fn force_check(&self) { - self.send_event(ProxyEvent::ForceCheck); - } - fn send_event(&self, event: ProxyEvent) { if let Err(e) = self.event_sender.send(event) { - log::error!(target: "app", "发送代理事件失败: {e}"); + logging!(error, Type::Network, "发送代理事件失败: {e}"); } } @@ -179,7 +144,7 @@ impl EventDrivenProxyManager { event_rx: mpsc::UnboundedReceiver, query_rx: mpsc::UnboundedReceiver, ) { - log::info!(target: "app", "事件驱动代理管理器启动"); + logging!(info, Type::Network, "事件驱动代理管理器启动"); // 将 mpsc 接收器包装成 Stream,避免每次循环创建 future let mut event_stream = UnboundedReceiverStream::new(event_rx); @@ -194,7 +159,7 @@ impl EventDrivenProxyManager { loop { tokio::select! { Some(event) = event_stream.next() => { - log::debug!(target: "app", "处理代理事件: {event:?}"); + logging!(debug, Type::Network, "处理代理事件: {event:?}"); let event_clone = event.clone(); // 保存一份副本用于后续检查 Self::handle_event(&state, event).await; @@ -215,13 +180,13 @@ impl EventDrivenProxyManager { // 定时检查代理设置 let config = Self::get_proxy_config().await; if config.guard_enabled && config.sys_enabled { - log::debug!(target: "app", "定时检查代理设置"); + logging!(debug, Type::Network, "定时检查代理设置"); Self::check_and_restore_proxy(&state).await; } } else => { // 两个通道都关闭时退出 - log::info!(target: "app", "事件或查询通道关闭,代理管理器停止"); + logging!(info, Type::Network, "事件或查询通道关闭,代理管理器停止"); break; } } @@ -230,26 +195,14 @@ impl EventDrivenProxyManager { async fn handle_event(state: &Arc>, event: ProxyEvent) { match event { - ProxyEvent::ConfigChanged | ProxyEvent::ForceCheck => { + ProxyEvent::ConfigChanged => { Self::update_proxy_config(state).await; } - ProxyEvent::EnableProxy => { - Self::enable_system_proxy(state).await; - } - ProxyEvent::DisableProxy => { - Self::disable_system_proxy(state); - } - ProxyEvent::SwitchToPac => { - Self::switch_proxy_mode(state, true).await; - } - ProxyEvent::SwitchToHttp => { - Self::switch_proxy_mode(state, false).await; - } ProxyEvent::AppStarted => { Self::initialize_proxy_state(state).await; } ProxyEvent::AppStopping => { - log::info!(target: "app", "清理代理状态"); + logging!(info, Type::Network, "清理代理状态"); Self::update_state_timestamp(state, |s| { s.sys_enabled = false; s.pac_enabled = false; @@ -272,7 +225,7 @@ impl EventDrivenProxyManager { } async fn initialize_proxy_state(state: &Arc>) { - log::info!(target: "app", "初始化代理状态"); + logging!(info, Type::Network, "初始化代理状态"); let config = Self::get_proxy_config().await; let auto_proxy = Self::get_auto_proxy_with_timeout().await; @@ -287,11 +240,17 @@ impl EventDrivenProxyManager { }) .await; - log::info!(target: "app", "代理状态初始化完成: sys={}, pac={}", config.sys_enabled, config.pac_enabled); + logging!( + info, + Type::Network, + "代理状态初始化完成: sys={}, pac={}", + config.sys_enabled, + config.pac_enabled + ); } async fn update_proxy_config(state: &Arc>) { - log::debug!(target: "app", "更新代理配置"); + logging!(debug, Type::Network, "更新代理配置"); let config = Self::get_proxy_config().await; @@ -308,7 +267,7 @@ impl EventDrivenProxyManager { async fn check_and_restore_proxy(state: &Arc>) { if handle::Handle::global().is_exiting() { - log::debug!(target: "app", "应用正在退出,跳过系统代理守卫检查"); + logging!(debug, Type::Network, "应用正在退出,跳过系统代理守卫检查"); return; } let (sys_enabled, pac_enabled) = { @@ -320,7 +279,7 @@ impl EventDrivenProxyManager { return; } - log::debug!(target: "app", "检查代理状态"); + logging!(debug, Type::Network, "检查代理状态"); if pac_enabled { Self::check_and_restore_pac_proxy(state).await; @@ -331,7 +290,7 @@ impl EventDrivenProxyManager { async fn check_and_restore_pac_proxy(state: &Arc>) { if handle::Handle::global().is_exiting() { - log::debug!(target: "app", "应用正在退出,跳过PAC代理恢复检查"); + logging!(debug, Type::Network, "应用正在退出,跳过PAC代理恢复检查"); return; } @@ -344,9 +303,9 @@ impl EventDrivenProxyManager { .await; if !current.enable || current.url != expected.url { - log::info!(target: "app", "PAC代理设置异常,正在恢复..."); + logging!(info, Type::Network, "PAC代理设置异常,正在恢复..."); if let Err(e) = Self::restore_pac_proxy(&expected.url).await { - log::error!(target: "app", "恢复PAC代理失败: {}", e); + logging!(error, Type::Network, "恢复PAC代理失败: {}", e); } sleep(Duration::from_millis(500)).await; @@ -362,7 +321,7 @@ impl EventDrivenProxyManager { async fn check_and_restore_sys_proxy(state: &Arc>) { if handle::Handle::global().is_exiting() { - log::debug!(target: "app", "应用正在退出,跳过系统代理恢复检查"); + logging!(debug, Type::Network, "应用正在退出,跳过系统代理恢复检查"); return; } @@ -375,9 +334,9 @@ impl EventDrivenProxyManager { .await; if !current.enable || current.host != expected.host || current.port != expected.port { - log::info!(target: "app", "系统代理设置异常,正在恢复..."); + logging!(info, Type::Network, "系统代理设置异常,正在恢复..."); if let Err(e) = Self::restore_sys_proxy(&expected).await { - log::error!(target: "app", "恢复系统代理失败: {}", e); + logging!(error, Type::Network, "恢复系统代理失败: {}", e); } sleep(Duration::from_millis(500)).await; @@ -393,74 +352,6 @@ impl EventDrivenProxyManager { } } - async fn enable_system_proxy(state: &Arc>) { - if handle::Handle::global().is_exiting() { - log::debug!(target: "app", "应用正在退出,跳过启用系统代理"); - return; - } - - log::info!(target: "app", "启用系统代理"); - - let pac_enabled = state.read().await.pac_enabled; - - if pac_enabled { - let expected = Self::get_expected_pac_config().await; - if let Err(e) = Self::restore_pac_proxy(&expected.url).await { - log::error!(target: "app", "启用PAC代理失败: {}", e); - } - } else { - let expected = Self::get_expected_sys_proxy().await; - if let Err(e) = Self::restore_sys_proxy(&expected).await { - log::error!(target: "app", "启用系统代理失败: {}", e); - } - } - - Self::check_and_restore_proxy(state).await; - } - - fn disable_system_proxy(_state: &Arc>) { - log::info!(target: "app", "禁用系统代理"); - - #[cfg(not(target_os = "windows"))] - { - let disabled_sys = Sysproxy::default(); - let disabled_auto = Autoproxy::default(); - - logging_error!(Type::System, disabled_auto.set_auto_proxy()); - logging_error!(Type::System, disabled_sys.set_system_proxy()); - } - } - - async fn switch_proxy_mode(state: &Arc>, to_pac: bool) { - if handle::Handle::global().is_exiting() { - log::debug!(target: "app", "应用正在退出,跳过代理模式切换"); - return; - } - - log::info!(target: "app", "切换到{}模式", if to_pac { "PAC" } else { "HTTP代理" }); - - if to_pac { - let disabled_sys = Sysproxy::default(); - logging_error!(Type::System, disabled_sys.set_system_proxy()); - - let expected = Self::get_expected_pac_config().await; - if let Err(e) = Self::restore_pac_proxy(&expected.url).await { - log::error!(target: "app", "切换到PAC模式失败: {}", e); - } - } else { - let disabled_auto = Autoproxy::default(); - logging_error!(Type::System, disabled_auto.set_auto_proxy()); - - let expected = Self::get_expected_sys_proxy().await; - if let Err(e) = Self::restore_sys_proxy(&expected).await { - log::error!(target: "app", "切换到HTTP代理模式失败: {}", e); - } - } - - Self::update_state_timestamp(state, |s| s.pac_enabled = to_pac).await; - Self::check_and_restore_proxy(state).await; - } - async fn get_auto_proxy_with_timeout() -> Autoproxy { let async_proxy = AsyncProxyQuery::get_auto_proxy().await; @@ -496,7 +387,7 @@ impl EventDrivenProxyManager { async fn get_proxy_config() -> ProxyConfig { let (sys_enabled, pac_enabled, guard_enabled, guard_duration) = { let verge_config = Config::verge().await; - let verge = verge_config.latest_ref(); + let verge = verge_config.latest_arc(); ( verge.enable_system_proxy.unwrap_or(false), verge.proxy_auto_config.unwrap_or(false), @@ -515,11 +406,11 @@ impl EventDrivenProxyManager { async fn get_expected_pac_config() -> Autoproxy { let proxy_host = { let verge_config = Config::verge().await; - let verge = verge_config.latest_ref(); + let verge = verge_config.latest_arc(); verge .proxy_host .clone() - .unwrap_or_else(|| "127.0.0.1".to_string()) + .unwrap_or_else(|| "127.0.0.1".into()) }; let pac_port = IVerge::get_singleton_port(); Autoproxy { @@ -529,54 +420,51 @@ impl EventDrivenProxyManager { } async fn get_expected_sys_proxy() -> Sysproxy { - let verge_config = Config::verge().await; - let verge_mixed_port = verge_config.latest_ref().verge_mixed_port; - let proxy_host = verge_config.latest_ref().proxy_host.clone(); + use crate::constants::network; - let port = verge_mixed_port.unwrap_or(Config::clash().await.latest_ref().get_mixed_port()); - let proxy_host = proxy_host.unwrap_or_else(|| "127.0.0.1".to_string()); + let (verge_mixed_port, proxy_host) = { + let verge_config = Config::verge().await; + let verge_ref = verge_config.latest_arc(); + (verge_ref.verge_mixed_port, verge_ref.proxy_host.clone()) + }; + + let default_port = { + let clash_config = Config::clash().await; + clash_config.latest_arc().get_mixed_port() + }; + + let port = verge_mixed_port.unwrap_or(default_port); + let host = proxy_host + .unwrap_or_else(|| network::DEFAULT_PROXY_HOST.into()) + .into(); Sysproxy { enable: true, - host: proxy_host, + host, port, - bypass: Self::get_bypass_config().await, + bypass: Self::get_bypass_config().await.into(), } } async fn get_bypass_config() -> String { - let (use_default, custom_bypass) = { - let verge_config = Config::verge().await; - let verge = verge_config.latest_ref(); - ( - verge.use_default_bypass.unwrap_or(true), - verge.system_proxy_bypass.clone().unwrap_or_default(), - ) - }; + use crate::constants::bypass; - #[cfg(target_os = "windows")] - let default_bypass = "localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;"; + let verge_config = Config::verge().await; + let verge = verge_config.latest_arc(); + let use_default = verge.use_default_bypass.unwrap_or(true); + let custom = verge.system_proxy_bypass.as_deref().unwrap_or(""); - #[cfg(target_os = "linux")] - let default_bypass = - "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,172.29.0.0/16,::1"; - - #[cfg(target_os = "macos")] - let default_bypass = "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,172.29.0.0/16,localhost,*.local,*.crashlytics.com,"; - - if custom_bypass.is_empty() { - default_bypass.to_string() - } else if use_default { - format!("{default_bypass},{custom_bypass}") - } else { - custom_bypass + match (use_default, custom.is_empty()) { + (_, true) => bypass::DEFAULT.into(), + (true, false) => format!("{},{}", bypass::DEFAULT, custom).into(), + (false, false) => custom.into(), } } #[cfg(target_os = "windows")] async fn restore_pac_proxy(expected_url: &str) -> Result<(), anyhow::Error> { if handle::Handle::global().is_exiting() { - log::debug!(target: "app", "应用正在退出,跳过PAC代理恢复"); + logging!(debug, Type::Network, "应用正在退出,跳过PAC代理恢复"); return Ok(()); } Self::execute_sysproxy_command(&["pac", expected_url]).await @@ -600,7 +488,7 @@ impl EventDrivenProxyManager { #[cfg(target_os = "windows")] async fn restore_sys_proxy(expected: &Sysproxy) -> Result<(), anyhow::Error> { if handle::Handle::global().is_exiting() { - log::debug!(target: "app", "应用正在退出,跳过系统代理恢复"); + logging!(debug, Type::Network, "应用正在退出,跳过系统代理恢复"); return Ok(()); } let address = format!("{}:{}", expected.host, expected.port); @@ -621,8 +509,9 @@ impl EventDrivenProxyManager { #[cfg(target_os = "windows")] async fn execute_sysproxy_command(args: &[&str]) -> Result<(), anyhow::Error> { if handle::Handle::global().is_exiting() { - log::debug!( - target: "app", + logging!( + debug, + Type::Network, "应用正在退出,取消调用 sysproxy.exe,参数: {:?}", args ); @@ -631,20 +520,20 @@ impl EventDrivenProxyManager { use crate::utils::dirs; #[allow(unused_imports)] // creation_flags必须 - use std::os::windows::process::CommandExt; + use std::os::windows::process::CommandExt as _; use tokio::process::Command; let binary_path = match dirs::service_path() { Ok(path) => path, Err(e) => { - log::error!(target: "app", "获取服务路径失败: {e}"); + logging!(error, Type::Network, "获取服务路径失败: {e}"); return Err(e); } }; let sysproxy_exe = binary_path.with_file_name("sysproxy.exe"); if !sysproxy_exe.exists() { - log::error!(target: "app", "sysproxy.exe 不存在"); + logging!(error, Type::Network, "sysproxy.exe 不存在"); } anyhow::ensure!(sysproxy_exe.exists(), "sysproxy.exe does not exist"); diff --git a/clash-verge-rev/src-tauri/src/core/handle.rs b/clash-verge-rev/src-tauri/src/core/handle.rs index 1c36ef6c49..a5c973a61e 100644 --- a/clash-verge-rev/src-tauri/src/core/handle.rs +++ b/clash-verge-rev/src-tauri/src/core/handle.rs @@ -1,274 +1,38 @@ -use crate::{APP_HANDLE, singleton}; +use crate::{APP_HANDLE, constants::timing, singleton}; use parking_lot::RwLock; +use smartstring::alias::String; use std::{ sync::{ Arc, - atomic::{AtomicU64, Ordering}, - mpsc, + atomic::{AtomicBool, Ordering}, }, thread, - time::{Duration, Instant}, }; -use tauri::{AppHandle, Emitter, Manager, WebviewWindow}; -use tauri_plugin_mihomo::{Mihomo, MihomoExt}; -use tokio::sync::{RwLockReadGuard, RwLockWriteGuard}; +use tauri::{AppHandle, Manager as _, WebviewWindow}; +use tauri_plugin_mihomo::{Mihomo, MihomoExt as _}; +use tokio::sync::RwLockReadGuard; -use crate::{logging, utils::logging::Type}; +use super::notification::{ErrorMessage, FrontendEvent, NotificationSystem}; -/// 不同类型的前端通知 -#[derive(Debug, Clone)] -enum FrontendEvent { - RefreshClash, - RefreshVerge, - NoticeMessage { status: String, message: String }, - ProfileChanged { current_profile_id: String }, - TimerUpdated { profile_index: String }, - ProfileUpdateStarted { uid: String }, - ProfileUpdateCompleted { uid: String }, -} - -/// 事件发送统计和监控 -#[derive(Debug, Default)] -struct EventStats { - total_sent: AtomicU64, - total_errors: AtomicU64, - last_error_time: RwLock>, -} - -/// 存储启动期间的错误消息 -#[derive(Debug, Clone)] -struct ErrorMessage { - status: String, - message: String, -} - -/// 全局前端通知系统 #[derive(Debug)] -struct NotificationSystem { - sender: Option>, - worker_handle: Option>, - is_running: bool, - stats: EventStats, - last_emit_time: RwLock, - /// 当通知系统失败超过阈值时,进入紧急模式 - emergency_mode: RwLock, -} - -impl Default for NotificationSystem { - fn default() -> Self { - Self::new() - } -} - -impl NotificationSystem { - fn new() -> Self { - Self { - sender: None, - worker_handle: None, - is_running: false, - stats: EventStats::default(), - last_emit_time: RwLock::new(Instant::now()), - emergency_mode: RwLock::new(false), - } - } - - /// 启动通知处理线程 - fn start(&mut self) { - if self.is_running { - return; - } - - let (tx, rx) = mpsc::channel(); - self.sender = Some(tx); - self.is_running = true; - - *self.last_emit_time.write() = Instant::now(); - - match thread::Builder::new() - .name("frontend-notifier".into()) - .spawn(move || { - let handle = Handle::global(); - - while !handle.is_exiting() { - match rx.recv_timeout(Duration::from_millis(100)) { - Ok(event) => { - let system_guard = handle.notification_system.read(); - let Some(system) = system_guard.as_ref() else { - log::warn!("NotificationSystem not found in handle while processing event."); - continue; - }; - - let is_emergency = *system.emergency_mode.read(); - - if is_emergency - && let FrontendEvent::NoticeMessage { ref status, .. } = event - && status == "info" { - log::warn!( - "Emergency mode active, skipping info message" - ); - continue; - } - - if let Some(window) = Handle::get_window() { - *system.last_emit_time.write() = Instant::now(); - - let (event_name_str, payload_result) = match event { - FrontendEvent::RefreshClash => { - ("verge://refresh-clash-config", Ok(serde_json::json!("yes"))) - } - FrontendEvent::RefreshVerge => { - ("verge://refresh-verge-config", Ok(serde_json::json!("yes"))) - } - FrontendEvent::NoticeMessage { status, message } => { - match serde_json::to_value((status, message)) { - Ok(p) => ("verge://notice-message", Ok(p)), - Err(e) => { - log::error!("Failed to serialize NoticeMessage payload: {e}"); - ("verge://notice-message", Err(e)) - } - } - } - FrontendEvent::ProfileChanged { current_profile_id } => { - ("profile-changed", Ok(serde_json::json!(current_profile_id))) - } - FrontendEvent::TimerUpdated { profile_index } => { - ("verge://timer-updated", Ok(serde_json::json!(profile_index))) - } - FrontendEvent::ProfileUpdateStarted { uid } => { - ("profile-update-started", Ok(serde_json::json!({ "uid": uid }))) - } - FrontendEvent::ProfileUpdateCompleted { uid } => { - ("profile-update-completed", Ok(serde_json::json!({ "uid": uid }))) - } - }; - - if let Ok(payload) = payload_result { - match window.emit(event_name_str, payload) { - Ok(_) => { - system.stats.total_sent.fetch_add(1, Ordering::SeqCst); - // 记录成功发送的事件 - if log::log_enabled!(log::Level::Debug) { - log::debug!("Successfully emitted event: {event_name_str}"); - } - } - Err(e) => { - log::warn!("Failed to emit event {event_name_str}: {e}"); - system.stats.total_errors.fetch_add(1, Ordering::SeqCst); - *system.stats.last_error_time.write() = Some(Instant::now()); - - let errors = system.stats.total_errors.load(Ordering::SeqCst); - const EMIT_ERROR_THRESHOLD: u64 = 10; - if errors > EMIT_ERROR_THRESHOLD && !*system.emergency_mode.read() { - log::warn!( - "Reached {EMIT_ERROR_THRESHOLD} emit errors, entering emergency mode" - ); - *system.emergency_mode.write() = true; - } - } - } - } else { - system.stats.total_errors.fetch_add(1, Ordering::SeqCst); - *system.stats.last_error_time.write() = Some(Instant::now()); - log::warn!("Skipped emitting event due to payload serialization error for {event_name_str}"); - } - } else { - log::warn!("No window found, skipping event emit."); - } - thread::sleep(Duration::from_millis(20)); - } - Err(mpsc::RecvTimeoutError::Timeout) => { - } - Err(mpsc::RecvTimeoutError::Disconnected) => { - log::info!( - "Notification channel disconnected, exiting worker thread" - ); - break; - } - } - } - - log::info!("Notification worker thread exiting"); - }) { - Ok(handle) => { - self.worker_handle = Some(handle); - } - Err(e) => { - log::error!("Failed to start notification worker thread: {e}"); - } - } - } - - /// 发送事件到队列 - fn send_event(&self, event: FrontendEvent) -> bool { - if *self.emergency_mode.read() - && let FrontendEvent::NoticeMessage { ref status, .. } = event - && status == "info" - { - log::info!("Skipping info message in emergency mode"); - return false; - } - - if let Some(sender) = &self.sender { - match sender.send(event) { - Ok(_) => true, - Err(e) => { - log::warn!("Failed to send event to notification queue: {e:?}"); - self.stats.total_errors.fetch_add(1, Ordering::SeqCst); - *self.stats.last_error_time.write() = Some(Instant::now()); - false - } - } - } else { - log::warn!("Notification system not started, can't send event"); - false - } - } - - fn shutdown(&mut self) { - log::info!("NotificationSystem shutdown initiated"); - self.is_running = false; - - // 先关闭发送端,让接收端知道不会再有新消息 - if let Some(sender) = self.sender.take() { - drop(sender); - } - - // 设置超时避免无限等待 - if let Some(handle) = self.worker_handle.take() { - match handle.join() { - Ok(_) => { - log::info!("NotificationSystem worker thread joined successfully"); - } - Err(e) => { - log::error!("NotificationSystem worker thread join failed: {e:?}"); - } - } - } - - log::info!("NotificationSystem shutdown completed"); - } -} - -#[derive(Debug, Clone)] pub struct Handle { - pub is_exiting: Arc>, + is_exiting: AtomicBool, startup_errors: Arc>>, - startup_completed: Arc>, - notification_system: Arc>>, + startup_completed: AtomicBool, + pub(crate) notification_system: Arc>>, } impl Default for Handle { fn default() -> Self { Self { - is_exiting: Arc::new(RwLock::new(false)), + is_exiting: AtomicBool::new(false), startup_errors: Arc::new(RwLock::new(Vec::new())), - startup_completed: Arc::new(RwLock::new(false)), + startup_completed: AtomicBool::new(false), notification_system: Arc::new(RwLock::new(Some(NotificationSystem::new()))), } } } -// Use singleton macro singleton!(Handle, HANDLE); impl Handle { @@ -277,45 +41,29 @@ impl Handle { } pub fn init(&self) { - // 如果正在退出,不要重新初始化 if self.is_exiting() { - log::debug!("Handle::init called while exiting, skipping initialization"); return; } let mut system_opt = self.notification_system.write(); - if let Some(system) = system_opt.as_mut() { - // 只在未运行时启动 - if !system.is_running { - system.start(); - } else { - log::debug!("NotificationSystem already running, skipping start"); - } + if let Some(system) = system_opt.as_mut() + && !system.is_running + { + system.start(); } } - /// 获取 AppHandle - #[allow(clippy::expect_used)] pub fn app_handle() -> &'static AppHandle { - APP_HANDLE.get().expect("failed to get global app handle") + #[allow(clippy::expect_used)] + APP_HANDLE.get().expect("App handle not initialized") } pub async fn mihomo() -> RwLockReadGuard<'static, Mihomo> { Self::app_handle().mihomo().read().await } - #[allow(unused)] - pub async fn mihomo_mut() -> RwLockWriteGuard<'static, Mihomo> { - Self::app_handle().mihomo().write().await - } - pub fn get_window() -> Option { - let app_handle = Self::app_handle(); - let window: Option = app_handle.get_webview_window("main"); - if window.is_none() { - log::debug!(target:"app", "main window not found"); - } - window + Self::app_handle().get_webview_window("main") } pub fn refresh_clash() { @@ -343,88 +91,31 @@ impl Handle { } pub fn notify_profile_changed(profile_id: String) { - let handle = Self::global(); - if handle.is_exiting() { - return; - } - - let system_opt = handle.notification_system.read(); - if let Some(system) = system_opt.as_ref() { - system.send_event(FrontendEvent::ProfileChanged { - current_profile_id: profile_id, - }); - } else { - log::warn!( - "Notification system not initialized when trying to send ProfileChanged event." - ); - } + Self::send_event(FrontendEvent::ProfileChanged { + current_profile_id: profile_id, + }); } pub fn notify_timer_updated(profile_index: String) { - let handle = Self::global(); - if handle.is_exiting() { - return; - } - - let system_opt = handle.notification_system.read(); - if let Some(system) = system_opt.as_ref() { - system.send_event(FrontendEvent::TimerUpdated { profile_index }); - } else { - log::warn!( - "Notification system not initialized when trying to send TimerUpdated event." - ); - } + Self::send_event(FrontendEvent::TimerUpdated { profile_index }); } pub fn notify_profile_update_started(uid: String) { - let handle = Self::global(); - if handle.is_exiting() { - return; - } - - let system_opt = handle.notification_system.read(); - if let Some(system) = system_opt.as_ref() { - system.send_event(FrontendEvent::ProfileUpdateStarted { uid }); - } else { - log::warn!( - "Notification system not initialized when trying to send ProfileUpdateStarted event." - ); - } + Self::send_event(FrontendEvent::ProfileUpdateStarted { uid }); } pub fn notify_profile_update_completed(uid: String) { - let handle = Self::global(); - if handle.is_exiting() { - return; - } - - let system_opt = handle.notification_system.read(); - if let Some(system) = system_opt.as_ref() { - system.send_event(FrontendEvent::ProfileUpdateCompleted { uid }); - } else { - log::warn!( - "Notification system not initialized when trying to send ProfileUpdateCompleted event." - ); - } + Self::send_event(FrontendEvent::ProfileUpdateCompleted { uid }); } - /// 通知前端显示消息队列 + // TODO 利用 &str 等缩短 Clone pub fn notice_message, M: Into>(status: S, msg: M) { let handle = Self::global(); let status_str = status.into(); let msg_str = msg.into(); - if !*handle.startup_completed.read() { - logging!( - info, - Type::Frontend, - "启动过程中发现错误,加入消息队列: {} - {}", - status_str, - msg_str - ); - - let mut errors = handle.startup_errors.write(); - errors.push(ErrorMessage { + if !handle.startup_completed.load(Ordering::Acquire) { + handle.startup_errors.write().push(ErrorMessage { status: status_str, message: msg_str, }); @@ -435,25 +126,29 @@ impl Handle { return; } + Self::send_event(FrontendEvent::NoticeMessage { + status: status_str, + message: msg_str, + }); + } + + fn send_event(event: FrontendEvent) { + let handle = Self::global(); + if handle.is_exiting() { + return; + } + let system_opt = handle.notification_system.read(); if let Some(system) = system_opt.as_ref() { - system.send_event(FrontendEvent::NoticeMessage { - status: status_str, - message: msg_str, - }); + system.send_event(event); } } pub fn mark_startup_completed(&self) { - { - let mut completed = self.startup_completed.write(); - *completed = true; - } - + self.startup_completed.store(true, Ordering::Release); self.send_startup_errors(); } - /// 发送启动时累积的所有错误消息 fn send_startup_errors(&self) { let errors = { let mut errors = self.startup_errors.write(); @@ -464,21 +159,12 @@ impl Handle { return; } - logging!( - info, - Type::Frontend, - "发送{}条启动时累积的错误消息: {:?}", - errors.len(), - errors - ); - - // 启动单独线程处理启动错误,避免阻塞主线程 - let thread_result = thread::Builder::new() + let _ = thread::Builder::new() .name("startup-errors-sender".into()) .spawn(move || { - thread::sleep(Duration::from_secs(2)); + thread::sleep(timing::STARTUP_ERROR_DELAY); - let handle = Handle::global(); + let handle = Self::global(); if handle.is_exiting() { return; } @@ -495,19 +181,14 @@ impl Handle { message: error.message, }); - thread::sleep(Duration::from_millis(300)); + thread::sleep(timing::ERROR_BATCH_DELAY); } } }); - - if let Err(e) = thread_result { - log::error!("Failed to spawn startup errors thread: {e}"); - } } pub fn set_is_exiting(&self) { - let mut is_exiting = self.is_exiting.write(); - *is_exiting = true; + self.is_exiting.store(true, Ordering::Release); let mut system_opt = self.notification_system.write(); if let Some(system) = system_opt.as_mut() { @@ -516,50 +197,23 @@ impl Handle { } pub fn is_exiting(&self) -> bool { - *self.is_exiting.read() + self.is_exiting.load(Ordering::Acquire) } } #[cfg(target_os = "macos")] impl Handle { pub fn set_activation_policy(&self, policy: tauri::ActivationPolicy) -> Result<(), String> { - let app_handle = Self::app_handle(); - app_handle + Self::app_handle() .set_activation_policy(policy) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string().into()) } pub fn set_activation_policy_regular(&self) { - if let Err(e) = self.set_activation_policy(tauri::ActivationPolicy::Regular) { - logging!( - warn, - Type::Setup, - "Failed to set regular activation policy: {}", - e - ); - } + let _ = self.set_activation_policy(tauri::ActivationPolicy::Regular); } pub fn set_activation_policy_accessory(&self) { - if let Err(e) = self.set_activation_policy(tauri::ActivationPolicy::Accessory) { - logging!( - warn, - Type::Setup, - "Failed to set accessory activation policy: {}", - e - ); - } - } - - #[allow(dead_code)] - pub fn set_activation_policy_prohibited(&self) { - if let Err(e) = self.set_activation_policy(tauri::ActivationPolicy::Prohibited) { - logging!( - warn, - Type::Setup, - "Failed to set prohibited activation policy: {}", - e - ); - } + let _ = self.set_activation_policy(tauri::ActivationPolicy::Accessory); } } diff --git a/clash-verge-rev/src-tauri/src/core/hotkey.rs b/clash-verge-rev/src-tauri/src/core/hotkey.rs index f527d4b11a..44db1f0196 100755 --- a/clash-verge-rev/src-tauri/src/core/hotkey.rs +++ b/clash-verge-rev/src-tauri/src/core/hotkey.rs @@ -1,14 +1,14 @@ use crate::process::AsyncHandler; use crate::utils::notification::{NotificationEvent, notify_event}; use crate::{ - config::Config, core::handle, feat, logging, logging_error, - module::lightweight::entry_lightweight_mode, singleton_with_logging, utils::logging::Type, + config::Config, core::handle, feat, logging, module::lightweight::entry_lightweight_mode, + singleton_with_logging, utils::logging::Type, }; use anyhow::{Result, bail}; -use parking_lot::Mutex; +use arc_swap::ArcSwap; +use smartstring::alias::String; use std::{collections::HashMap, fmt, str::FromStr, sync::Arc}; -use tauri::{AppHandle, Manager}; -use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, ShortcutState}; +use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt as _, ShortcutState}; /// Enum representing all available hotkey functions #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -28,16 +28,16 @@ pub enum HotkeyFunction { impl fmt::Display for HotkeyFunction { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let s = match self { - HotkeyFunction::OpenOrCloseDashboard => "open_or_close_dashboard", - HotkeyFunction::ClashModeRule => "clash_mode_rule", - HotkeyFunction::ClashModeGlobal => "clash_mode_global", - HotkeyFunction::ClashModeDirect => "clash_mode_direct", - HotkeyFunction::ToggleSystemProxy => "toggle_system_proxy", - HotkeyFunction::ToggleTunMode => "toggle_tun_mode", - HotkeyFunction::EntryLightweightMode => "entry_lightweight_mode", - HotkeyFunction::Quit => "quit", + Self::OpenOrCloseDashboard => "open_or_close_dashboard", + Self::ClashModeRule => "clash_mode_rule", + Self::ClashModeGlobal => "clash_mode_global", + Self::ClashModeDirect => "clash_mode_direct", + Self::ToggleSystemProxy => "toggle_system_proxy", + Self::ToggleTunMode => "toggle_tun_mode", + Self::EntryLightweightMode => "entry_lightweight_mode", + Self::Quit => "quit", #[cfg(target_os = "macos")] - HotkeyFunction::Hide => "hide", + Self::Hide => "hide", }; write!(f, "{s}") } @@ -48,16 +48,16 @@ impl FromStr for HotkeyFunction { fn from_str(s: &str) -> Result { match s.trim() { - "open_or_close_dashboard" => Ok(HotkeyFunction::OpenOrCloseDashboard), - "clash_mode_rule" => Ok(HotkeyFunction::ClashModeRule), - "clash_mode_global" => Ok(HotkeyFunction::ClashModeGlobal), - "clash_mode_direct" => Ok(HotkeyFunction::ClashModeDirect), - "toggle_system_proxy" => Ok(HotkeyFunction::ToggleSystemProxy), - "toggle_tun_mode" => Ok(HotkeyFunction::ToggleTunMode), - "entry_lightweight_mode" => Ok(HotkeyFunction::EntryLightweightMode), - "quit" => Ok(HotkeyFunction::Quit), + "open_or_close_dashboard" => Ok(Self::OpenOrCloseDashboard), + "clash_mode_rule" => Ok(Self::ClashModeRule), + "clash_mode_global" => Ok(Self::ClashModeGlobal), + "clash_mode_direct" => Ok(Self::ClashModeDirect), + "toggle_system_proxy" => Ok(Self::ToggleSystemProxy), + "toggle_tun_mode" => Ok(Self::ToggleTunMode), + "entry_lightweight_mode" => Ok(Self::EntryLightweightMode), + "quit" => Ok(Self::Quit), #[cfg(target_os = "macos")] - "hide" => Ok(HotkeyFunction::Hide), + "hide" => Ok(Self::Hide), _ => bail!("invalid hotkey function: {}", s), } } @@ -75,8 +75,8 @@ pub enum SystemHotkey { impl fmt::Display for SystemHotkey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let s = match self { - SystemHotkey::CmdQ => "CMD+Q", - SystemHotkey::CmdW => "CMD+W", + Self::CmdQ => "CMD+Q", + Self::CmdW => "CMD+W", }; write!(f, "{s}") } @@ -84,86 +84,73 @@ impl fmt::Display for SystemHotkey { #[cfg(target_os = "macos")] impl SystemHotkey { - pub fn function(self) -> HotkeyFunction { + pub const fn function(self) -> HotkeyFunction { match self { - SystemHotkey::CmdQ => HotkeyFunction::Quit, - SystemHotkey::CmdW => HotkeyFunction::Hide, + Self::CmdQ => HotkeyFunction::Quit, + Self::CmdW => HotkeyFunction::Hide, } } } pub struct Hotkey { - current: Arc>>, + current: ArcSwap>, } impl Hotkey { fn new() -> Self { Self { - current: Arc::new(Mutex::new(Vec::new())), + current: ArcSwap::new(Arc::new(Vec::new())), } } /// Execute the function associated with a hotkey function enum - fn execute_function(function: HotkeyFunction, app_handle: &AppHandle) { - let app_handle = app_handle.clone(); + fn execute_function(function: HotkeyFunction) { match function { HotkeyFunction::OpenOrCloseDashboard => { AsyncHandler::spawn(async move || { crate::feat::open_or_close_dashboard().await; - notify_event(app_handle, NotificationEvent::DashboardToggled).await; + notify_event(NotificationEvent::DashboardToggled).await; }); } HotkeyFunction::ClashModeRule => { AsyncHandler::spawn(async move || { feat::change_clash_mode("rule".into()).await; - notify_event( - app_handle, - NotificationEvent::ClashModeChanged { mode: "Rule" }, - ) - .await; + notify_event(NotificationEvent::ClashModeChanged { mode: "Rule" }).await; }); } HotkeyFunction::ClashModeGlobal => { AsyncHandler::spawn(async move || { feat::change_clash_mode("global".into()).await; - notify_event( - app_handle, - NotificationEvent::ClashModeChanged { mode: "Global" }, - ) - .await; + notify_event(NotificationEvent::ClashModeChanged { mode: "Global" }).await; }); } HotkeyFunction::ClashModeDirect => { AsyncHandler::spawn(async move || { feat::change_clash_mode("direct".into()).await; - notify_event( - app_handle, - NotificationEvent::ClashModeChanged { mode: "Direct" }, - ) - .await; + notify_event(NotificationEvent::ClashModeChanged { mode: "Direct" }).await; }); } HotkeyFunction::ToggleSystemProxy => { AsyncHandler::spawn(async move || { feat::toggle_system_proxy().await; - notify_event(app_handle, NotificationEvent::SystemProxyToggled).await; + notify_event(NotificationEvent::SystemProxyToggled).await; }); } HotkeyFunction::ToggleTunMode => { AsyncHandler::spawn(async move || { feat::toggle_tun_mode(None).await; - notify_event(app_handle, NotificationEvent::TunModeToggled).await; + notify_event(NotificationEvent::TunModeToggled).await; }); } HotkeyFunction::EntryLightweightMode => { AsyncHandler::spawn(async move || { entry_lightweight_mode().await; - notify_event(app_handle, NotificationEvent::LightweightModeEntered).await; + notify_event(NotificationEvent::LightweightModeEntered).await; }); } HotkeyFunction::Quit => { AsyncHandler::spawn(async move || { - notify_event(app_handle, NotificationEvent::AppQuit).await; + notify_event(NotificationEvent::AppQuit).await; feat::quit().await; }); } @@ -171,7 +158,7 @@ impl Hotkey { HotkeyFunction::Hide => { AsyncHandler::spawn(async move || { feat::hide().await; - notify_event(app_handle, NotificationEvent::AppHidden).await; + notify_event(NotificationEvent::AppHidden).await; }); } } @@ -223,54 +210,42 @@ impl Hotkey { let is_quit = matches!(function, HotkeyFunction::Quit); - let _ = manager.on_shortcut(hotkey, move |app_handle, hotkey_event, event| { - let hotkey_event_owned = *hotkey_event; - let event_owned = event; - let function_owned = function; - let is_quit_owned = is_quit; - - let app_handle_cloned = app_handle.clone(); - - AsyncHandler::spawn(move || async move { - if event_owned.state == ShortcutState::Pressed { - logging!( - debug, - Type::Hotkey, - "Hotkey pressed: {:?}", - hotkey_event_owned - ); - - if hotkey_event_owned.key == Code::KeyQ && is_quit_owned { - if let Some(window) = app_handle_cloned.get_webview_window("main") - && window.is_focused().unwrap_or(false) - { - logging!(debug, Type::Hotkey, "Executing quit function"); - Self::execute_function(function_owned, &app_handle_cloned); - } - } else { + manager.on_shortcut(hotkey, move |_app_handle, hotkey_event, event| { + if event.state == ShortcutState::Pressed { + logging!(debug, Type::Hotkey, "Hotkey pressed: {:?}", hotkey_event); + let hotkey = hotkey_event.key; + if hotkey == Code::KeyQ && is_quit { + if let Some(window) = handle::Handle::get_window() + && window.is_focused().unwrap_or(false) + { + logging!(debug, Type::Hotkey, "Executing quit function"); + Self::execute_function(function); + } + } else { + AsyncHandler::spawn(move || async move { logging!(debug, Type::Hotkey, "Executing function directly"); let is_enable_global_hotkey = Config::verge() .await - .latest_ref() + .data_arc() .enable_global_hotkey .unwrap_or(true); if is_enable_global_hotkey { - Self::execute_function(function_owned, &app_handle_cloned); + Self::execute_function(function); } else { use crate::utils::window_manager::WindowManager; let is_visible = WindowManager::is_main_window_visible(); let is_focused = WindowManager::is_main_window_focused(); if is_focused && is_visible { - Self::execute_function(function_owned, &app_handle_cloned); + Self::execute_function(function); } } - } + }); } - }); - }); + } + })?; logging!( debug, @@ -287,9 +262,9 @@ impl Hotkey { singleton_with_logging!(Hotkey, INSTANCE, "Hotkey"); impl Hotkey { - pub async fn init(&self) -> Result<()> { + pub async fn init(&self, skip: bool) -> Result<()> { let verge = Config::verge().await; - let enable_global_hotkey = verge.latest_ref().enable_global_hotkey.unwrap_or(true); + let enable_global_hotkey = !skip && verge.data_arc().enable_global_hotkey.unwrap_or(true); logging!( debug, @@ -298,12 +273,8 @@ impl Hotkey { enable_global_hotkey ); - if !enable_global_hotkey { - return Ok(()); - } - // Extract hotkeys data before async operations - let hotkeys = verge.latest_ref().hotkeys.as_ref().cloned(); + let hotkeys = verge.data_arc().hotkeys.clone(); if let Some(hotkeys) = hotkeys { logging!( @@ -359,7 +330,7 @@ impl Hotkey { } } } - self.current.lock().clone_from(&hotkeys); + self.current.store(Arc::new(hotkeys)); } else { logging!(debug, Type::Hotkey, "No hotkeys configured"); } @@ -390,8 +361,8 @@ impl Hotkey { pub async fn update(&self, new_hotkeys: Vec) -> Result<()> { // Extract current hotkeys before async operations - let current_hotkeys = self.current.lock().clone(); - let old_map = Self::get_map_from_vec(¤t_hotkeys); + let current_hotkeys = &*self.current.load(); + let old_map = Self::get_map_from_vec(current_hotkeys); let new_map = Self::get_map_from_vec(&new_hotkeys); let (del, add) = Self::get_diff(old_map, new_map); @@ -401,11 +372,11 @@ impl Hotkey { }); for (key, func) in add.iter() { - logging_error!(Type::Hotkey, self.register(key, func).await); + self.register(key, func).await?; } // Update the current hotkeys after all async operations - *self.current.lock() = new_hotkeys; + self.current.store(Arc::new(new_hotkeys)); Ok(()) } diff --git a/clash-verge-rev/src-tauri/src/core/logger.rs b/clash-verge-rev/src-tauri/src/core/logger.rs index 7eb30479c0..8fd38c3d1f 100644 --- a/clash-verge-rev/src-tauri/src/core/logger.rs +++ b/clash-verge-rev/src-tauri/src/core/logger.rs @@ -1,37 +1,6 @@ -use std::{collections::VecDeque, sync::Arc}; +use std::sync::Arc; -use once_cell::sync::OnceCell; -use parking_lot::{RwLock, RwLockReadGuard}; +use clash_verge_logger::AsyncLogger; +use once_cell::sync::Lazy; -const LOGS_QUEUE_LEN: usize = 100; - -pub struct Logger { - logs: Arc>>, -} - -impl Logger { - pub fn global() -> &'static Logger { - static LOGGER: OnceCell = OnceCell::new(); - - LOGGER.get_or_init(|| Logger { - logs: Arc::new(RwLock::new(VecDeque::with_capacity(LOGS_QUEUE_LEN + 10))), - }) - } - - pub fn get_logs(&self) -> RwLockReadGuard<'_, VecDeque> { - self.logs.read() - } - - pub fn append_log(&self, text: String) { - let mut logs = self.logs.write(); - if logs.len() > LOGS_QUEUE_LEN { - logs.pop_front(); - } - logs.push_back(text); - } - - pub fn clear_logs(&self) { - let mut logs = self.logs.write(); - logs.clear(); - } -} +pub static CLASH_LOGGER: Lazy> = Lazy::new(|| Arc::new(AsyncLogger::new())); diff --git a/clash-verge-rev/src-tauri/src/core/manager/config.rs b/clash-verge-rev/src-tauri/src/core/manager/config.rs new file mode 100644 index 0000000000..64283a590e --- /dev/null +++ b/clash-verge-rev/src-tauri/src/core/manager/config.rs @@ -0,0 +1,154 @@ +use super::CoreManager; +use crate::{ + config::{Config, ConfigType, IRuntime}, + constants::timing, + core::{handle, validate::CoreConfigValidator}, + logging, + utils::{dirs, help, logging::Type}, +}; +use anyhow::{Result, anyhow}; +use smartstring::alias::String; +use std::{path::PathBuf, time::Instant}; +use tauri_plugin_mihomo::Error as MihomoError; +use tokio::time::sleep; + +impl CoreManager { + pub async fn use_default_config(&self, error_key: &str, error_msg: &str) -> Result<()> { + use crate::constants::files::RUNTIME_CONFIG; + + let runtime_path = dirs::app_home_dir()?.join(RUNTIME_CONFIG); + let clash_config = &Config::clash().await.latest_arc().0; + + Config::runtime().await.edit_draft(|d| { + *d = IRuntime { + config: Some(clash_config.to_owned()), + exists_keys: vec![], + chain_logs: Default::default(), + } + }); + + help::save_yaml(&runtime_path, &clash_config, Some("# Clash Verge Runtime")).await?; + handle::Handle::notice_message(error_key, error_msg); + Ok(()) + } + + pub async fn update_config(&self) -> Result<(bool, String)> { + if handle::Handle::global().is_exiting() { + return Ok((true, String::new())); + } + + if !self.should_update_config()? { + return Ok((true, String::new())); + } + + self.perform_config_update().await + } + + fn should_update_config(&self) -> Result { + let now = Instant::now(); + let last = self.get_last_update(); + + if let Some(last_time) = last + && now.duration_since(*last_time) < timing::CONFIG_UPDATE_DEBOUNCE + { + return Ok(false); + } + + self.set_last_update(now); + Ok(true) + } + + async fn perform_config_update(&self) -> Result<(bool, String)> { + Config::generate().await?; + + match CoreConfigValidator::global().validate_config().await { + Ok((true, _)) => { + let run_path = Config::generate_file(ConfigType::Run).await?; + self.apply_config(run_path).await?; + Ok((true, String::new())) + } + Ok((false, error_msg)) => { + Config::runtime().await.discard(); + Ok((false, error_msg)) + } + Err(e) => { + Config::runtime().await.discard(); + Err(e) + } + } + } + + pub async fn put_configs_force(&self, path: PathBuf) -> Result<()> { + self.apply_config(path).await + } + + pub(super) async fn apply_config(&self, path: PathBuf) -> Result<()> { + let path_str = dirs::path_to_str(&path)?; + + match self.reload_config(path_str).await { + Ok(_) => { + Config::runtime().await.apply(); + logging!(info, Type::Core, "Configuration applied"); + Ok(()) + } + Err(err) if Self::should_restart_on_error(&err) => { + self.retry_with_restart(path_str).await + } + Err(err) => { + Config::runtime().await.discard(); + Err(anyhow!("Failed to apply config: {}", err)) + } + } + } + + async fn retry_with_restart(&self, config_path: &str) -> Result<()> { + if handle::Handle::global().is_exiting() { + return Err(anyhow!("Application exiting")); + } + + logging!(warn, Type::Core, "Restarting core for config reload"); + self.restart_core().await?; + sleep(timing::CONFIG_RELOAD_DELAY).await; + + self.reload_config(config_path).await?; + Config::runtime().await.apply(); + logging!(info, Type::Core, "Configuration applied after restart"); + Ok(()) + } + + async fn reload_config(&self, path: &str) -> Result<(), MihomoError> { + handle::Handle::mihomo() + .await + .reload_config(true, path) + .await + } + + fn should_restart_on_error(err: &MihomoError) -> bool { + match err { + MihomoError::ConnectionFailed | MihomoError::ConnectionLost => true, + MihomoError::Io(io_err) => Self::is_connection_io_error(io_err.kind()), + MihomoError::Reqwest(req_err) => { + req_err.is_connect() + || req_err.is_timeout() + || Self::contains_error_pattern(&req_err.to_string()) + } + MihomoError::FailedResponse(msg) => Self::contains_error_pattern(msg), + _ => false, + } + } + + const fn is_connection_io_error(kind: std::io::ErrorKind) -> bool { + matches!( + kind, + std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::NotFound + ) + } + + fn contains_error_pattern(text: &str) -> bool { + use crate::constants::error_patterns::CONNECTION_ERRORS; + CONNECTION_ERRORS.iter().any(|p| text.contains(p)) + } +} diff --git a/clash-verge-rev/src-tauri/src/core/manager/lifecycle.rs b/clash-verge-rev/src-tauri/src/core/manager/lifecycle.rs new file mode 100644 index 0000000000..75574068c2 --- /dev/null +++ b/clash-verge-rev/src-tauri/src/core/manager/lifecycle.rs @@ -0,0 +1,126 @@ +use super::{CoreManager, RunningMode}; +use crate::config::{Config, ConfigType, IVerge}; +use crate::{ + core::{ + logger::CLASH_LOGGER, + service::{SERVICE_MANAGER, ServiceStatus}, + }, + logging, + utils::logging::Type, +}; +use anyhow::Result; +use smartstring::alias::String; + +impl CoreManager { + pub async fn start_core(&self) -> Result<()> { + self.prepare_startup().await?; + + match *self.get_running_mode() { + RunningMode::Service => self.start_core_by_service().await, + RunningMode::NotRunning | RunningMode::Sidecar => self.start_core_by_sidecar().await, + } + } + + pub async fn stop_core(&self) -> Result<()> { + CLASH_LOGGER.clear_logs().await; + + match *self.get_running_mode() { + RunningMode::Service => self.stop_core_by_service().await, + RunningMode::Sidecar => self.stop_core_by_sidecar(), + RunningMode::NotRunning => Ok(()), + } + } + + pub async fn restart_core(&self) -> Result<()> { + logging!(info, Type::Core, "Restarting core"); + self.stop_core().await?; + + if SERVICE_MANAGER.lock().await.init().await.is_ok() { + let _ = SERVICE_MANAGER.lock().await.refresh().await; + } + + self.start_core().await + } + + pub async fn change_core(&self, clash_core: &String) -> Result<(), String> { + if !IVerge::VALID_CLASH_CORES.contains(&clash_core.as_str()) { + return Err(format!("Invalid clash core: {}", clash_core).into()); + } + + Config::verge().await.edit_draft(|d| { + d.clash_core = Some(clash_core.to_owned()); + }); + Config::verge().await.apply(); + + let verge_data = Config::verge().await.latest_arc(); + verge_data.save_file().await.map_err(|e| e.to_string())?; + + let run_path = Config::generate_file(ConfigType::Run) + .await + .map_err(|e| e.to_string())?; + + self.apply_config(run_path) + .await + .map_err(|e| e.to_string().into()) + } + + async fn prepare_startup(&self) -> Result<()> { + #[cfg(target_os = "windows")] + self.wait_for_service_if_needed().await; + + let value = SERVICE_MANAGER.lock().await.current(); + let mode = match value { + ServiceStatus::Ready => RunningMode::Service, + _ => RunningMode::Sidecar, + }; + + self.set_running_mode(mode); + Ok(()) + } + + #[cfg(target_os = "windows")] + async fn wait_for_service_if_needed(&self) { + use crate::{config::Config, constants::timing}; + use backoff::{Error as BackoffError, ExponentialBackoff}; + + let needs_service = Config::verge() + .await + .latest_arc() + .enable_tun_mode + .unwrap_or(false); + + if !needs_service { + return; + } + + let backoff = ExponentialBackoff { + initial_interval: timing::SERVICE_WAIT_INTERVAL, + max_interval: timing::SERVICE_WAIT_INTERVAL, + max_elapsed_time: Some(timing::SERVICE_WAIT_MAX), + multiplier: 1.0, + randomization_factor: 0.0, + ..Default::default() + }; + + let operation = || async { + let mut manager = SERVICE_MANAGER.lock().await; + + if matches!(manager.current(), ServiceStatus::Ready) { + return Ok(()); + } + + manager.init().await.map_err(BackoffError::transient)?; + let _ = manager.refresh().await; + + if matches!(manager.current(), ServiceStatus::Ready) { + Ok(()) + } else { + Err(BackoffError::transient(anyhow::anyhow!( + "Service not ready" + ))) + } + }; + + let _ = backoff::future::retry(backoff, operation).await; + } +} diff --git a/clash-verge-rev/src-tauri/src/core/manager/mod.rs b/clash-verge-rev/src-tauri/src/core/manager/mod.rs new file mode 100644 index 0000000000..8f41af64fc --- /dev/null +++ b/clash-verge-rev/src-tauri/src/core/manager/mod.rs @@ -0,0 +1,96 @@ +mod config; +mod lifecycle; +mod state; + +use anyhow::Result; +use arc_swap::{ArcSwap, ArcSwapOption}; +use std::{fmt, sync::Arc, time::Instant}; + +use crate::process::CommandChildGuard; +use crate::singleton_lazy; + +#[derive(Debug, serde::Serialize, PartialEq, Eq)] +pub enum RunningMode { + Service, + Sidecar, + NotRunning, +} + +impl fmt::Display for RunningMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Service => write!(f, "Service"), + Self::Sidecar => write!(f, "Sidecar"), + Self::NotRunning => write!(f, "NotRunning"), + } + } +} + +#[derive(Debug)] +pub struct CoreManager { + state: ArcSwap, + last_update: ArcSwapOption, +} + +#[derive(Debug)] +struct State { + running_mode: ArcSwap, + child_sidecar: ArcSwapOption, +} + +impl Default for State { + fn default() -> Self { + Self { + running_mode: ArcSwap::new(Arc::new(RunningMode::NotRunning)), + child_sidecar: ArcSwapOption::new(None), + } + } +} + +impl Default for CoreManager { + fn default() -> Self { + Self { + state: ArcSwap::new(Arc::new(State::default())), + last_update: ArcSwapOption::new(None), + } + } +} + +impl CoreManager { + pub fn get_running_mode(&self) -> Arc { + Arc::clone(&self.state.load().running_mode.load()) + } + + pub fn take_child_sidecar(&self) -> Option { + self.state + .load() + .child_sidecar + .swap(None) + .and_then(|arc| Arc::try_unwrap(arc).ok()) + } + + pub fn get_last_update(&self) -> Option> { + self.last_update.load_full() + } + + pub fn set_running_mode(&self, mode: RunningMode) { + let state = self.state.load(); + state.running_mode.store(Arc::new(mode)); + } + + pub fn set_running_child_sidecar(&self, child: CommandChildGuard) { + let state = self.state.load(); + state.child_sidecar.store(Some(Arc::new(child))); + } + + pub fn set_last_update(&self, time: Instant) { + self.last_update.store(Some(Arc::new(time))); + } + + pub async fn init(&self) -> Result<()> { + self.start_core().await?; + Ok(()) + } +} + +singleton_lazy!(CoreManager, CORE_MANAGER, CoreManager::default); diff --git a/clash-verge-rev/src-tauri/src/core/manager/state.rs b/clash-verge-rev/src-tauri/src/core/manager/state.rs new file mode 100644 index 0000000000..520c1f2a1a --- /dev/null +++ b/clash-verge-rev/src-tauri/src/core/manager/state.rs @@ -0,0 +1,128 @@ +use super::{CoreManager, RunningMode}; +use crate::{ + AsyncHandler, + config::Config, + core::{handle, logger::CLASH_LOGGER, service}, + logging, + process::CommandChildGuard, + utils::{ + dirs, + init::sidecar_writer, + logging::{SharedWriter, Type, write_sidecar_log}, + }, +}; +use anyhow::Result; +use compact_str::CompactString; +use flexi_logger::DeferredNow; +use log::Level; +use scopeguard::defer; +use tauri_plugin_shell::ShellExt as _; + +impl CoreManager { + pub async fn get_clash_logs(&self) -> Result> { + match *self.get_running_mode() { + RunningMode::Service => service::get_clash_logs_by_service().await, + RunningMode::Sidecar => Ok(CLASH_LOGGER.get_logs().await), + RunningMode::NotRunning => Ok(Vec::new()), + } + } + + pub(super) async fn start_core_by_sidecar(&self) -> Result<()> { + logging!(info, Type::Core, "Starting core in sidecar mode"); + + let config_file = Config::generate_file(crate::config::ConfigType::Run).await?; + let app_handle = handle::Handle::app_handle(); + let clash_core = Config::verge().await.latest_arc().get_valid_clash_core(); + let config_dir = dirs::app_home_dir()?; + + let (mut rx, child) = app_handle + .shell() + .sidecar(clash_core.as_str())? + .args([ + "-d", + dirs::path_to_str(&config_dir)?, + "-f", + dirs::path_to_str(&config_file)?, + ]) + .spawn()?; + + let pid = child.pid(); + logging!(trace, Type::Core, "Sidecar started with PID: {}", pid); + + self.set_running_child_sidecar(CommandChildGuard::new(child)); + self.set_running_mode(RunningMode::Sidecar); + + let shared_writer: SharedWriter = + std::sync::Arc::new(tokio::sync::Mutex::new(sidecar_writer().await?)); + + AsyncHandler::spawn(|| async move { + while let Some(event) = rx.recv().await { + match event { + tauri_plugin_shell::process::CommandEvent::Stdout(line) + | tauri_plugin_shell::process::CommandEvent::Stderr(line) => { + let mut now = DeferredNow::default(); + let message = CompactString::from(String::from_utf8_lossy(&line).as_ref()); + write_sidecar_log( + shared_writer.lock().await, + &mut now, + Level::Error, + &message, + ); + CLASH_LOGGER.append_log(message).await; + } + tauri_plugin_shell::process::CommandEvent::Terminated(term) => { + let mut now = DeferredNow::default(); + let message = if let Some(code) = term.code { + CompactString::from(format!("Process terminated with code: {}", code)) + } else if let Some(signal) = term.signal { + CompactString::from(format!("Process terminated by signal: {}", signal)) + } else { + CompactString::from("Process terminated") + }; + write_sidecar_log( + shared_writer.lock().await, + &mut now, + Level::Info, + &message, + ); + CLASH_LOGGER.clear_logs().await; + break; + } + _ => {} + } + } + }); + + Ok(()) + } + + pub(super) fn stop_core_by_sidecar(&self) -> Result<()> { + logging!(info, Type::Core, "Stopping sidecar"); + defer! { + self.set_running_mode(RunningMode::NotRunning); + } + if let Some(child) = self.take_child_sidecar() { + let pid = child.pid(); + drop(child); + logging!(trace, Type::Core, "Sidecar stopped (PID: {:?})", pid); + } + Ok(()) + } + + pub(super) async fn start_core_by_service(&self) -> Result<()> { + logging!(info, Type::Core, "Starting core in service mode"); + let config_file = Config::generate_file(crate::config::ConfigType::Run).await?; + service::run_core_by_service(&config_file).await?; + self.set_running_mode(RunningMode::Service); + Ok(()) + } + + pub(super) async fn stop_core_by_service(&self) -> Result<()> { + logging!(info, Type::Core, "Stopping service"); + defer! { + self.set_running_mode(RunningMode::NotRunning); + } + service::stop_core_by_service().await?; + Ok(()) + } +} diff --git a/clash-verge-rev/src-tauri/src/core/mod.rs b/clash-verge-rev/src-tauri/src/core/mod.rs index 2a01fe23a4..6bdb2418b0 100644 --- a/clash-verge-rev/src-tauri/src/core/mod.rs +++ b/clash-verge-rev/src-tauri/src/core/mod.rs @@ -1,15 +1,16 @@ pub mod async_proxy_query; pub mod backup; -#[allow(clippy::module_inception)] -mod core; pub mod event_driven_proxy; pub mod handle; pub mod hotkey; pub mod logger; +pub mod manager; +mod notification; pub mod service; pub mod sysopt; pub mod timer; pub mod tray; +pub mod validate; pub mod win_uwp; -pub use self::{core::*, event_driven_proxy::EventDrivenProxyManager, timer::Timer}; +pub use self::{event_driven_proxy::EventDrivenProxyManager, manager::CoreManager, timer::Timer}; diff --git a/clash-verge-rev/src-tauri/src/core/notification.rs b/clash-verge-rev/src-tauri/src/core/notification.rs new file mode 100644 index 0000000000..fba5ecaa1f --- /dev/null +++ b/clash-verge-rev/src-tauri/src/core/notification.rs @@ -0,0 +1,221 @@ +use super::handle::Handle; +use crate::{ + constants::{retry, timing}, + logging, + utils::logging::Type, +}; +use parking_lot::RwLock; +use smartstring::alias::String; +use std::{ + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc, + }, + thread, + time::{Duration, Instant}, +}; +use tauri::{Emitter as _, WebviewWindow}; + +#[derive(Debug, Clone)] +pub enum FrontendEvent { + RefreshClash, + RefreshVerge, + NoticeMessage { status: String, message: String }, + ProfileChanged { current_profile_id: String }, + TimerUpdated { profile_index: String }, + ProfileUpdateStarted { uid: String }, + ProfileUpdateCompleted { uid: String }, +} + +#[derive(Debug, Default)] +struct EventStats { + total_sent: AtomicU64, + total_errors: AtomicU64, + last_error_time: RwLock>, +} + +#[derive(Debug, Clone)] +pub struct ErrorMessage { + pub status: String, + pub message: String, +} + +#[derive(Debug)] +pub struct NotificationSystem { + sender: Option>, + #[allow(clippy::type_complexity)] + worker_handle: Option>, + pub(super) is_running: bool, + stats: EventStats, + emergency_mode: AtomicBool, +} + +impl Default for NotificationSystem { + fn default() -> Self { + Self::new() + } +} + +impl NotificationSystem { + pub fn new() -> Self { + Self { + sender: None, + worker_handle: None, + is_running: false, + stats: EventStats::default(), + emergency_mode: AtomicBool::new(false), + } + } + + pub fn start(&mut self) { + if self.is_running { + return; + } + + let (tx, rx) = mpsc::channel(); + self.sender = Some(tx); + self.is_running = true; + + let result = thread::Builder::new() + .name("frontend-notifier".into()) + .spawn(move || Self::worker_loop(rx)); + + match result { + Ok(handle) => self.worker_handle = Some(handle), + Err(e) => logging!( + error, + Type::System, + "Failed to start notification worker: {}", + e + ), + } + } + + fn worker_loop(rx: mpsc::Receiver) { + loop { + let handle = Handle::global(); + if handle.is_exiting() { + break; + } + match rx.recv_timeout(Duration::from_millis(1_000)) { + Ok(event) => Self::process_event(handle, event), + Err(mpsc::RecvTimeoutError::Timeout) => (), + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + } + + // Clippy 似乎对 parking lot 的 RwLock 有误报,这里禁用相关警告 + #[allow(clippy::significant_drop_tightening)] + fn process_event(handle: &super::handle::Handle, event: FrontendEvent) { + let binding = handle.notification_system.read(); + let system = match binding.as_ref() { + Some(s) => s, + None => return, + }; + + if system.should_skip_event(&event) { + return; + } + + if let Some(window) = super::handle::Handle::get_window() { + system.emit_to_window(&window, event); + thread::sleep(timing::EVENT_EMIT_DELAY); + } + } + + fn should_skip_event(&self, event: &FrontendEvent) -> bool { + let is_emergency = self.emergency_mode.load(Ordering::Acquire); + matches!( + (is_emergency, event), + (true, FrontendEvent::NoticeMessage { status, .. }) if status == "info" + ) + } + + fn emit_to_window(&self, window: &WebviewWindow, event: FrontendEvent) { + let (event_name, payload) = self.serialize_event(event); + + let Ok(payload) = payload else { + self.stats.total_errors.fetch_add(1, Ordering::Relaxed); + return; + }; + + match window.emit(event_name, payload) { + Ok(_) => { + self.stats.total_sent.fetch_add(1, Ordering::Relaxed); + } + Err(e) => { + logging!(warn, Type::Frontend, "Event emit failed: {}", e); + self.handle_emit_error(); + } + } + } + + fn serialize_event( + &self, + event: FrontendEvent, + ) -> (&'static str, Result) { + use serde_json::json; + + match event { + FrontendEvent::RefreshClash => ("verge://refresh-clash-config", Ok(json!("yes"))), + FrontendEvent::RefreshVerge => ("verge://refresh-verge-config", Ok(json!("yes"))), + FrontendEvent::NoticeMessage { status, message } => ( + "verge://notice-message", + serde_json::to_value((status, message)), + ), + FrontendEvent::ProfileChanged { current_profile_id } => { + ("profile-changed", Ok(json!(current_profile_id))) + } + FrontendEvent::TimerUpdated { profile_index } => { + ("verge://timer-updated", Ok(json!(profile_index))) + } + FrontendEvent::ProfileUpdateStarted { uid } => { + ("profile-update-started", Ok(json!({ "uid": uid }))) + } + FrontendEvent::ProfileUpdateCompleted { uid } => { + ("profile-update-completed", Ok(json!({ "uid": uid }))) + } + } + } + + fn handle_emit_error(&self) { + self.stats.total_errors.fetch_add(1, Ordering::Relaxed); + *self.stats.last_error_time.write() = Some(Instant::now()); + + let errors = self.stats.total_errors.load(Ordering::Relaxed); + if errors > retry::EVENT_EMIT_THRESHOLD && !self.emergency_mode.load(Ordering::Acquire) { + logging!( + warn, + Type::Frontend, + "Entering emergency mode after {} errors", + errors + ); + self.emergency_mode.store(true, Ordering::Release); + } + } + + pub fn send_event(&self, event: FrontendEvent) -> bool { + if self.should_skip_event(&event) { + return false; + } + + if let Some(sender) = &self.sender { + sender.send(event).is_ok() + } else { + false + } + } + + pub fn shutdown(&mut self) { + self.is_running = false; + + if let Some(sender) = self.sender.take() { + drop(sender); + } + + if let Some(handle) = self.worker_handle.take() { + let _ = handle.join(); + } + } +} diff --git a/clash-verge-rev/src-tauri/src/core/service.rs b/clash-verge-rev/src-tauri/src/core/service.rs index 0a752cedc8..892294d95d 100644 --- a/clash-verge-rev/src-tauri/src/core/service.rs +++ b/clash-verge-rev/src-tauri/src/core/service.rs @@ -1,10 +1,12 @@ use crate::{ config::Config, + core::tray, logging, logging_error, utils::{dirs, init::service_writer_config, logging::Type}, }; -use anyhow::{Context, Result, bail}; +use anyhow::{Context as _, Result, bail}; use clash_verge_service_ipc::CoreConfig; +use compact_str::CompactString; use once_cell::sync::Lazy; use std::{ env::current_exe, @@ -35,7 +37,7 @@ async fn uninstall_service() -> Result<()> { use deelevate::{PrivilegeLevel, Token}; use runas::Command as RunasCommand; - use std::os::windows::process::CommandExt; + use std::os::windows::process::CommandExt as _; let binary_path = dirs::service_path()?; let uninstall_path = binary_path.with_file_name("clash-verge-service-uninstall.exe"); @@ -70,7 +72,7 @@ async fn install_service() -> Result<()> { use deelevate::{PrivilegeLevel, Token}; use runas::Command as RunasCommand; - use std::os::windows::process::CommandExt; + use std::os::windows::process::CommandExt as _; let binary_path = dirs::service_path()?; let install_path = binary_path.with_file_name("clash-verge-service-install.exe"); @@ -120,7 +122,6 @@ async fn reinstall_service() -> Result<()> { #[cfg(target_os = "linux")] async fn uninstall_service() -> Result<()> { logging!(info, Type::Service, "uninstall service"); - use users::get_effective_uid; let uninstall_path = tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-uninstall"); @@ -132,13 +133,14 @@ async fn uninstall_service() -> Result<()> { let uninstall_shell: String = uninstall_path.to_string_lossy().replace(" ", "\\ "); let elevator = crate::utils::help::linux_elevator(); - let status = match get_effective_uid() { - 0 => StdCommand::new(uninstall_shell).status()?, - _ => StdCommand::new(elevator.clone()) + let status = if linux_running_as_root() { + StdCommand::new(&uninstall_path).status()? + } else { + StdCommand::new(elevator) .arg("sh") .arg("-c") .arg(uninstall_shell) - .status()?, + .status()? }; logging!( info, @@ -161,7 +163,6 @@ async fn uninstall_service() -> Result<()> { #[allow(clippy::unused_async)] async fn install_service() -> Result<()> { logging!(info, Type::Service, "install service"); - use users::get_effective_uid; let install_path = tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install"); @@ -173,13 +174,14 @@ async fn install_service() -> Result<()> { let install_shell: String = install_path.to_string_lossy().replace(" ", "\\ "); let elevator = crate::utils::help::linux_elevator(); - let status = match get_effective_uid() { - 0 => StdCommand::new(install_shell).status()?, - _ => StdCommand::new(elevator.clone()) + let status = if linux_running_as_root() { + StdCommand::new(&install_path).status()? + } else { + StdCommand::new(elevator) .arg("sh") .arg("-c") .arg(install_shell) - .status()?, + .status()? }; logging!( info, @@ -216,10 +218,15 @@ async fn reinstall_service() -> Result<()> { } } +#[cfg(target_os = "linux")] +fn linux_running_as_root() -> bool { + const ROOT_UID: u32 = 0; + + unsafe { libc::geteuid() == ROOT_UID } +} + #[cfg(target_os = "macos")] async fn uninstall_service() -> Result<()> { - use crate::utils::i18n::t; - logging!(info, Type::Service, "uninstall service"); let binary_path = dirs::service_path()?; @@ -231,7 +238,9 @@ async fn uninstall_service() -> Result<()> { let uninstall_shell: String = uninstall_path.to_string_lossy().into_owned(); - let prompt = t("Service Administrator Prompt").await; + crate::utils::i18n::sync_locale().await; + + let prompt = rust_i18n::t!("service.adminPrompt").to_string(); let command = format!( r#"do shell script "sudo '{uninstall_shell}'" with administrator privileges with prompt "{prompt}""# ); @@ -254,8 +263,6 @@ async fn uninstall_service() -> Result<()> { #[cfg(target_os = "macos")] async fn install_service() -> Result<()> { - use crate::utils::i18n::t; - logging!(info, Type::Service, "install service"); let binary_path = dirs::service_path()?; @@ -267,7 +274,9 @@ async fn install_service() -> Result<()> { let install_shell: String = install_path.to_string_lossy().into_owned(); - let prompt = t("Service Administrator Prompt").await; + crate::utils::i18n::sync_locale().await; + + let prompt = rust_i18n::t!("service.adminPrompt").to_string(); let command = format!( r#"do shell script "sudo '{install_shell}'" with administrator privileges with prompt "{prompt}""# ); @@ -328,7 +337,7 @@ async fn check_service_version() -> Result { return Err(anyhow::anyhow!(err_msg)); } - let version = response.data.unwrap_or("unknown".to_string()); + let version = response.data.unwrap_or_else(|| "unknown".into()); Ok(version) }; @@ -351,7 +360,7 @@ pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result logging!(info, Type::Service, "尝试使用现有服务启动核心"); let verge_config = Config::verge().await; - let clash_core = verge_config.latest_ref().get_valid_clash_core(); + let clash_core = verge_config.latest_arc().get_valid_clash_core(); drop(verge_config); let bin_ext = if cfg!(windows) { ".exe" } else { "" }; @@ -359,9 +368,9 @@ pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result let payload = clash_verge_service_ipc::ClashConfig { core_config: CoreConfig { - config_path: dirs::path_to_str(config_file)?.to_string(), - core_path: dirs::path_to_str(&bin_path)?.to_string(), - config_dir: dirs::path_to_str(&dirs::app_home_dir()?)?.to_string(), + config_path: dirs::path_to_str(config_file)?.into(), + core_path: dirs::path_to_str(&bin_path)?.into(), + config_dir: dirs::path_to_str(&dirs::app_home_dir()?)?.into(), }, log_config: service_writer_config().await?, }; @@ -392,6 +401,28 @@ pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> { start_with_existing_service(config_file).await } +pub(super) async fn get_clash_logs_by_service() -> Result> { + logging!(info, Type::Service, "正在获取服务模式下的 Clash 日志"); + + let response = clash_verge_service_ipc::get_clash_logs() + .await + .context("无法连接到Clash Verge Service")?; + + if response.code > 0 { + let err_msg = response.message; + logging!( + error, + Type::Service, + "获取服务模式下的 Clash 日志失败: {}", + err_msg + ); + bail!(err_msg); + } + + logging!(info, Type::Service, "成功获取服务模式下的 Clash 日志"); + Ok(response.data.unwrap_or_default()) +} + /// 通过服务停止core pub(super) async fn stop_core_by_service() -> Result<()> { logging!(info, Type::Service, "通过服务停止核心 (IPC)"); @@ -425,7 +456,7 @@ impl ServiceManager { Self(ServiceStatus::Unavailable("Need Checks".into())) } - pub fn config() -> Option { + pub const fn config() -> Option { Some(clash_verge_service_ipc::IpcConfig { default_timeout: Duration::from_millis(30), retry_delay: Duration::from_millis(250), @@ -508,6 +539,7 @@ impl ServiceManager { return Err(anyhow::anyhow!("服务不可用: {}", reason)); } } + let _ = tray::Tray::global().update_menu().await; Ok(()) } } diff --git a/clash-verge-rev/src-tauri/src/core/sysopt.rs b/clash-verge-rev/src-tauri/src/core/sysopt.rs index a06b348be1..bd5383d973 100644 --- a/clash-verge-rev/src-tauri/src/core/sysopt.rs +++ b/clash-verge-rev/src-tauri/src/core/sysopt.rs @@ -7,15 +7,17 @@ use crate::{ utils::logging::Type, }; use anyhow::Result; -use std::sync::Arc; +use scopeguard::defer; +use smartstring::alias::String; +use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(not(target_os = "windows"))] use sysproxy::{Autoproxy, Sysproxy}; -use tauri::async_runtime::Mutex as TokioMutex; -use tauri_plugin_autostart::ManagerExt; +use tauri_plugin_autostart::ManagerExt as _; pub struct Sysopt { - update_sysproxy: Arc>, - reset_sysproxy: Arc>, + initialed: AtomicBool, + update_sysproxy: AtomicBool, + reset_sysproxy: AtomicBool, } #[cfg(target_os = "windows")] @@ -29,23 +31,23 @@ static DEFAULT_BYPASS: &str = "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12 async fn get_bypass() -> String { let use_default = Config::verge() .await - .latest_ref() + .latest_arc() .use_default_bypass .unwrap_or(true); let res = { let verge = Config::verge().await; - let verge = verge.latest_ref(); + let verge = verge.latest_arc(); verge.system_proxy_bypass.clone() }; let custom_bypass = match res { Some(bypass) => bypass, - None => "".to_string(), + None => "".into(), }; if custom_bypass.is_empty() { - DEFAULT_BYPASS.to_string() + DEFAULT_BYPASS.into() } else if use_default { - format!("{DEFAULT_BYPASS},{custom_bypass}") + format!("{DEFAULT_BYPASS},{custom_bypass}").into() } else { custom_bypass } @@ -53,11 +55,11 @@ async fn get_bypass() -> String { // Uses tokio Command with CREATE_NO_WINDOW flag to avoid DLL initialization issues during shutdown #[cfg(target_os = "windows")] -async fn execute_sysproxy_command(args: Vec) -> Result<()> { +async fn execute_sysproxy_command(args: Vec) -> Result<()> { use crate::utils::dirs; use anyhow::bail; #[allow(unused_imports)] // Required for .creation_flags() method - use std::os::windows::process::CommandExt; + use std::os::windows::process::CommandExt as _; use tokio::process::Command; let binary_path = dirs::service_path()?; @@ -68,7 +70,7 @@ async fn execute_sysproxy_command(args: Vec) -> Result<()> { } let output = Command::new(sysproxy_exe) - .args(&args) + .args(args) .creation_flags(0x08000000) // CREATE_NO_WINDOW - 隐藏窗口 .output() .await?; @@ -82,9 +84,10 @@ async fn execute_sysproxy_command(args: Vec) -> Result<()> { impl Default for Sysopt { fn default() -> Self { - Sysopt { - update_sysproxy: Arc::new(TokioMutex::new(false)), - reset_sysproxy: Arc::new(TokioMutex::new(false)), + Self { + initialed: AtomicBool::new(false), + update_sysproxy: AtomicBool::new(false), + reset_sysproxy: AtomicBool::new(false), } } } @@ -93,31 +96,45 @@ impl Default for Sysopt { singleton_lazy!(Sysopt, SYSOPT, Sysopt::default); impl Sysopt { + pub fn is_initialed(&self) -> bool { + self.initialed.load(Ordering::SeqCst) + } + pub fn init_guard_sysproxy(&self) -> Result<()> { // 使用事件驱动代理管理器 let proxy_manager = EventDrivenProxyManager::global(); proxy_manager.notify_app_started(); - log::info!(target: "app", "已启用事件驱动代理守卫"); + logging!(info, Type::Core, "已启用事件驱动代理守卫"); Ok(()) } /// init the sysproxy pub async fn update_sysproxy(&self) -> Result<()> { - let _lock = self.update_sysproxy.lock().await; + self.initialed.store(true, Ordering::SeqCst); + if self + .update_sysproxy + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return Ok(()); + } + defer! { + self.update_sysproxy.store(false, Ordering::SeqCst); + } let port = { - let verge_port = Config::verge().await.latest_ref().verge_mixed_port; + let verge_port = Config::verge().await.latest_arc().verge_mixed_port; match verge_port { Some(port) => port, - None => Config::clash().await.latest_ref().get_mixed_port(), + None => Config::clash().await.latest_arc().get_mixed_port(), } }; let pac_port = IVerge::get_singleton_port(); let (sys_enable, pac_enable, proxy_host) = { let verge = Config::verge().await; - let verge = verge.latest_ref(); + let verge = verge.latest_arc(); ( verge.enable_system_proxy.unwrap_or(false), verge.proxy_auto_config.unwrap_or(false), @@ -132,9 +149,9 @@ impl Sysopt { { let mut sys = Sysproxy { enable: false, - host: proxy_host.clone(), + host: proxy_host.clone().into(), port, - bypass: get_bypass().await, + bypass: get_bypass().await.into(), }; let mut auto = Autoproxy { enable: false, @@ -178,38 +195,59 @@ impl Sysopt { return result; } - let args = if pac_enable { + let args: Vec = if pac_enable { let address = format!("http://{proxy_host}:{pac_port}/commands/pac"); - vec!["pac".to_string(), address] + vec!["pac".into(), address] } else { let address = format!("{proxy_host}:{port}"); let bypass = get_bypass().await; - vec!["global".to_string(), address, bypass] + vec!["global".into(), address, bypass.into()] }; execute_sysproxy_command(args).await?; } let proxy_manager = EventDrivenProxyManager::global(); proxy_manager.notify_config_changed(); - Ok(()) } /// reset the sysproxy + #[allow(clippy::unused_async)] pub async fn reset_sysproxy(&self) -> Result<()> { - let _lock = self.reset_sysproxy.lock().await; + if self + .reset_sysproxy + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return Ok(()); + } + defer! { + self.reset_sysproxy.store(false, Ordering::SeqCst); + } + //直接关闭所有代理 #[cfg(not(target_os = "windows"))] { - let mut sysproxy: Sysproxy = Sysproxy::get_system_proxy()?; + let mut sysproxy: Sysproxy = match Sysproxy::get_system_proxy() { + Ok(sp) => sp, + Err(e) => { + logging!( + warn, + Type::Core, + "Warning: 重置代理时获取系统代理配置失败: {e}, 使用默认配置" + ); + Sysproxy::default() + } + }; let mut autoproxy = match Autoproxy::get_auto_proxy() { Ok(ap) => ap, Err(e) => { - log::warn!(target: "app", "重置代理时获取自动代理配置失败: {e}, 使用默认配置"); - Autoproxy { - enable: false, - url: "".to_string(), - } + logging!( + warn, + Type::Core, + "Warning: 重置代理时获取自动代理配置失败: {e}, 使用默认配置" + ); + Autoproxy::default() } }; sysproxy.enable = false; @@ -220,7 +258,7 @@ impl Sysopt { #[cfg(target_os = "windows")] { - execute_sysproxy_command(vec!["set".to_string(), "1".to_string()]).await?; + execute_sysproxy_command(vec!["set".into(), "1".into()]).await?; } Ok(()) @@ -228,7 +266,7 @@ impl Sysopt { /// update the startup pub async fn update_launch(&self) -> Result<()> { - let enable_auto_launch = { Config::verge().await.latest_ref().enable_auto_launch }; + let enable_auto_launch = { Config::verge().await.latest_arc().enable_auto_launch }; let is_enable = enable_auto_launch.unwrap_or(false); logging!( info, @@ -241,15 +279,15 @@ impl Sysopt { #[cfg(target_os = "windows")] { if is_enable { - if let Err(e) = startup_shortcut::create_shortcut() { - log::error!(target: "app", "创建启动快捷方式失败: {e}"); + if let Err(e) = startup_shortcut::create_shortcut().await { + logging!(error, Type::Setup, "创建启动快捷方式失败: {e}"); // 如果快捷方式创建失败,回退到原来的方法 self.try_original_autostart_method(is_enable); } else { return Ok(()); } - } else if let Err(e) = startup_shortcut::remove_shortcut() { - log::error!(target: "app", "删除启动快捷方式失败: {e}"); + } else if let Err(e) = startup_shortcut::remove_shortcut().await { + logging!(error, Type::Setup, "删除启动快捷方式失败: {e}"); self.try_original_autostart_method(is_enable); } else { return Ok(()); @@ -284,11 +322,11 @@ impl Sysopt { { match startup_shortcut::is_shortcut_enabled() { Ok(enabled) => { - log::info!(target: "app", "快捷方式自启动状态: {enabled}"); + logging!(info, Type::System, "快捷方式自启动状态: {enabled}"); return Ok(enabled); } Err(e) => { - log::error!(target: "app", "检查快捷方式失败,尝试原来的方法: {e}"); + logging!(error, Type::System, "检查快捷方式失败,尝试原来的方法: {e}"); } } } @@ -299,11 +337,11 @@ impl Sysopt { match autostart_manager.is_enabled() { Ok(status) => { - log::info!(target: "app", "Auto launch status: {status}"); + logging!(info, Type::System, "Auto launch status: {status}"); Ok(status) } Err(e) => { - log::error!(target: "app", "Failed to get auto launch status: {e}"); + logging!(error, Type::System, "Failed to get auto launch status: {e}"); Err(anyhow::anyhow!("Failed to get auto launch status: {}", e)) } } diff --git a/clash-verge-rev/src-tauri/src/core/timer.rs b/clash-verge-rev/src-tauri/src/core/timer.rs index 5552f8432a..20c0bb6c05 100644 --- a/clash-verge-rev/src-tauri/src/core/timer.rs +++ b/clash-verge-rev/src-tauri/src/core/timer.rs @@ -1,7 +1,11 @@ -use crate::{config::Config, feat, logging, logging_error, singleton, utils::logging::Type}; -use anyhow::{Context, Result}; +use crate::{ + config::Config, core::sysopt::Sysopt, feat, logging, logging_error, singleton, + utils::logging::Type, +}; +use anyhow::{Context as _, Result}; use delay_timer::prelude::{DelayTimer, DelayTimerBuilder, TaskBuilder}; use parking_lot::RwLock; +use smartstring::alias::String; use std::{ collections::HashMap, pin::Pin, @@ -9,7 +13,9 @@ use std::{ Arc, atomic::{AtomicBool, AtomicU64, Ordering}, }, + time::Duration, }; +use tokio::time::{sleep, timeout}; type TaskID = u64; @@ -40,7 +46,7 @@ singleton!(Timer, TIMER_INSTANCE); impl Timer { fn new() -> Self { - Timer { + Self { delay_timer: Arc::new(RwLock::new(DelayTimerBuilder::default().build())), timer_map: Arc::new(RwLock::new(HashMap::new())), timer_count: AtomicU64::new(1), @@ -94,10 +100,16 @@ impl Timer { // Collect profiles that need immediate update let profiles_to_update = - if let Some(items) = Config::profiles().await.latest_ref().get_items() { + if let Some(items) = Config::profiles().await.latest_arc().get_items() { items .iter() .filter_map(|item| { + let allow_auto_update = + item.option.as_ref()?.allow_auto_update.unwrap_or_default(); + if !allow_auto_update { + return None; + } + let interval = item.option.as_ref()?.update_interval? as i64; let updated = item.updated? as i64; let uid = item.uid.as_ref()?; @@ -142,19 +154,17 @@ impl Timer { /// 每 3 秒更新系统托盘菜单,总共执行 3 次 pub fn add_update_tray_menu_task(&self) -> Result<()> { let tid = self.timer_count.fetch_add(1, Ordering::SeqCst); - let delay_timer = self.delay_timer.write(); let task = TaskBuilder::default() .set_task_id(tid) .set_maximum_parallel_runnable_num(1) .set_frequency_count_down_by_seconds(3, 3) .spawn_async_routine(|| async move { - logging!(info, Type::Timer, "Updating tray menu"); - crate::core::tray::Tray::global() - .update_tray_display() - .await + logging!(debug, Type::Timer, "Updating tray menu"); + crate::core::tray::Tray::global().update_menu().await }) .context("failed to create update tray menu timer task")?; - delay_timer + self.delay_timer + .write() .add_task(task) .context("failed to add update tray menu timer task")?; Ok(()) @@ -183,14 +193,12 @@ impl Timer { // Perform sync operations while holding locks { - let mut timer_map = self.timer_map.write(); - let delay_timer = self.delay_timer.write(); - for (uid, diff) in diff_map { match diff { DiffFlag::Del(tid) => { - timer_map.remove(&uid); - if let Err(e) = delay_timer.remove_task(tid) { + self.timer_map.write().remove(&uid); + let value = self.delay_timer.write().remove_task(tid); + if let Err(e) = value { logging!( warn, Type::Timer, @@ -210,12 +218,13 @@ impl Timer { last_run: chrono::Local::now().timestamp(), }; - timer_map.insert(uid.clone(), task); + self.timer_map.write().insert(uid.clone(), task); operations_to_add.push((uid, tid, interval)); } DiffFlag::Mod(tid, interval) => { // Remove old task first - if let Err(e) = delay_timer.remove_task(tid) { + let value = self.delay_timer.write().remove_task(tid); + if let Err(e) = value { logging!( warn, Type::Timer, @@ -233,7 +242,7 @@ impl Timer { last_run: chrono::Local::now().timestamp(), }; - timer_map.insert(uid.clone(), task); + self.timer_map.write().insert(uid.clone(), task); operations_to_add.push((uid, tid, interval)); } } @@ -243,8 +252,8 @@ impl Timer { // Now perform async operations without holding locks for (uid, tid, interval) in operations_to_add { // Re-acquire locks for individual operations - let mut delay_timer = self.delay_timer.write(); - if let Err(e) = self.add_task(&mut delay_timer, uid.clone(), tid, interval) { + let delay_timer = self.delay_timer.write(); + if let Err(e) = self.add_task(&delay_timer, uid.clone(), tid, interval) { logging_error!(Type::Timer, "Failed to add task for uid {}: {}", uid, e); // Rollback on failure - remove from timer_map @@ -261,10 +270,12 @@ impl Timer { async fn gen_map(&self) -> HashMap { let mut new_map = HashMap::new(); - if let Some(items) = Config::profiles().await.latest_ref().get_items() { + if let Some(items) = Config::profiles().await.latest_arc().get_items() { for item in items.iter() { if let Some(option) = item.option.as_ref() + && let Some(allow_auto_update) = option.allow_auto_update && let (Some(interval), Some(uid)) = (option.update_interval, &item.uid) + && allow_auto_update && interval > 0 { logging!( @@ -359,7 +370,7 @@ impl Timer { /// Add a timer task with better error handling fn add_task( &self, - delay_timer: &mut DelayTimer, + delay_timer: &DelayTimer, uid: String, tid: TaskID, minutes: u64, @@ -381,7 +392,8 @@ impl Timer { .spawn_async_routine(move || { let uid = uid.clone(); Box::pin(async move { - Self::async_task(uid).await; + Self::wait_until_sysopt(Duration::from_millis(1000)).await; + Self::async_task(&uid).await; }) as Pin + Send>> }) .context("failed to create timer task")?; @@ -410,13 +422,15 @@ impl Timer { }; // Get the profile updated timestamp - now safe to await - let config_profiles = Config::profiles().await; - let profiles = config_profiles.data_ref().clone(); - let items = match profiles.get_items() { - Some(i) => i, - None => { - logging!(warn, Type::Timer, "获取配置列表失败"); - return None; + let items = { + let profiles = Config::profiles().await; + let profiles_guard = profiles.latest_arc(); + match profiles_guard.get_items() { + Some(i) => i.clone(), + None => { + logging!(warn, Type::Timer, "获取配置列表失败"); + return None; + } } }; @@ -457,22 +471,22 @@ impl Timer { fn emit_update_event(_uid: &str, _is_start: bool) { { if _is_start { - super::handle::Handle::notify_profile_update_started(_uid.to_string()); + super::handle::Handle::notify_profile_update_started(_uid.into()); } else { - super::handle::Handle::notify_profile_update_completed(_uid.to_string()); + super::handle::Handle::notify_profile_update_completed(_uid.into()); } } } /// Async task with better error handling and logging - async fn async_task(uid: String) { + async fn async_task(uid: &String) { let task_start = std::time::Instant::now(); logging!(info, Type::Timer, "Running timer task for profile: {}", uid); match tokio::time::timeout(std::time::Duration::from_secs(40), async { - Self::emit_update_event(&uid, true); + Self::emit_update_event(uid, true); - let is_current = Config::profiles().await.latest_ref().current.as_ref() == Some(&uid); + let is_current = Config::profiles().await.latest_arc().current.as_ref() == Some(uid); logging!( info, Type::Timer, @@ -481,7 +495,7 @@ impl Timer { is_current ); - feat::update_profile(uid.clone(), None, Some(is_current)).await + feat::update_profile(uid, None, is_current, false).await }) .await { @@ -506,7 +520,17 @@ impl Timer { } // Emit completed event - Self::emit_update_event(&uid, false); + Self::emit_update_event(uid, false); + } + + async fn wait_until_sysopt(max_wait: Duration) { + let _ = timeout(max_wait, async { + while !Sysopt::global().is_initialed() { + logging!(warn, Type::Timer, "Waiting for Sysopt to be initialized..."); + sleep(Duration::from_millis(30)).await; + } + }) + .await; } } diff --git a/clash-verge-rev/src-tauri/src/core/tray/menu_def.rs b/clash-verge-rev/src-tauri/src/core/tray/menu_def.rs new file mode 100644 index 0000000000..24a488fdcf --- /dev/null +++ b/clash-verge-rev/src-tauri/src/core/tray/menu_def.rs @@ -0,0 +1,58 @@ +use rust_i18n::t; +use std::{borrow::Cow, sync::Arc}; + +fn to_arc_str(value: Cow<'static, str>) -> Arc { + match value { + Cow::Borrowed(s) => Arc::from(s), + Cow::Owned(s) => Arc::from(s.into_boxed_str()), + } +} + +macro_rules! define_menu { + ($($field:ident => $const_name:ident, $id:expr, $text:expr),+ $(,)?) => { + #[derive(Debug)] + pub struct MenuTexts { + $(pub $field: Arc,)+ + } + + pub struct MenuIds; + + impl MenuTexts { + pub fn new() -> Self { + Self { + $($field: to_arc_str(t!($text)),)+ + } + } + } + + impl MenuIds { + $(pub const $const_name: &'static str = $id;)+ + } + }; +} + +define_menu! { + dashboard => DASHBOARD, "tray_dashboard", "tray.dashboard", + rule_mode => RULE_MODE, "tray_rule_mode", "tray.ruleMode", + global_mode => GLOBAL_MODE, "tray_global_mode", "tray.globalMode", + direct_mode => DIRECT_MODE, "tray_direct_mode", "tray.directMode", + outbound_modes => OUTBOUND_MODES, "tray_outbound_modes", "tray.outboundModes", + profiles => PROFILES, "tray_profiles", "tray.profiles", + proxies => PROXIES, "tray_proxies", "tray.proxies", + system_proxy => SYSTEM_PROXY, "tray_system_proxy", "tray.systemProxy", + tun_mode => TUN_MODE, "tray_tun_mode", "tray.tunMode", + close_all_connections => CLOSE_ALL_CONNECTIONS, "tray_close_all_connections", "tray.closeAllConnections", + lightweight_mode => LIGHTWEIGHT_MODE, "tray_lightweight_mode", "tray.lightweightMode", + copy_env => COPY_ENV, "tray_copy_env", "tray.copyEnv", + conf_dir => CONF_DIR, "tray_conf_dir", "tray.confDir", + core_dir => CORE_DIR, "tray_core_dir", "tray.coreDir", + logs_dir => LOGS_DIR, "tray_logs_dir", "tray.logsDir", + open_dir => OPEN_DIR, "tray_open_dir", "tray.openDir", + app_log => APP_LOG, "tray_app_log", "tray.appLog", + core_log => CORE_LOG, "tray_core_log", "tray.coreLog", + restart_clash => RESTART_CLASH, "tray_restart_clash", "tray.restartClash", + restart_app => RESTART_APP, "tray_restart_app", "tray.restartApp", + verge_version => VERGE_VERSION, "tray_verge_version", "tray.vergeVersion", + more => MORE, "tray_more", "tray.more", + exit => EXIT, "tray_exit", "tray.exit", +} diff --git a/clash-verge-rev/src-tauri/src/core/tray/mod.rs b/clash-verge-rev/src-tauri/src/core/tray/mod.rs index 8535ba8dd0..ce29aecc02 100644 --- a/clash-verge-rev/src-tauri/src/core/tray/mod.rs +++ b/clash-verge-rev/src-tauri/src/core/tray/mod.rs @@ -1,8 +1,11 @@ use once_cell::sync::OnceCell; -use tauri::Emitter; use tauri::tray::TrayIconBuilder; +use tauri_plugin_mihomo::models::Proxies; +use tokio::fs; #[cfg(target_os = "macos")] pub mod speed_rate; +use crate::config::{IVerge, PrfSelected}; +use crate::core::service; use crate::module::lightweight; use crate::process::AsyncHandler; use crate::utils::window_manager::WindowManager; @@ -12,16 +15,17 @@ use crate::{ feat, logging, module::lightweight::is_in_lightweight_mode, singleton_lazy, - utils::{dirs::find_target_icons, i18n::t}, + utils::{dirs::find_target_icons, i18n}, }; use super::handle; use anyhow::Result; use futures::future::join_all; use parking_lot::Mutex; +use smartstring::alias::String; use std::collections::HashMap; +use std::sync::Arc; use std::{ - fs, sync::atomic::{AtomicBool, Ordering}, time::{Duration, Instant}, }; @@ -30,9 +34,13 @@ use tauri::{ menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, tray::{MouseButton, MouseButtonState, TrayIconEvent}, }; +mod menu_def; +use menu_def::{MenuIds, MenuTexts}; // TODO: 是否需要将可变菜单抽离存储起来,后续直接更新对应菜单实例,无需重新创建菜单(待考虑) +type ProxyMenuItem = (Option>, Vec>>); + #[derive(Clone)] struct TrayState {} @@ -46,15 +54,18 @@ fn get_tray_click_debounce() -> &'static Mutex { fn should_handle_tray_click() -> bool { let debounce_lock = get_tray_click_debounce(); - let mut last_click = debounce_lock.lock(); let now = Instant::now(); - if now.duration_since(*last_click) >= Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS) { - *last_click = now; + if now.duration_since(*debounce_lock.lock()) >= Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS) { + *debounce_lock.lock() = now; true } else { - log::debug!(target: "app", "托盘点击被防抖机制忽略,距离上次点击 {:?}ms", - now.duration_since(*last_click).as_millis()); + logging!( + debug, + Type::Tray, + "托盘点击被防抖机制忽略,距离上次点击 {}ms", + now.duration_since(*debounce_lock.lock()).as_millis() + ); false } } @@ -72,18 +83,20 @@ pub struct Tray { } impl TrayState { - pub async fn get_common_tray_icon() -> (bool, Vec) { - let verge = Config::verge().await.latest_ref().clone(); + async fn get_common_tray_icon(verge: &IVerge) -> (bool, Vec) { let is_common_tray_icon = verge.common_tray_icon.unwrap_or(false); if is_common_tray_icon && let Ok(Some(common_icon_path)) = find_target_icons("common") - && let Ok(icon_data) = fs::read(common_icon_path) + && let Ok(icon_data) = fs::read(common_icon_path).await { return (true, icon_data); } #[cfg(target_os = "macos")] { - let tray_icon_colorful = verge.tray_icon.unwrap_or("monochrome".to_string()); + let tray_icon_colorful = verge + .tray_icon + .clone() + .unwrap_or_else(|| "monochrome".into()); if tray_icon_colorful == "monochrome" { ( false, @@ -106,18 +119,20 @@ impl TrayState { } } - pub async fn get_sysproxy_tray_icon() -> (bool, Vec) { - let verge = Config::verge().await.latest_ref().clone(); + async fn get_sysproxy_tray_icon(verge: &IVerge) -> (bool, Vec) { let is_sysproxy_tray_icon = verge.sysproxy_tray_icon.unwrap_or(false); if is_sysproxy_tray_icon && let Ok(Some(sysproxy_icon_path)) = find_target_icons("sysproxy") - && let Ok(icon_data) = fs::read(sysproxy_icon_path) + && let Ok(icon_data) = fs::read(sysproxy_icon_path).await { return (true, icon_data); } #[cfg(target_os = "macos")] { - let tray_icon_colorful = verge.tray_icon.clone().unwrap_or("monochrome".to_string()); + let tray_icon_colorful = verge + .tray_icon + .clone() + .unwrap_or_else(|| "monochrome".into()); if tray_icon_colorful == "monochrome" { ( false, @@ -140,18 +155,20 @@ impl TrayState { } } - pub async fn get_tun_tray_icon() -> (bool, Vec) { - let verge = Config::verge().await.latest_ref().clone(); + async fn get_tun_tray_icon(verge: &IVerge) -> (bool, Vec) { let is_tun_tray_icon = verge.tun_tray_icon.unwrap_or(false); if is_tun_tray_icon && let Ok(Some(tun_icon_path)) = find_target_icons("tun") - && let Ok(icon_data) = fs::read(tun_icon_path) + && let Ok(icon_data) = fs::read(tun_icon_path).await { return (true, icon_data); } #[cfg(target_os = "macos")] { - let tray_icon_colorful = verge.tray_icon.clone().unwrap_or("monochrome".to_string()); + let tray_icon_colorful = verge + .tray_icon + .clone() + .unwrap_or_else(|| "monochrome".into()); if tray_icon_colorful == "monochrome" { ( false, @@ -176,7 +193,7 @@ impl TrayState { impl Default for Tray { fn default() -> Self { - Tray { + Self { last_menu_update: Mutex::new(None), menu_updating: AtomicBool::new(false), } @@ -189,7 +206,7 @@ singleton_lazy!(Tray, TRAY, Tray::default); impl Tray { pub async fn init(&self) -> Result<()> { if handle::Handle::global().is_exiting() { - log::debug!(target: "app", "应用正在退出,跳过托盘初始化"); + logging!(debug, Type::Tray, "应用正在退出,跳过托盘初始化"); return Ok(()); } @@ -197,11 +214,15 @@ impl Tray { match self.create_tray_from_handle(app_handle).await { Ok(_) => { - log::info!(target: "app", "System tray created successfully"); + logging!(info, Type::Tray, "System tray created successfully"); } Err(e) => { // Don't return error, let application continue running without tray - log::warn!(target: "app", "System tray creation failed: {}, Application will continue running without tray icon", e); + logging!( + warn, + Type::Tray, + "System tray creation failed: {e}, Application will continue running without tray icon", + ); } } // TODO: 初始化时,暂时使用此方法更新系统托盘菜单,有效避免代理节点菜单空白 @@ -212,13 +233,13 @@ impl Tray { /// 更新托盘点击行为 pub async fn update_click_behavior(&self) -> Result<()> { if handle::Handle::global().is_exiting() { - log::debug!(target: "app", "应用正在退出,跳过托盘点击行为更新"); + logging!(debug, Type::Tray, "应用正在退出,跳过托盘点击行为更新"); return Ok(()); } let app_handle = handle::Handle::app_handle(); - let tray_event = { Config::verge().await.latest_ref().tray_event.clone() }; - let tray_event: String = tray_event.unwrap_or("main_window".into()); + let tray_event = { Config::verge().await.latest_arc().tray_event.clone() }; + let tray_event = tray_event.unwrap_or_else(|| "main_window".into()); let tray = app_handle .tray_by_id("main") .ok_or_else(|| anyhow::anyhow!("Failed to get main tray"))?; @@ -232,7 +253,7 @@ impl Tray { /// 更新托盘菜单 pub async fn update_menu(&self) -> Result<()> { if handle::Handle::global().is_exiting() { - log::debug!(target: "app", "应用正在退出,跳过托盘菜单更新"); + logging!(debug, Type::Tray, "应用正在退出,跳过托盘菜单更新"); return Ok(()); } // 调整最小更新间隔,确保状态及时刷新 @@ -277,24 +298,24 @@ impl Tray { } async fn update_menu_internal(&self, app_handle: &AppHandle) -> Result<()> { - let verge = Config::verge().await.latest_ref().clone(); + let verge = Config::verge().await.latest_arc(); let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false); let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false); + let tun_mode_available = cmd::system::is_admin().unwrap_or_default() + || service::is_service_available().await.is_ok(); let mode = { Config::clash() .await - .latest_ref() + .latest_arc() .0 .get("mode") .map(|val| val.as_str().unwrap_or("rule")) .unwrap_or("rule") .to_owned() }; - let profile_uid_and_name = Config::profiles() - .await - .data_mut() - .all_profile_uid_and_name() - .unwrap_or_default(); + let profiles_config = Config::profiles().await; + let profiles_arc = profiles_config.latest_arc(); + let profile_uid_and_name = profiles_arc.all_profile_uid_and_name().unwrap_or_default(); let is_lightweight_mode = is_in_lightweight_mode(); match app_handle.tray_by_id("main") { @@ -305,16 +326,21 @@ impl Tray { Some(mode.as_str()), *system_proxy, *tun_mode, + tun_mode_available, profile_uid_and_name, is_lightweight_mode, ) .await?, )); - log::debug!(target: "app", "托盘菜单更新成功"); + logging!(debug, Type::Tray, "托盘菜单更新成功"); Ok(()) } None => { - log::warn!(target: "app", "更新托盘菜单失败: 托盘不存在"); + logging!( + warn, + Type::Tray, + "Failed to update tray menu: tray not found" + ); Ok(()) } } @@ -322,9 +348,9 @@ impl Tray { /// 更新托盘图标 #[cfg(target_os = "macos")] - pub async fn update_icon(&self) -> Result<()> { + pub async fn update_icon(&self, verge: &IVerge) -> Result<()> { if handle::Handle::global().is_exiting() { - log::debug!(target: "app", "应用正在退出,跳过托盘图标更新"); + logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新"); return Ok(()); } @@ -333,23 +359,29 @@ impl Tray { let tray = match app_handle.tray_by_id("main") { Some(tray) => tray, None => { - log::warn!(target: "app", "更新托盘图标失败: 托盘不存在"); + logging!( + warn, + Type::Tray, + "Failed to update tray icon: tray not found" + ); return Ok(()); } }; - let verge = Config::verge().await.latest_ref().clone(); let system_mode = verge.enable_system_proxy.as_ref().unwrap_or(&false); let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false); let (_is_custom_icon, icon_bytes) = match (*system_mode, *tun_mode) { - (true, true) => TrayState::get_tun_tray_icon().await, - (true, false) => TrayState::get_sysproxy_tray_icon().await, - (false, true) => TrayState::get_tun_tray_icon().await, - (false, false) => TrayState::get_common_tray_icon().await, + (true, true) => TrayState::get_tun_tray_icon(verge).await, + (true, false) => TrayState::get_sysproxy_tray_icon(verge).await, + (false, true) => TrayState::get_tun_tray_icon(verge).await, + (false, false) => TrayState::get_common_tray_icon(verge).await, }; - let colorful = verge.tray_icon.clone().unwrap_or("monochrome".to_string()); + let colorful = verge + .tray_icon + .clone() + .unwrap_or_else(|| "monochrome".into()); let is_colorful = colorful == "colorful"; let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?)); @@ -358,9 +390,9 @@ impl Tray { } #[cfg(not(target_os = "macos"))] - pub async fn update_icon(&self) -> Result<()> { + pub async fn update_icon(&self, verge: &IVerge) -> Result<()> { if handle::Handle::global().is_exiting() { - log::debug!(target: "app", "应用正在退出,跳过托盘图标更新"); + logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新"); return Ok(()); } @@ -369,54 +401,41 @@ impl Tray { let tray = match app_handle.tray_by_id("main") { Some(tray) => tray, None => { - log::warn!(target: "app", "更新托盘图标失败: 托盘不存在"); + logging!( + warn, + Type::Tray, + "Failed to update tray icon: tray not found" + ); return Ok(()); } }; - let verge = Config::verge().await.latest_ref().clone(); let system_mode = verge.enable_system_proxy.as_ref().unwrap_or(&false); let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false); let (_is_custom_icon, icon_bytes) = match (*system_mode, *tun_mode) { - (true, true) => TrayState::get_tun_tray_icon().await, - (true, false) => TrayState::get_sysproxy_tray_icon().await, - (false, true) => TrayState::get_tun_tray_icon().await, - (false, false) => TrayState::get_common_tray_icon().await, + (true, true) => TrayState::get_tun_tray_icon(verge).await, + (true, false) => TrayState::get_sysproxy_tray_icon(verge).await, + (false, true) => TrayState::get_tun_tray_icon(verge).await, + (false, false) => TrayState::get_common_tray_icon(verge).await, }; let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?)); Ok(()) } - /// 更新托盘显示状态的函数 - pub async fn update_tray_display(&self) -> Result<()> { - if handle::Handle::global().is_exiting() { - log::debug!(target: "app", "应用正在退出,跳过托盘显示状态更新"); - return Ok(()); - } - - let app_handle = handle::Handle::app_handle(); - let _tray = app_handle - .tray_by_id("main") - .ok_or_else(|| anyhow::anyhow!("Failed to get main tray"))?; - - // 更新菜单 - self.update_menu().await?; - - Ok(()) - } - /// 更新托盘提示 pub async fn update_tooltip(&self) -> Result<()> { if handle::Handle::global().is_exiting() { - log::debug!(target: "app", "应用正在退出,跳过托盘提示更新"); + logging!(debug, Type::Tray, "应用正在退出,跳过托盘提示更新"); return Ok(()); } let app_handle = handle::Handle::app_handle(); - let verge = Config::verge().await.latest_ref().clone(); + i18n::sync_locale().await; + + let verge = Config::verge().await.latest_arc(); let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false); let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false); @@ -427,12 +446,12 @@ impl Tray { map }; - let mut current_profile_name = "None".to_string(); + let mut current_profile_name = "None".into(); { let profiles = Config::profiles().await; - let profiles = profiles.latest_ref(); + let profiles = profiles.latest_arc(); if let Some(current_profile_uid) = profiles.get_current() - && let Ok(profile) = profiles.get_item(¤t_profile_uid) + && let Ok(profile) = profiles.get_item(current_profile_uid) { current_profile_name = match &profile.name { Some(profile_name) => profile_name.to_string(), @@ -442,14 +461,15 @@ impl Tray { } // Get localized strings before using them - let sys_proxy_text = t("SysProxy").await; - let tun_text = t("TUN").await; - let profile_text = t("Profile").await; + let sys_proxy_text = rust_i18n::t!("tray.tooltip.systemProxy"); + let tun_text = rust_i18n::t!("tray.tooltip.tun"); + let profile_text = rust_i18n::t!("tray.tooltip.profile"); let v = env!("CARGO_PKG_VERSION"); - let reassembled_version = v.split_once('+').map_or(v.to_string(), |(main, rest)| { - format!("{main}+{}", rest.split('.').next().unwrap_or("")) - }); + let reassembled_version = v.split_once('+').map_or_else( + || v.into(), + |(main, rest)| format!("{main}+{}", rest.split('.').next().unwrap_or("")), + ); let tooltip = format!( "Clash Verge {}\n{}: {}\n{}: {}\n{}: {}", @@ -465,7 +485,11 @@ impl Tray { if let Some(tray) = app_handle.tray_by_id("main") { let _ = tray.set_tooltip(Some(&tooltip)); } else { - log::warn!(target: "app", "更新托盘提示失败: 托盘不存在"); + logging!( + warn, + Type::Tray, + "Failed to update tray tooltip: tray not found" + ); } Ok(()) @@ -473,27 +497,28 @@ impl Tray { pub async fn update_part(&self) -> Result<()> { if handle::Handle::global().is_exiting() { - log::debug!(target: "app", "应用正在退出,跳过托盘局部更新"); + logging!(debug, Type::Tray, "应用正在退出,跳过托盘局部更新"); return Ok(()); } - // self.update_menu().await?; - // 更新轻量模式显示状态 - self.update_tray_display().await?; - self.update_icon().await?; + let verge = Config::verge().await.data_arc(); + self.update_menu().await?; + self.update_icon(&verge).await?; self.update_tooltip().await?; Ok(()) } - pub async fn create_tray_from_handle(&self, app_handle: &AppHandle) -> Result<()> { + async fn create_tray_from_handle(&self, app_handle: &AppHandle) -> Result<()> { if handle::Handle::global().is_exiting() { - log::debug!(target: "app", "应用正在退出,跳过托盘创建"); + logging!(debug, Type::Tray, "应用正在退出,跳过托盘创建"); return Ok(()); } - log::info!(target: "app", "正在从AppHandle创建系统托盘"); + logging!(info, Type::Tray, "正在从AppHandle创建系统托盘"); + + let verge = Config::verge().await.data_arc(); // 获取图标 - let icon_bytes = TrayState::get_common_tray_icon().await.1; + let icon_bytes = TrayState::get_common_tray_icon(&verge).await.1; let icon = tauri::image::Image::from_bytes(&icon_bytes)?; #[cfg(target_os = "linux")] @@ -503,8 +528,9 @@ impl Tray { #[cfg(any(target_os = "macos", target_os = "windows"))] let show_menu_on_left_click = { - let tray_event = { Config::verge().await.latest_ref().tray_event.clone() }; - let tray_event: String = tray_event.unwrap_or("main_window".into()); + // TODO 优化这里 复用 verge + let tray_event = { Config::verge().await.latest_arc().tray_event.clone() }; + let tray_event: String = tray_event.unwrap_or_else(|| "main_window".into()); tray_event.as_str() == "tray_menu" }; @@ -523,144 +549,106 @@ impl Tray { let tray = builder.build(app_handle)?; tray.on_tray_icon_event(|_app_handle, event| { - AsyncHandler::spawn(|| async move { - let tray_event = { Config::verge().await.latest_ref().tray_event.clone() }; - let tray_event: String = tray_event.unwrap_or("main_window".into()); - log::debug!(target: "app", "tray event: {tray_event:?}"); + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Down, + .. + } = event + { + AsyncHandler::spawn(|| async move { + let tray_event = { Config::verge().await.latest_arc().tray_event.clone() }; + let tray_event: String = tray_event.unwrap_or_else(|| "main_window".into()); + logging!(debug, Type::Tray, "tray event: {tray_event:?}"); - if let TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Down, - .. - } = event - { // 添加防抖检查,防止快速连击 if !should_handle_tray_click() { return; } - use std::future::Future; - use std::pin::Pin; - - let fut: Pin + Send>> = match tray_event.as_str() { - "system_proxy" => Box::pin(async move { - feat::toggle_system_proxy().await; - }), - "tun_mode" => Box::pin(async move { - feat::toggle_tun_mode(None).await; - }), - "main_window" => Box::pin(async move { + match tray_event.as_str() { + "system_proxy" => feat::toggle_system_proxy().await, + "tun_mode" => feat::toggle_tun_mode(None).await, + "main_window" => { if !lightweight::exit_lightweight_mode().await { WindowManager::show_main_window().await; }; - }), - _ => Box::pin(async move {}), + } + _ => {} }; - fut.await; - } - }); + }); + } }); tray.on_menu_event(on_menu_event); - log::info!(target: "app", "系统托盘创建成功"); - Ok(()) - } - - // 托盘统一的状态更新函数 - pub async fn update_all_states(&self) -> Result<()> { - if handle::Handle::global().is_exiting() { - log::debug!(target: "app", "应用正在退出,跳过托盘状态更新"); - return Ok(()); - } - - // 确保所有状态更新完成 - self.update_tray_display().await?; - // self.update_menu().await?; - self.update_icon().await?; - self.update_tooltip().await?; - Ok(()) } } -async fn create_tray_menu( - app_handle: &AppHandle, - mode: Option<&str>, - system_proxy_enabled: bool, - tun_mode_enabled: bool, - profile_uid_and_name: Vec<(String, String)>, - is_lightweight_mode: bool, -) -> Result> { - let mode = mode.unwrap_or(""); - - // 获取当前配置文件的选中代理组信息 - let current_profile_selected = { - let profiles_config = Config::profiles().await; - let profiles_ref = profiles_config.latest_ref(); - profiles_ref - .get_current() - .and_then(|uid| profiles_ref.get_item(&uid).ok()) - .and_then(|profile| profile.selected.clone()) - .unwrap_or_default() - }; - - let proxy_nodes_data = handle::Handle::mihomo().await.get_proxies().await; - - let version = env!("CARGO_PKG_VERSION"); - - let hotkeys = Config::verge() - .await - .latest_ref() - .hotkeys +fn create_hotkeys(hotkeys: &Option>) -> HashMap { + hotkeys .as_ref() .map(|h| { h.iter() .filter_map(|item| { let mut parts = item.split(','); match (parts.next(), parts.next()) { - (Some(func), Some(key)) => Some((func.to_string(), key.to_string())), + (Some(func), Some(key)) => { + // 托盘菜单中的 `accelerator` 属性,在 Linux/Windows 中都不支持小键盘按键的解析 + if key.to_uppercase().contains("NUMPAD") { + None + } else { + Some((func.into(), key.into())) + } + } _ => None, } }) .collect::>() }) - .unwrap_or_default(); + .unwrap_or_default() +} - let profile_menu_items: Vec> = { - let futures = profile_uid_and_name - .iter() - .map(|(profile_uid, profile_name)| { - let app_handle = app_handle.clone(); - let profile_uid = profile_uid.clone(); - let profile_name = profile_name.clone(); - async move { - let is_current_profile = Config::profiles() - .await - .data_mut() - .is_current_profile_index(profile_uid.to_string()); - CheckMenuItem::with_id( - &app_handle, - format!("profiles_{profile_uid}"), - t(&profile_name).await, - true, - is_current_profile, - None::<&str>, - ) - } - }); - let results = join_all(futures).await; - results.into_iter().collect::, _>>()? - }; +async fn create_profile_menu_item( + app_handle: &AppHandle, + profile_uid_and_name: Vec<(&String, &String)>, +) -> Result>> { + let futures = profile_uid_and_name + .iter() + .map(|(profile_uid, profile_name)| { + let app_handle = app_handle.clone(); + async move { + let is_current_profile = Config::profiles() + .await + .latest_arc() + .is_current_profile_index(profile_uid); + CheckMenuItem::with_id( + &app_handle, + format!("profiles_{profile_uid}"), + profile_name.as_str(), + true, + is_current_profile, + None::<&str>, + ) + } + }); + let results = join_all(futures).await; + Ok(results.into_iter().collect::, _>>()?) +} - // 代理组子菜单 +fn create_subcreate_proxy_menu_item( + app_handle: &AppHandle, + proxy_mode: &str, + current_profile_selected: &[PrfSelected], + proxy_group_order_map: Option>, + proxy_nodes_data: Result, +) -> Result>> { let proxy_submenus: Vec> = { - let mut submenus = Vec::new(); - let mut group_name_submenus_hash = HashMap::new(); + let mut submenus: Vec<(String, usize, Submenu)> = Vec::new(); // TODO: 应用启动时,内核还未启动完全,无法获取代理节点信息 if let Ok(proxy_nodes_data) = proxy_nodes_data { for (group_name, group_data) in proxy_nodes_data.proxies.iter() { // Filter groups based on mode - let should_show = match mode { + let should_show = match proxy_mode { "global" => group_name == "GLOBAL", _ => group_name != "GLOBAL", } && @@ -690,11 +678,11 @@ async fn create_tray_menu( .get(proxy_str) .and_then(|h| h.history.last()) .map(|h| match h.delay { - 0 => "-ms".to_string(), - delay if delay >= 10000 => "-ms".to_string(), + 0 => "-ms".into(), + delay if delay >= 10000 => "-ms".into(), _ => format!("{}ms", h.delay), }) - .unwrap_or_else(|| "-ms".to_string()); + .unwrap_or_else(|| "-ms".into()); let display_text = format!("{} | {}", proxy_str, delay_text); @@ -706,7 +694,9 @@ async fn create_tray_menu( is_selected, None::<&str>, ) - .map_err(|e| log::warn!(target: "app", "创建代理菜单项失败: {}", e)) + .map_err(|e| { + logging!(warn, Type::Tray, "Failed to create proxy menu item: {}", e) + }) .ok() }) .collect(); @@ -716,7 +706,7 @@ async fn create_tray_menu( } // Determine if group is active - let is_group_active = match mode { + let is_group_active = match proxy_mode { "global" => group_name == "GLOBAL" && !now_proxy.is_empty(), "direct" => false, _ => { @@ -745,76 +735,152 @@ async fn create_tray_menu( true, &group_items_refs, ) { - group_name_submenus_hash.insert(group_name.to_string(), submenu); + let insertion_index = submenus.len(); + submenus.push((group_name.into(), insertion_index, submenu)); } else { - log::warn!(target: "app", "创建代理组子菜单失败: {}", group_name); + logging!( + warn, + Type::Tray, + "Failed to create proxy group submenu: {}", + group_name + ); } } } - // 获取运行时代理组配置 - let runtime_proxy_groups_config = cmd::get_runtime_config() - .await - .map_err(|e| { - logging!( - error, - Type::Cmd, - "Failed to fetch runtime proxy groups for tray menu: {e}" - ); - }) - .ok() - .flatten() - .map(|config| { - config - .get("proxy-groups") - .and_then(|groups| groups.as_sequence()) - .map(|groups| { - groups - .iter() - .filter_map(|group| group.get("name")) - .filter_map(|name| name.as_str()) - .map(|name| name.to_string()) - .collect::>() - }) - .unwrap_or_default() - }); - - if let Some(runtime_proxy_groups_config) = runtime_proxy_groups_config { - for group_name in runtime_proxy_groups_config { - if let Some(submenu) = group_name_submenus_hash.get(&group_name) { - submenus.push(submenu.clone()); - } - } - } else { - for (_, submenu) in group_name_submenus_hash { - submenus.push(submenu); - } + if let Some(order_map) = proxy_group_order_map.as_ref() { + submenus.sort_by( + |(name_a, original_index_a, _), (name_b, original_index_b, _)| match ( + order_map.get(name_a), + order_map.get(name_b), + ) { + (Some(index_a), Some(index_b)) => index_a.cmp(index_b), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => original_index_a.cmp(original_index_b), + }, + ); } submenus + .into_iter() + .map(|(_, _, submenu)| submenu) + .collect() + }; + Ok(proxy_submenus) +} + +fn create_proxy_menu_item( + app_handle: &AppHandle, + show_proxy_groups_inline: bool, + proxy_submenus: Vec>, + proxies_text: &Arc, +) -> Result { + // 创建代理主菜单 + let (proxies_submenu, inline_proxy_items) = if show_proxy_groups_inline { + ( + None, + proxy_submenus + .into_iter() + .map(|submenu| Box::new(submenu) as Box>) + .collect(), + ) + } else if !proxy_submenus.is_empty() { + let proxy_submenu_refs: Vec<&dyn IsMenuItem> = proxy_submenus + .iter() + .map(|submenu| submenu as &dyn IsMenuItem) + .collect(); + + ( + Some(Submenu::with_id_and_items( + app_handle, + MenuIds::PROXIES, + proxies_text, + true, + &proxy_submenu_refs, + )?), + Vec::new(), + ) + } else { + (None, Vec::new()) + }; + Ok((proxies_submenu, inline_proxy_items)) +} + +async fn create_tray_menu( + app_handle: &AppHandle, + mode: Option<&str>, + system_proxy_enabled: bool, + tun_mode_enabled: bool, + tun_mode_available: bool, + profile_uid_and_name: Vec<(&String, &String)>, + is_lightweight_mode: bool, +) -> Result> { + let current_proxy_mode = mode.unwrap_or(""); + + i18n::sync_locale().await; + + // 获取当前配置文件的选中代理组信息 + let current_profile_selected = { + let profiles_config = Config::profiles().await; + let profiles_ref = profiles_config.latest_arc(); + profiles_ref + .get_current() + .and_then(|uid| profiles_ref.get_item(uid).ok()) + .and_then(|profile| profile.selected.clone()) + .unwrap_or_default() }; - // Pre-fetch all localized strings - let dashboard_text = t("Dashboard").await; - let rule_mode_text = t("Rule Mode").await; - let global_mode_text = t("Global Mode").await; - let direct_mode_text = t("Direct Mode").await; - let profiles_text = t("Profiles").await; - let proxies_text = t("Proxies").await; - let system_proxy_text = t("System Proxy").await; - let tun_mode_text = t("TUN Mode").await; - let lightweight_mode_text = t("LightWeight Mode").await; - let copy_env_text = t("Copy Env").await; - let conf_dir_text = t("Conf Dir").await; - let core_dir_text = t("Core Dir").await; - let logs_dir_text = t("Logs Dir").await; - let open_dir_text = t("Open Dir").await; - let restart_clash_text = t("Restart Clash Core").await; - let restart_app_text = t("Restart App").await; - let verge_version_text = t("Verge Version").await; - let more_text = t("More").await; - let exit_text = t("Exit").await; + let proxy_nodes_data = handle::Handle::mihomo().await.get_proxies().await; + let runtime_proxy_groups_order = cmd::get_runtime_config() + .await + .map_err(|e| { + logging!( + error, + Type::Cmd, + "Failed to fetch runtime proxy groups for tray menu: {e}" + ); + }) + .ok() + .flatten() + .map(|config| { + config + .get("proxy-groups") + .and_then(|groups| groups.as_sequence()) + .map(|groups| { + groups + .iter() + .filter_map(|group| group.get("name")) + .filter_map(|name| name.as_str()) + .map(|name| name.into()) + .collect::>() + }) + .unwrap_or_default() + }); + + let proxy_group_order_map: Option< + HashMap, usize>, + > = runtime_proxy_groups_order.as_ref().map(|group_names| { + group_names + .iter() + .enumerate() + .map(|(index, name)| (name.clone(), index)) + .collect::>() + }); + + let verge_settings = Config::verge().await.latest_arc(); + let show_proxy_groups_inline = verge_settings.tray_inline_proxy_groups.unwrap_or(true); + + let version = env!("CARGO_PKG_VERSION"); + + let hotkeys = create_hotkeys(&verge_settings.hotkeys); + + let profile_menu_items: Vec> = + create_profile_menu_item(app_handle, profile_uid_and_name).await?; + + // 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() @@ -823,69 +889,85 @@ async fn create_tray_menu( let open_window = &MenuItem::with_id( app_handle, - "open_window", - dashboard_text, + MenuIds::DASHBOARD, + &texts.dashboard, true, hotkeys.get("open_or_close_dashboard").map(|s| s.as_str()), )?; + let current_mode_text = match current_proxy_mode { + "global" => rust_i18n::t!("tray.global"), + "direct" => rust_i18n::t!("tray.direct"), + _ => rust_i18n::t!("tray.rule"), + }; + let outbound_modes_label = format!("{} ({})", texts.outbound_modes, current_mode_text); + let rule_mode = &CheckMenuItem::with_id( app_handle, - "rule_mode", - rule_mode_text, + MenuIds::RULE_MODE, + &texts.rule_mode, true, - mode == "rule", + current_proxy_mode == "rule", hotkeys.get("clash_mode_rule").map(|s| s.as_str()), )?; let global_mode = &CheckMenuItem::with_id( app_handle, - "global_mode", - global_mode_text, + MenuIds::GLOBAL_MODE, + &texts.global_mode, true, - mode == "global", + current_proxy_mode == "global", hotkeys.get("clash_mode_global").map(|s| s.as_str()), )?; let direct_mode = &CheckMenuItem::with_id( app_handle, - "direct_mode", - direct_mode_text, + MenuIds::DIRECT_MODE, + &texts.direct_mode, true, - mode == "direct", + current_proxy_mode == "direct", hotkeys.get("clash_mode_direct").map(|s| s.as_str()), )?; + let outbound_modes = &Submenu::with_id_and_items( + app_handle, + MenuIds::OUTBOUND_MODES, + outbound_modes_label.as_str(), + true, + &[ + rule_mode as &dyn IsMenuItem, + global_mode as &dyn IsMenuItem, + direct_mode as &dyn IsMenuItem, + ], + )?; + let profiles = &Submenu::with_id_and_items( app_handle, - "profiles", - profiles_text, + MenuIds::PROFILES, + &texts.profiles, true, &profile_menu_items_refs, )?; - // 创建代理主菜单 - let proxies_submenu = if !proxy_submenus.is_empty() { - let proxy_submenu_refs: Vec<&dyn IsMenuItem> = proxy_submenus - .iter() - .map(|submenu| submenu as &dyn IsMenuItem) - .collect(); + let proxy_sub_menus = create_subcreate_proxy_menu_item( + app_handle, + current_proxy_mode, + ¤t_profile_selected, + proxy_group_order_map, + proxy_nodes_data.map_err(anyhow::Error::from), + )?; - Some(Submenu::with_id_and_items( - app_handle, - "proxies", - proxies_text, - true, - &proxy_submenu_refs, - )?) - } else { - None - }; + let (proxies_menu, inline_proxy_items) = create_proxy_menu_item( + app_handle, + show_proxy_groups_inline, + proxy_sub_menus, + &texts.proxies, + )?; let system_proxy = &CheckMenuItem::with_id( app_handle, - "system_proxy", - system_proxy_text, + MenuIds::SYSTEM_PROXY, + &texts.system_proxy, true, system_proxy_enabled, hotkeys.get("toggle_system_proxy").map(|s| s.as_str()), @@ -893,105 +975,150 @@ async fn create_tray_menu( let tun_mode = &CheckMenuItem::with_id( app_handle, - "tun_mode", - tun_mode_text, - true, + MenuIds::TUN_MODE, + &texts.tun_mode, + tun_mode_available, tun_mode_enabled, hotkeys.get("toggle_tun_mode").map(|s| s.as_str()), )?; - let lighteweight_mode = &CheckMenuItem::with_id( + let close_all_connections = &MenuItem::with_id( app_handle, - "entry_lightweight_mode", - lightweight_mode_text, + MenuIds::CLOSE_ALL_CONNECTIONS, + &texts.close_all_connections, + true, + None::<&str>, + )?; + + let lightweight_mode = &CheckMenuItem::with_id( + app_handle, + MenuIds::LIGHTWEIGHT_MODE, + &texts.lightweight_mode, true, is_lightweight_mode, hotkeys.get("entry_lightweight_mode").map(|s| s.as_str()), )?; - let copy_env = &MenuItem::with_id(app_handle, "copy_env", copy_env_text, true, None::<&str>)?; + let copy_env = &MenuItem::with_id( + app_handle, + MenuIds::COPY_ENV, + &texts.copy_env, + true, + None::<&str>, + )?; let open_app_dir = &MenuItem::with_id( app_handle, - "open_app_dir", - conf_dir_text, + MenuIds::CONF_DIR, + &texts.conf_dir, true, None::<&str>, )?; let open_core_dir = &MenuItem::with_id( app_handle, - "open_core_dir", - core_dir_text, + MenuIds::CORE_DIR, + &texts.core_dir, true, None::<&str>, )?; let open_logs_dir = &MenuItem::with_id( app_handle, - "open_logs_dir", - logs_dir_text, + MenuIds::LOGS_DIR, + &texts.logs_dir, + true, + None::<&str>, + )?; + + let open_app_log = &MenuItem::with_id( + app_handle, + MenuIds::APP_LOG, + &texts.app_log, + true, + None::<&str>, + )?; + + let open_core_log = &MenuItem::with_id( + app_handle, + MenuIds::CORE_LOG, + &texts.core_log, true, None::<&str>, )?; let open_dir = &Submenu::with_id_and_items( app_handle, - "open_dir", - open_dir_text, + MenuIds::OPEN_DIR, + &texts.open_dir, true, - &[open_app_dir, open_core_dir, open_logs_dir], + &[ + open_app_dir, + open_core_dir, + open_logs_dir, + open_app_log, + open_core_log, + ], )?; let restart_clash = &MenuItem::with_id( app_handle, - "restart_clash", - restart_clash_text, + MenuIds::RESTART_CLASH, + &texts.restart_clash, true, None::<&str>, )?; let restart_app = &MenuItem::with_id( app_handle, - "restart_app", - restart_app_text, + MenuIds::RESTART_APP, + &texts.restart_app, true, None::<&str>, )?; let app_version = &MenuItem::with_id( app_handle, - "app_version", - format!("{} {version}", verge_version_text), + MenuIds::VERGE_VERSION, + format!("{} {version}", &texts.verge_version), true, None::<&str>, )?; let more = &Submenu::with_id_and_items( app_handle, - "more", - more_text, + MenuIds::MORE, + &texts.more, true, - &[restart_clash, restart_app, app_version], + &[ + copy_env as &dyn IsMenuItem, + close_all_connections, + restart_clash, + restart_app, + app_version, + ], )?; - let quit = &MenuItem::with_id(app_handle, "quit", exit_text, true, Some("CmdOrControl+Q"))?; + let quit = &MenuItem::with_id( + app_handle, + MenuIds::EXIT, + &texts.exit, + true, + Some("CmdOrControl+Q"), + )?; let separator = &PredefinedMenuItem::separator(app_handle)?; // 动态构建菜单项 - let mut menu_items: Vec<&dyn IsMenuItem> = vec![ - open_window, - separator, - rule_mode, - global_mode, - direct_mode, - separator, - profiles, - ]; + let mut menu_items: Vec<&dyn IsMenuItem> = + vec![open_window, outbound_modes, separator, profiles]; // 如果有代理节点,添加代理节点菜单 - if let Some(ref proxies_menu) = proxies_submenu { + if show_proxy_groups_inline { + if !inline_proxy_items.is_empty() { + menu_items.extend(inline_proxy_items.iter().map(|item| item.as_ref())); + } + } else if let Some(ref proxies_menu) = proxies_menu { menu_items.push(proxies_menu); } @@ -1000,8 +1127,7 @@ async fn create_tray_menu( system_proxy as &dyn IsMenuItem, tun_mode as &dyn IsMenuItem, separator, - lighteweight_mode as &dyn IsMenuItem, - copy_env as &dyn IsMenuItem, + lightweight_mode as &dyn IsMenuItem, open_dir as &dyn IsMenuItem, more as &dyn IsMenuItem, separator, @@ -1017,13 +1143,14 @@ async fn create_tray_menu( fn on_menu_event(_: &AppHandle, event: MenuEvent) { AsyncHandler::spawn(|| async move { match event.id.as_ref() { - mode @ ("rule_mode" | "global_mode" | "direct_mode") => { - let mode = &mode[0..mode.len() - 5]; // Removing the "_mode" suffix + mode @ (MenuIds::RULE_MODE | MenuIds::GLOBAL_MODE | MenuIds::DIRECT_MODE) => { + // Removing the the "tray_" preffix and "_mode" suffix + let mode = &mode[5..mode.len() - 5]; logging!(info, Type::ProxyMode, "Switch Proxy Mode To: {}", mode); feat::change_clash_mode(mode.into()).await; } - "open_window" => { - log::info!(target: "app", "托盘菜单点击: 打开窗口"); + MenuIds::DASHBOARD => { + logging!(info, Type::Tray, "托盘菜单点击: 打开窗口"); if !should_handle_tray_click() { return; @@ -1032,35 +1159,51 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) { WindowManager::show_main_window().await; }; } - "system_proxy" => { + MenuIds::SYSTEM_PROXY => { feat::toggle_system_proxy().await; } - "tun_mode" => { + MenuIds::TUN_MODE => { feat::toggle_tun_mode(None).await; } - "copy_env" => feat::copy_clash_env().await, - "open_app_dir" => { + MenuIds::CLOSE_ALL_CONNECTIONS => { + if let Err(err) = handle::Handle::mihomo().await.close_all_connections().await { + logging!( + error, + Type::Tray, + "Failed to close all connections from tray: {err}" + ); + } + } + MenuIds::COPY_ENV => feat::copy_clash_env().await, + MenuIds::CONF_DIR => { + println!("Open directory submenu clicked"); let _ = cmd::open_app_dir().await; } - "open_core_dir" => { + MenuIds::CORE_DIR => { let _ = cmd::open_core_dir().await; } - "open_logs_dir" => { + MenuIds::LOGS_DIR => { let _ = cmd::open_logs_dir().await; } - "restart_clash" => feat::restart_clash_core().await, - "restart_app" => feat::restart_app().await, - "entry_lightweight_mode" => { + MenuIds::APP_LOG => { + let _ = cmd::open_app_log().await; + } + MenuIds::CORE_LOG => { + let _ = cmd::open_core_log().await; + } + MenuIds::RESTART_CLASH => feat::restart_clash_core().await, + MenuIds::RESTART_APP => feat::restart_app().await, + MenuIds::LIGHTWEIGHT_MODE => { if !should_handle_tray_click() { return; } if !is_in_lightweight_mode() { - lightweight::entry_lightweight_mode().await; // Await async function + lightweight::entry_lightweight_mode().await; } else { - lightweight::exit_lightweight_mode().await; // Await async function + lightweight::exit_lightweight_mode().await; } } - "quit" => { + MenuIds::EXIT => { feat::quit().await; } id if id.starts_with("profiles_") => { @@ -1069,47 +1212,27 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) { } id if id.starts_with("proxy_") => { // proxy_{group_name}_{proxy_name} - let parts: Vec<&str> = id.splitn(3, '_').collect(); - - if parts.len() == 3 && parts[0] == "proxy" { - let group_name = parts[1]; - let proxy_name = parts[2]; - - match handle::Handle::mihomo() - .await - .select_node_for_group(group_name, proxy_name) - .await - { - Ok(_) => { - log::info!(target: "app", "切换代理成功: {} -> {}", group_name, proxy_name); - let _ = handle::Handle::app_handle() - .emit("verge://refresh-proxy-config", ()); - } - Err(e) => { - log::error!(target: "app", "切换代理失败: {} -> {}, 错误: {:?}", group_name, proxy_name, e); - - // Fallback to IPC update - if (handle::Handle::mihomo() - .await - .select_node_for_group(group_name, proxy_name) - .await) - .is_ok() - { - log::info!(target: "app", "代理切换回退成功: {} -> {}", group_name, proxy_name); - - let app_handle = handle::Handle::app_handle(); - let _ = app_handle.emit("verge://force-refresh-proxies", ()); - } - } - } - } + let rest = match id.strip_prefix("proxy_") { + Some(r) => r, + None => return, + }; + let (group_name, proxy_name) = match rest.split_once('_') { + Some((g, p)) => (g, p), + None => return, + }; + feat::switch_proxy_node(group_name, proxy_name).await; + } + _ => { + logging!( + debug, + Type::Tray, + "Unhandled tray menu event: {:?}", + event.id + ); } - _ => {} } - // Ensure tray state update is awaited and properly handled - if let Err(e) = Tray::global().update_all_states().await { - log::warn!(target: "app", "更新托盘状态失败: {e}"); - } + // We dont expected to refresh tray state here + // as the inner handle function (SHOULD) already takes care of it }); } diff --git a/clash-verge-rev/src-tauri/src/core/validate.rs b/clash-verge-rev/src-tauri/src/core/validate.rs new file mode 100644 index 0000000000..330c96f3c7 --- /dev/null +++ b/clash-verge-rev/src-tauri/src/core/validate.rs @@ -0,0 +1,368 @@ +use anyhow::Result; +use scopeguard::defer; +use smartstring::alias::String; +use std::sync::atomic::{AtomicBool, Ordering}; +use tauri_plugin_shell::ShellExt as _; +use tokio::fs; + +use crate::config::{Config, ConfigType}; +use crate::core::handle; +use crate::singleton_lazy; +use crate::utils::dirs; +use crate::{logging, utils::logging::Type}; + +pub struct CoreConfigValidator { + is_processing: AtomicBool, +} + +impl CoreConfigValidator { + pub const fn new() -> Self { + Self { + is_processing: AtomicBool::new(false), + } + } + + pub fn try_start(&self) -> bool { + !self.is_processing.swap(true, Ordering::AcqRel) + } + + pub fn finish(&self) { + self.is_processing.store(false, Ordering::Release) + } +} + +impl CoreConfigValidator { + /// 检查文件是否为脚本文件 + async fn is_script_file(path: &str) -> Result { + // 1. 先通过扩展名快速判断 + if has_ext(path, "yaml") || has_ext(path, "yml") { + return Ok(false); // YAML文件不是脚本文件 + } else if has_ext(path, "js") { + return Ok(true); // JS文件是脚本文件 + } + + // 2. 读取文件内容 + let content = match fs::read_to_string(path).await { + Ok(content) => content, + Err(err) => { + logging!( + warn, + Type::Validate, + "无法读取文件以检测类型: {}, 错误: {}", + path, + err + ); + return Err(anyhow::anyhow!( + "Failed to read file to detect type: {}", + err + )); + } + }; + + // 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 ") + || content.contains("var ") + || content.contains("//") + || content.contains("/*") + || content.contains("*/") + || 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") + { + return Ok(true); + } + + // 检查冒号后是否有空格(YAML的典型特征) + let yaml_pattern_count = content.lines().filter(|line| line.contains(": ")).count(); + + if yaml_pattern_count > 2 { + return Ok(false); // 多个键值对格式,更可能是YAML + } + } + + // 默认情况:无法确定时,假设为非脚本文件(更安全) + logging!( + debug, + Type::Validate, + "无法确定文件类型,默认当作YAML处理: {}", + path + ); + Ok(false) + } + + /// 只进行文件语法检查,不进行完整验证 + async fn validate_file_syntax(config_path: &str) -> Result<(bool, String)> { + logging!(info, Type::Validate, "开始检查文件: {}", config_path); + + // 读取文件内容 + let content = match fs::read_to_string(config_path).await { + Ok(content) => content, + Err(err) => { + let error_msg = format!("Failed to read file: {err}").into(); + logging!(error, Type::Validate, "无法读取文件: {}", error_msg); + return Ok((false, error_msg)); + } + }; + // 对YAML文件尝试解析,只检查语法正确性 + logging!(info, Type::Validate, "进行YAML语法检查"); + match serde_yaml_ng::from_str::(&content) { + Ok(_) => { + logging!(info, Type::Validate, "YAML语法检查通过"); + Ok((true, String::new())) + } + Err(err) => { + // 使用标准化的前缀,以便错误处理函数能正确识别 + let error_msg = format!("YAML syntax error: {err}").into(); + logging!(error, Type::Validate, "YAML语法错误: {}", error_msg); + Ok((false, error_msg)) + } + } + } + + /// 验证脚本文件语法 + async fn validate_script_file(path: &str) -> Result<(bool, String)> { + // 读取脚本内容 + let content = match fs::read_to_string(path).await { + Ok(content) => content, + Err(err) => { + let error_msg = format!("Failed to read script file: {err}").into(); + logging!(warn, Type::Validate, "脚本语法错误: {}", err); + //handle::Handle::notice_message("config_validate::script_syntax_error", &error_msg); + return Ok((false, error_msg)); + } + }; + + logging!(debug, Type::Validate, "验证脚本文件: {}", path); + + // 使用boa引擎进行基本语法检查 + use boa_engine::{Context, Source}; + + let mut context = Context::default(); + let result = context.eval(Source::from_bytes(&content)); + + match result { + Ok(_) => { + logging!(debug, Type::Validate, "脚本语法验证通过: {}", path); + + // 检查脚本是否包含main函数 + if !content.contains("function main") + && !content.contains("const main") + && !content.contains("let main") + { + let error_msg = "Script must contain a main function"; + logging!(warn, Type::Validate, "脚本缺少main函数: {}", path); + //handle::Handle::notice_message("config_validate::script_missing_main", error_msg); + return Ok((false, error_msg.into())); + } + + Ok((true, String::new())) + } + Err(err) => { + let error_msg = format!("Script syntax error: {err}").into(); + logging!(warn, Type::Validate, "脚本语法错误: {}", err); + //handle::Handle::notice_message("config_validate::script_syntax_error", &error_msg); + Ok((false, error_msg)) + } + } + } + + /// 验证指定的配置文件 + pub async fn validate_config_file( + config_path: &str, + is_merge_file: Option, + ) -> Result<(bool, String)> { + // 检查程序是否正在退出,如果是则跳过验证 + if handle::Handle::global().is_exiting() { + logging!(info, Type::Core, "应用正在退出,跳过验证"); + return Ok((true, String::new())); + } + + // 检查文件是否存在 + if !std::path::Path::new(config_path).exists() { + let error_msg = format!("File not found: {config_path}").into(); + //handle::Handle::notice_message("config_validate::file_not_found", &error_msg); + return Ok((false, error_msg)); + } + + // 如果是合并文件且不是强制验证,执行语法检查但不进行完整验证 + if is_merge_file.unwrap_or(false) { + logging!( + info, + Type::Validate, + "检测到Merge文件,仅进行语法检查: {}", + config_path + ); + return Self::validate_file_syntax(config_path).await; + } + + // 检查是否为脚本文件 + let is_script = if config_path.ends_with(".js") { + true + } else { + 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(config_path).await; + } + } + }; + + if is_script { + logging!( + info, + Type::Validate, + "检测到脚本文件,使用JavaScript验证: {}", + config_path + ); + return Self::validate_script_file(config_path).await; + } + + // 对YAML配置文件使用Clash内核验证 + logging!( + info, + Type::Validate, + "使用Clash内核验证配置文件: {}", + config_path + ); + Self::validate_config_internal(config_path).await + } + + /// 内部验证配置文件的实现 + async fn validate_config_internal(config_path: &str) -> Result<(bool, String)> { + // 检查程序是否正在退出,如果是则跳过验证 + if handle::Handle::global().is_exiting() { + logging!(info, Type::Validate, "应用正在退出,跳过验证"); + return Ok((true, String::new())); + } + + logging!(info, Type::Validate, "开始验证配置文件: {}", config_path); + + let clash_core = Config::verge().await.latest_arc().get_valid_clash_core(); + logging!(info, Type::Validate, "使用内核: {}", clash_core); + + let app_handle = handle::Handle::app_handle(); + let app_dir = dirs::app_home_dir()?; + 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())?.args([ + "-t", + "-d", + app_dir_str, + "-f", + config_path, + ]); + let output = command.output().await?; + + let status = &output.status; + 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); + + logging!(info, Type::Validate, "-------- 验证结果 --------"); + + if !stderr.is_empty() { + logging!(info, Type::Validate, "stderr输出:\n{:?}", stderr); + } + + if has_error { + logging!(info, Type::Validate, "发现错误,开始处理错误信息"); + let error_msg: String = if !stdout.is_empty() { + str::from_utf8(stdout).unwrap_or_default().into() + } else if !stderr.is_empty() { + str::from_utf8(stderr).unwrap_or_default().into() + } else if let Some(code) = status.code() { + format!("验证进程异常退出,退出码: {code}").into() + } else { + "验证进程被终止".into() + }; + + logging!(info, Type::Validate, "-------- 验证结束 --------"); + Ok((false, error_msg)) // 返回错误消息给调用者处理 + } else { + logging!(info, Type::Validate, "验证成功"); + logging!(info, Type::Validate, "-------- 验证结束 --------"); + Ok((true, String::new())) + } + } + + /// 验证运行时配置 + pub async fn validate_config(&self) -> Result<(bool, String)> { + if !self.try_start() { + logging!(info, Type::Validate, "验证已在进行中,跳过新的验证请求"); + return Ok((true, String::new())); + } + defer! { + self.finish(); + } + logging!(info, Type::Validate, "生成临时配置文件用于验证"); + + let config_path = Config::generate_file(ConfigType::Check).await?; + let config_path = dirs::path_to_str(&config_path)?; + Self::validate_config_internal(config_path).await + } +} + +fn has_ext>(path: P, ext: &str) -> bool { + path.as_ref() + .extension() + .and_then(|s| s.to_str()) + .map(|s| s.eq_ignore_ascii_case(ext)) + .unwrap_or(false) +} + +fn contains_any_keyword<'a>(buf: &'a [u8], keywords: &'a [&str]) -> bool { + for &kw in keywords { + let needle = kw.as_bytes(); + if needle.is_empty() { + continue; + } + let mut i = 0; + while i + needle.len() <= buf.len() { + if &buf[i..i + needle.len()] == needle { + return true; + } + i += 1; + } + } + false +} + +singleton_lazy!( + CoreConfigValidator, + CORECONFIGVALIDATOR, + CoreConfigValidator::new +); diff --git a/clash-verge-rev/src-tauri/src/enhance/chain.rs b/clash-verge-rev/src-tauri/src/enhance/chain.rs index 9f9b0e8b06..efbcf216d6 100644 --- a/clash-verge-rev/src-tauri/src/enhance/chain.rs +++ b/clash-verge-rev/src-tauri/src/enhance/chain.rs @@ -4,7 +4,8 @@ use crate::{ utils::{dirs, help}, }; use serde_yaml_ng::Mapping; -use std::fs; +use smartstring::alias::String; +use tokio::fs; #[derive(Debug, Clone)] pub struct ChainItem { @@ -22,12 +23,9 @@ pub enum ChainType { } #[derive(Debug, Clone)] -#[allow(dead_code)] pub enum ChainSupport { - Clash, ClashMeta, ClashMetaAlpha, - All, } // impl From<&PrfItem> for Option { @@ -72,11 +70,11 @@ pub trait AsyncChainItemFrom { } impl AsyncChainItemFrom for Option { - async fn from_async(item: &PrfItem) -> Option { + async fn from_async(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); + let uid = item.uid.clone().unwrap_or_else(|| "".into()); + let path = dirs::app_profiles_dir().ok()?.join(file.as_str()); if !path.exists() { return None; @@ -85,7 +83,7 @@ impl AsyncChainItemFrom for Option { match itype { "script" => Some(ChainItem { uid, - data: ChainType::Script(fs::read_to_string(path).ok()?), + data: ChainType::Script(fs::read_to_string(path).await.ok()?.into()), }), "merge" => Some(ChainItem { uid, @@ -118,22 +116,21 @@ impl AsyncChainItemFrom for Option { } impl ChainItem { /// 内建支持一些脚本 - pub fn builtin() -> Vec<(ChainSupport, ChainItem)> { + pub fn builtin() -> Vec<(ChainSupport, Self)> { // meta 的一些处理 let meta_guard = - ChainItem::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js")); + Self::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js")); // meta 1.13.2 alpn string 转 数组 - let hy_alpn = - ChainItem::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js")); + let hy_alpn = Self::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js")); // meta 的一些处理 let meta_guard_alpha = - ChainItem::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js")); + Self::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js")); // meta 1.13.2 alpn string 转 数组 let hy_alpn_alpha = - ChainItem::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js")); + Self::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js")); vec![ (ChainSupport::ClashMeta, hy_alpn), @@ -156,10 +153,7 @@ impl ChainSupport { match core { Some(core) => matches!( (self, core.as_str()), - (ChainSupport::All, _) - | (ChainSupport::Clash, "clash") - | (ChainSupport::ClashMeta, "verge-mihomo") - | (ChainSupport::ClashMetaAlpha, "verge-mihomo-alpha") + (Self::ClashMeta, "verge-mihomo") | (Self::ClashMetaAlpha, "verge-mihomo-alpha") ), None => true, } diff --git a/clash-verge-rev/src-tauri/src/enhance/field.rs b/clash-verge-rev/src-tauri/src/enhance/field.rs index 77dbeced57..b75f682fe6 100644 --- a/clash-verge-rev/src-tauri/src/enhance/field.rs +++ b/clash-verge-rev/src-tauri/src/enhance/field.rs @@ -1,4 +1,5 @@ use serde_yaml_ng::{Mapping, Value}; +use smartstring::alias::String; use std::collections::HashSet; pub const HANDLE_FIELDS: [&str; 12] = [ @@ -31,7 +32,7 @@ pub fn use_lowercase(config: Mapping) -> Mapping { if let Some(key_str) = key.as_str() { let mut key_str = String::from(key_str); key_str.make_ascii_lowercase(); - ret.insert(Value::from(key_str), value); + ret.insert(Value::from(key_str.as_str()), value); } } ret @@ -70,8 +71,8 @@ pub fn use_keys(config: &Mapping) -> Vec { config .iter() .filter_map(|(key, _)| key.as_str()) - .map(|s| { - let mut s = s.to_string(); + .map(|s: &str| { + let mut s: String = s.into(); s.make_ascii_lowercase(); s }) diff --git a/clash-verge-rev/src-tauri/src/enhance/merge.rs b/clash-verge-rev/src-tauri/src/enhance/merge.rs index 0210851d34..b93829e412 100644 --- a/clash-verge-rev/src-tauri/src/enhance/merge.rs +++ b/clash-verge-rev/src-tauri/src/enhance/merge.rs @@ -1,3 +1,5 @@ +use crate::{logging, utils::logging::Type}; + use super::use_lowercase; use serde_yaml_ng::{self, Mapping, Value}; @@ -14,12 +16,16 @@ fn deep_merge(a: &mut Value, b: &Value) { pub fn use_merge(merge: Mapping, config: Mapping) -> Mapping { let mut config = Value::from(config); - let merge = use_lowercase(merge.clone()); + let merge = use_lowercase(merge); deep_merge(&mut config, &Value::from(merge)); config.as_mapping().cloned().unwrap_or_else(|| { - log::error!("Failed to convert merged config to mapping, using empty mapping"); + logging!( + error, + Type::Core, + "Failed to convert merged config to mapping, using empty mapping" + ); Mapping::new() }) } diff --git a/clash-verge-rev/src-tauri/src/enhance/mod.rs b/clash-verge-rev/src-tauri/src/enhance/mod.rs index e3dfbfa51d..6b74b74a08 100644 --- a/clash-verge-rev/src-tauri/src/enhance/mod.rs +++ b/clash-verge-rev/src-tauri/src/enhance/mod.rs @@ -5,22 +5,95 @@ mod script; pub mod seq; mod tun; -use self::{chain::*, field::*, merge::*, script::*, seq::*, tun::*}; +use self::{ + chain::{AsyncChainItemFrom as _, ChainItem, ChainType}, + field::{use_keys, use_lowercase, use_sort}, + merge::use_merge, + script::use_script, + seq::{SeqMap, use_seq}, + tun::use_tun, +}; +use crate::constants; +use crate::utils::dirs; use crate::{config::Config, utils::tmpl}; +use crate::{logging, utils::logging::Type}; use serde_yaml_ng::Mapping; +use smartstring::alias::String; use std::collections::{HashMap, HashSet}; +use tokio::fs; type ResultLog = Vec<(String, String)>; +#[derive(Debug)] +struct ConfigValues { + clash_config: Mapping, + clash_core: Option, + enable_tun: bool, + enable_builtin: bool, + socks_enabled: bool, + http_enabled: bool, + enable_dns_settings: bool, + #[cfg(not(target_os = "windows"))] + redir_enabled: bool, + #[cfg(target_os = "linux")] + tproxy_enabled: bool, +} -/// Enhance mode -/// 返回最终订阅、该订阅包含的键、和script执行的结果 -pub async fn enhance() -> (Mapping, Vec, HashMap) { - // config.yaml 的订阅 - let clash_config = { Config::clash().await.latest_ref().0.clone() }; +#[derive(Debug)] +struct ProfileItems { + config: Mapping, + merge_item: ChainItem, + script_item: ChainItem, + rules_item: ChainItem, + proxies_item: ChainItem, + groups_item: ChainItem, + global_merge: ChainItem, + global_script: ChainItem, + profile_name: String, +} + +impl Default for ProfileItems { + fn default() -> Self { + Self { + config: Default::default(), + profile_name: Default::default(), + merge_item: ChainItem { + uid: "".into(), + data: ChainType::Merge(Mapping::new()), + }, + script_item: ChainItem { + uid: "".into(), + data: ChainType::Script(tmpl::ITEM_SCRIPT.into()), + }, + rules_item: ChainItem { + uid: "".into(), + data: ChainType::Rules(SeqMap::default()), + }, + proxies_item: ChainItem { + uid: "".into(), + data: ChainType::Proxies(SeqMap::default()), + }, + groups_item: ChainItem { + uid: "".into(), + data: ChainType::Groups(SeqMap::default()), + }, + global_merge: ChainItem { + uid: "Merge".into(), + data: ChainType::Merge(Mapping::new()), + }, + global_script: ChainItem { + uid: "Script".into(), + data: ChainType::Script(tmpl::ITEM_SCRIPT.into()), + }, + } + } +} + +async fn get_config_values() -> ConfigValues { + let clash_config = { Config::clash().await.latest_arc().0.clone() }; let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = { let verge = Config::verge().await; - let verge = verge.latest_ref(); + let verge = verge.latest_arc(); ( Some(verge.get_valid_clash_core()), verge.enable_tun_mode.unwrap_or(false), @@ -30,22 +103,213 @@ pub async fn enhance() -> (Mapping, Vec, HashMap) { verge.enable_dns_settings.unwrap_or(false), ) }; + #[cfg(not(target_os = "windows"))] let redir_enabled = { let verge = Config::verge().await; - let verge = verge.latest_ref(); + let verge = verge.latest_arc(); verge.verge_redir_enabled.unwrap_or(false) }; + #[cfg(target_os = "linux")] let tproxy_enabled = { let verge = Config::verge().await; - let verge = verge.latest_ref(); + let verge = verge.latest_arc(); verge.verge_tproxy_enabled.unwrap_or(false) }; + ConfigValues { + clash_config, + clash_core, + enable_tun, + enable_builtin, + socks_enabled, + http_enabled, + enable_dns_settings, + #[cfg(not(target_os = "windows"))] + redir_enabled, + #[cfg(target_os = "linux")] + tproxy_enabled, + } +} + +#[allow(clippy::cognitive_complexity)] +async fn collect_profile_items() -> ProfileItems { // 从profiles里拿东西 - 先收集需要的数据,然后释放锁 - let ( - mut config, + let (current, merge_uid, script_uid, rules_uid, proxies_uid, groups_uid, name) = { + let current = { + let profiles = Config::profiles().await; + let profiles_clone = profiles.latest_arc(); + profiles_clone.current_mapping().await.unwrap_or_default() + }; + + let profiles = Config::profiles().await; + let profiles_ref = profiles.latest_arc(); + let current_profile_uid = match profiles_ref.get_current() { + Some(uid) => uid.clone(), + None => return ProfileItems::default(), + }; + + let current_item = match profiles_ref.get_item_arc(¤t_profile_uid) { + Some(item) => item, + None => return ProfileItems::default(), + }; + + let merge_uid = current_item + .current_merge() + .unwrap_or_else(|| "Merge".into()); + let script_uid = current_item + .current_script() + .unwrap_or_else(|| "Script".into()); + let rules_uid = current_item + .current_rules() + .unwrap_or_else(|| "Rules".into()); + let proxies_uid = current_item + .current_proxies() + .unwrap_or_else(|| "Proxies".into()); + let groups_uid = current_item + .current_groups() + .unwrap_or_else(|| "Groups".into()); + + let name = profiles_ref + .get_item(¤t_profile_uid) + .ok() + .and_then(|item| item.name.clone()) + .unwrap_or_default(); + + ( + current, + merge_uid, + script_uid, + rules_uid, + proxies_uid, + groups_uid, + name, + ) + }; + + // 现在获取具体的items,此时profiles锁已经释放 + let merge_item = { + let item = { + let profiles = Config::profiles().await; + let profiles = profiles.latest_arc(); + profiles.get_item(&merge_uid).ok().cloned() + }; + if let Some(item) = item { + >::from_async(&item).await + } else { + None + } + } + .unwrap_or_else(|| ChainItem { + uid: "".into(), + data: ChainType::Merge(Mapping::new()), + }); + + let script_item = { + let item = { + let profiles = Config::profiles().await; + let profiles = profiles.latest_arc(); + profiles.get_item(&script_uid).ok().cloned() + }; + if let Some(item) = item { + >::from_async(&item).await + } else { + None + } + } + .unwrap_or_else(|| ChainItem { + uid: "".into(), + data: ChainType::Script(tmpl::ITEM_SCRIPT.into()), + }); + + let rules_item = { + let item = { + let profiles = Config::profiles().await; + let profiles = profiles.latest_arc(); + profiles.get_item(&rules_uid).ok().cloned() + }; + if let Some(item) = item { + >::from_async(&item).await + } else { + None + } + } + .unwrap_or_else(|| ChainItem { + uid: "".into(), + data: ChainType::Rules(SeqMap::default()), + }); + + let proxies_item = { + let item = { + let profiles = Config::profiles().await; + let profiles = profiles.latest_arc(); + profiles.get_item(&proxies_uid).ok().cloned() + }; + if let Some(item) = item { + >::from_async(&item).await + } else { + None + } + } + .unwrap_or_else(|| ChainItem { + uid: "".into(), + data: ChainType::Proxies(SeqMap::default()), + }); + + let groups_item = { + let item = { + let profiles = Config::profiles().await; + let profiles = profiles.latest_arc(); + profiles.get_item(&groups_uid).ok().cloned() + }; + if let Some(item) = item { + >::from_async(&item).await + } else { + None + } + } + .unwrap_or_else(|| ChainItem { + uid: "".into(), + data: ChainType::Groups(SeqMap::default()), + }); + + let global_merge = { + let item = { + let profiles = Config::profiles().await; + let profiles = profiles.latest_arc(); + profiles.get_item("Merge").ok().cloned() + }; + if let Some(item) = item { + >::from_async(&item).await + } else { + None + } + } + .unwrap_or_else(|| ChainItem { + uid: "Merge".into(), + data: ChainType::Merge(Mapping::new()), + }); + + let global_script = { + let item = { + let profiles = Config::profiles().await; + let profiles = profiles.latest_arc(); + profiles.get_item("Script").ok().cloned() + }; + if let Some(item) = item { + >::from_async(&item).await + } else { + None + } + } + .unwrap_or_else(|| ChainItem { + uid: "Script".into(), + data: ChainType::Script(tmpl::ITEM_SCRIPT.into()), + }); + + ProfileItems { + config: current, merge_item, script_item, rules_item, @@ -53,192 +317,19 @@ pub async fn enhance() -> (Mapping, Vec, HashMap) { groups_item, global_merge, global_script, - profile_name, - ) = { - // 收集所有需要的数据,然后释放profiles锁 - let ( - current, - merge_uid, - script_uid, - rules_uid, - proxies_uid, - groups_uid, - _current_profile_uid, - name, - ) = { - // 分离async调用和数据获取,避免借用检查问题 - let current = { - let profiles = Config::profiles().await; - let profiles_clone = profiles.latest_ref().clone(); - profiles_clone.current_mapping().await.unwrap_or_default() - }; + profile_name: name, + } +} - // 重新获取锁进行其他操作 - let profiles = Config::profiles().await; - let profiles_ref = profiles.latest_ref(); +fn process_global_items( + mut config: Mapping, + global_merge: ChainItem, + global_script: ChainItem, + profile_name: String, +) -> (Mapping, Vec, HashMap) { + let mut result_map = HashMap::new(); + let mut exists_keys = use_keys(&config); - let merge_uid = profiles_ref.current_merge().unwrap_or_default(); - let script_uid = profiles_ref.current_script().unwrap_or_default(); - let rules_uid = profiles_ref.current_rules().unwrap_or_default(); - let proxies_uid = profiles_ref.current_proxies().unwrap_or_default(); - let groups_uid = profiles_ref.current_groups().unwrap_or_default(); - let current_profile_uid = profiles_ref.get_current().unwrap_or_default(); - - let name = profiles_ref - .get_item(¤t_profile_uid) - .ok() - .and_then(|item| item.name.clone()) - .unwrap_or_default(); - - ( - current, - merge_uid, - script_uid, - rules_uid, - proxies_uid, - groups_uid, - current_profile_uid, - name, - ) - }; - - // 现在获取具体的items,此时profiles锁已经释放 - let merge = { - let item = { - let profiles = Config::profiles().await; - let profiles = profiles.latest_ref(); - profiles.get_item(&merge_uid).ok().cloned() - }; - if let Some(item) = item { - >::from_async(&item).await - } else { - None - } - } - .unwrap_or_else(|| ChainItem { - uid: "".into(), - data: ChainType::Merge(Mapping::new()), - }); - - let script = { - let item = { - let profiles = Config::profiles().await; - let profiles = profiles.latest_ref(); - profiles.get_item(&script_uid).ok().cloned() - }; - if let Some(item) = item { - >::from_async(&item).await - } else { - None - } - } - .unwrap_or_else(|| ChainItem { - uid: "".into(), - data: ChainType::Script(tmpl::ITEM_SCRIPT.into()), - }); - - let rules = { - let item = { - let profiles = Config::profiles().await; - let profiles = profiles.latest_ref(); - profiles.get_item(&rules_uid).ok().cloned() - }; - if let Some(item) = item { - >::from_async(&item).await - } else { - None - } - } - .unwrap_or_else(|| ChainItem { - uid: "".into(), - data: ChainType::Rules(SeqMap::default()), - }); - - let proxies = { - let item = { - let profiles = Config::profiles().await; - let profiles = profiles.latest_ref(); - profiles.get_item(&proxies_uid).ok().cloned() - }; - if let Some(item) = item { - >::from_async(&item).await - } else { - None - } - } - .unwrap_or_else(|| ChainItem { - uid: "".into(), - data: ChainType::Proxies(SeqMap::default()), - }); - - let groups = { - let item = { - let profiles = Config::profiles().await; - let profiles = profiles.latest_ref(); - profiles.get_item(&groups_uid).ok().cloned() - }; - if let Some(item) = item { - >::from_async(&item).await - } else { - None - } - } - .unwrap_or_else(|| ChainItem { - uid: "".into(), - data: ChainType::Groups(SeqMap::default()), - }); - - let global_merge = { - let item = { - let profiles = Config::profiles().await; - let profiles = profiles.latest_ref(); - profiles.get_item(&"Merge".to_string()).ok().cloned() - }; - if let Some(item) = item { - >::from_async(&item).await - } else { - None - } - } - .unwrap_or_else(|| ChainItem { - uid: "Merge".into(), - data: ChainType::Merge(Mapping::new()), - }); - - let global_script = { - let item = { - let profiles = Config::profiles().await; - let profiles = profiles.latest_ref(); - profiles.get_item(&"Script".to_string()).ok().cloned() - }; - if let Some(item) = item { - >::from_async(&item).await - } else { - None - } - } - .unwrap_or_else(|| ChainItem { - uid: "Script".into(), - data: ChainType::Script(tmpl::ITEM_SCRIPT.into()), - }); - - ( - current, - merge, - script, - rules, - proxies, - groups, - global_merge, - global_script, - name, - ) - }; - - let mut result_map = HashMap::new(); // 保存脚本日志 - let mut exists_keys = use_keys(&config); // 保存出现过的keys - - // 全局Merge和Script if let ChainType::Merge(merge) = global_merge.data { exists_keys.extend(use_keys(&merge)); config = use_merge(merge, config.to_owned()); @@ -246,20 +337,32 @@ pub async fn enhance() -> (Mapping, Vec, HashMap) { if let ChainType::Script(script) = global_script.data { let mut logs = vec![]; - - match use_script(script, config.to_owned(), profile_name.to_owned()) { + match use_script(script, config.to_owned(), profile_name) { Ok((res_config, res_logs)) => { exists_keys.extend(use_keys(&res_config)); config = res_config; logs.extend(res_logs); } - Err(err) => logs.push(("exception".into(), err.to_string())), + Err(err) => logs.push(("exception".into(), err.to_string().into())), } - result_map.insert(global_script.uid, logs); } - // 订阅关联的Merge、Script、Rules、Proxies、Groups + (config, exists_keys, result_map) +} + +#[allow(clippy::too_many_arguments)] +fn process_profile_items( + mut config: Mapping, + mut exists_keys: Vec, + mut result_map: HashMap, + rules_item: ChainItem, + proxies_item: ChainItem, + groups_item: ChainItem, + merge_item: ChainItem, + script_item: ChainItem, + profile_name: String, +) -> (Mapping, Vec, HashMap) { if let ChainType::Rules(rules) = rules_item.data { config = use_seq(rules, config.to_owned(), "rules"); } @@ -279,26 +382,34 @@ pub async fn enhance() -> (Mapping, Vec, HashMap) { if let ChainType::Script(script) = script_item.data { let mut logs = vec![]; - - match use_script(script, config.to_owned(), profile_name.to_owned()) { + match use_script(script, config.to_owned(), profile_name) { Ok((res_config, res_logs)) => { exists_keys.extend(use_keys(&res_config)); config = res_config; logs.extend(res_logs); } - Err(err) => logs.push(("exception".into(), err.to_string())), + Err(err) => logs.push(("exception".into(), err.to_string().into())), } - result_map.insert(script_item.uid, logs); } - // 合并默认的config + (config, exists_keys, result_map) +} + +async fn merge_default_config( + mut config: Mapping, + clash_config: Mapping, + socks_enabled: bool, + http_enabled: bool, + #[cfg(not(target_os = "windows"))] redir_enabled: bool, + #[cfg(target_os = "linux")] tproxy_enabled: bool, +) -> Mapping { for (key, value) in clash_config.into_iter() { if key.as_str() == Some("tun") { - let mut tun = config.get_mut("tun").map_or(Mapping::new(), |val| { - val.as_mapping().cloned().unwrap_or(Mapping::new()) + let mut tun = config.get_mut("tun").map_or_else(Mapping::new, |val| { + val.as_mapping().cloned().unwrap_or_else(Mapping::new) }); - let patch_tun = value.as_mapping().cloned().unwrap_or(Mapping::new()); + let patch_tun = value.as_mapping().cloned().unwrap_or_else(Mapping::new); for (key, value) in patch_tun.into_iter() { tun.insert(key, value); } @@ -336,7 +447,7 @@ pub async fn enhance() -> (Mapping, Vec, HashMap) { if key.as_str() == Some("external-controller") { let enable_external_controller = Config::verge() .await - .latest_ref() + .latest_arc() .enable_external_controller .unwrap_or(false); @@ -352,66 +463,140 @@ pub async fn enhance() -> (Mapping, Vec, HashMap) { } } - // 内建脚本最后跑 + config +} + +fn apply_builtin_scripts( + mut config: Mapping, + clash_core: Option, + enable_builtin: bool, +) -> Mapping { if enable_builtin { ChainItem::builtin() .into_iter() .filter(|(s, _)| s.is_support(clash_core.as_ref())) .map(|(_, c)| c) .for_each(|item| { - log::debug!(target: "app", "run builtin script {}", item.uid); + logging!(debug, Type::Core, "run builtin script {}", item.uid); if let ChainType::Script(script) = item.data { - match use_script(script, config.to_owned(), "".to_string()) { + match use_script(script, config.to_owned(), "".into()) { Ok((res_config, _)) => { config = res_config; } Err(err) => { - log::error!(target: "app", "builtin script error `{err}`"); + logging!(error, Type::Core, "builtin script error `{err}`"); } } } }); } - config = use_tun(config, enable_tun); - config = use_sort(config); + config +} - // 应用独立的DNS配置(如果启用) - if enable_dns_settings { - use crate::utils::dirs; - use std::fs; +async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> Mapping { + if enable_dns_settings && let Ok(app_dir) = dirs::app_home_dir() { + let dns_path = app_dir.join(constants::files::DNS_CONFIG); - if let Ok(app_dir) = dirs::app_home_dir() { - let dns_path = app_dir.join("dns_config.yaml"); - - if dns_path.exists() - && let Ok(dns_yaml) = fs::read_to_string(&dns_path) - && let Ok(dns_config) = serde_yaml_ng::from_str::(&dns_yaml) + if dns_path.exists() + && let Ok(dns_yaml) = fs::read_to_string(&dns_path).await + && let Ok(dns_config) = serde_yaml_ng::from_str::(&dns_yaml) + { + if let Some(hosts_value) = dns_config.get("hosts") + && hosts_value.is_mapping() { - // 处理hosts配置 - if let Some(hosts_value) = dns_config.get("hosts") - && hosts_value.is_mapping() - { - config.insert("hosts".into(), hosts_value.clone()); - log::info!(target: "app", "apply hosts configuration"); - } + config.insert("hosts".into(), hosts_value.clone()); + logging!(info, Type::Core, "apply hosts configuration"); + } - if let Some(dns_value) = dns_config.get("dns") { - if let Some(dns_mapping) = dns_value.as_mapping() { - config.insert("dns".into(), dns_mapping.clone().into()); - log::info!(target: "app", "apply dns_config.yaml (dns section)"); - } - } else { - config.insert("dns".into(), dns_config.into()); - log::info!(target: "app", "apply dns_config.yaml"); + if let Some(dns_value) = dns_config.get("dns") { + if let Some(dns_mapping) = dns_value.as_mapping() { + config.insert("dns".into(), dns_mapping.clone().into()); + logging!(info, Type::Core, "apply dns_config.yaml (dns section)"); } + } else { + config.insert("dns".into(), dns_config.into()); + logging!(info, Type::Core, "apply dns_config.yaml"); } } } + config +} + +/// Enhance mode +/// 返回最终订阅、该订阅包含的键、和script执行的结果 +pub async fn enhance() -> (Mapping, Vec, HashMap) { + // gather config values + let cfg_vals = get_config_values().await; + let ConfigValues { + clash_config, + clash_core, + enable_tun, + enable_builtin, + socks_enabled, + http_enabled, + enable_dns_settings, + #[cfg(not(target_os = "windows"))] + redir_enabled, + #[cfg(target_os = "linux")] + tproxy_enabled, + } = cfg_vals; + + // collect profile items + let profile = collect_profile_items().await; + let config = profile.config; + let merge_item = profile.merge_item; + let script_item = profile.script_item; + let rules_item = profile.rules_item; + let proxies_item = profile.proxies_item; + let groups_item = profile.groups_item; + let global_merge = profile.global_merge; + let global_script = profile.global_script; + let profile_name = profile.profile_name; + + // process globals + let (config, exists_keys, result_map) = + process_global_items(config, global_merge, global_script, profile_name.clone()); + + // process profile-specific items + let (config, exists_keys, result_map) = process_profile_items( + config, + exists_keys, + result_map, + rules_item, + proxies_item, + groups_item, + merge_item, + script_item, + profile_name, + ); + + // merge default clash config + let config = merge_default_config( + config, + clash_config, + socks_enabled, + http_enabled, + #[cfg(not(target_os = "windows"))] + redir_enabled, + #[cfg(target_os = "linux")] + tproxy_enabled, + ) + .await; + + // builtin scripts + let mut config = apply_builtin_scripts(config, clash_core, enable_builtin); + + config = use_tun(config, enable_tun); + config = use_sort(config); + + // dns settings + config = apply_dns_settings(config, enable_dns_settings).await; + let mut exists_set = HashSet::new(); exists_set.extend(exists_keys); - exists_keys = exists_set.into_iter().collect(); + let exists_keys: Vec = exists_set.into_iter().collect(); (config, exists_keys, result_map) } diff --git a/clash-verge-rev/src-tauri/src/enhance/script.rs b/clash-verge-rev/src-tauri/src/enhance/script.rs index 42a968aafe..799c19dc37 100644 --- a/clash-verge-rev/src-tauri/src/enhance/script.rs +++ b/clash-verge-rev/src-tauri/src/enhance/script.rs @@ -1,6 +1,7 @@ use super::use_lowercase; use anyhow::{Error, Result}; use serde_yaml_ng::Mapping; +use smartstring::alias::String; pub fn use_script( script: String, @@ -44,7 +45,7 @@ pub fn use_script( ) })?; let mut out = copy_outputs.borrow_mut(); - out.push((level, data)); + out.push((level.into(), data.into())); Ok(JsValue::undefined()) }, ), @@ -61,7 +62,7 @@ pub fn use_script( });"#, )); - let config = use_lowercase(config.clone()); + let config = use_lowercase(config); let config_str = serde_json::to_string(&config)?; // 仅处理 name 参数中的特殊字符 @@ -94,7 +95,7 @@ pub fn use_script( match res { Ok(config) => Ok((use_lowercase(config), out.to_vec())), Err(err) => { - out.push(("exception".into(), err.to_string())); + out.push(("exception".into(), err.to_string().into())); Ok((config, out.to_vec())) } } @@ -121,7 +122,7 @@ fn strip_outer_quotes(s: &str) -> &str { // 转义单引号和反斜杠,用于单引号包裹的JavaScript字符串 fn escape_js_string_for_single_quote(s: &str) -> String { - s.replace('\\', "\\\\").replace('\'', "\\'") + s.replace('\\', "\\\\").replace('\'', "\\'").into() } #[test] @@ -150,7 +151,7 @@ fn test_script() { "; let config = serde_yaml_ng::from_str(config).expect("Failed to parse test config YAML"); - let (config, results) = use_script(script.into(), config, "".to_string()) + let (config, results) = use_script(script.into(), config, "".into()) .expect("Script execution should succeed in test"); let _ = serde_yaml_ng::to_string(&config).expect("Failed to serialize config to YAML"); diff --git a/clash-verge-rev/src-tauri/src/enhance/tun.rs b/clash-verge-rev/src-tauri/src/enhance/tun.rs index d019960a25..dc78dc2685 100644 --- a/clash-verge-rev/src-tauri/src/enhance/tun.rs +++ b/clash-verge-rev/src-tauri/src/enhance/tun.rs @@ -24,36 +24,16 @@ macro_rules! append { pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping { let tun_key = Value::from("tun"); let tun_val = config.get(&tun_key); - let mut tun_val = tun_val.map_or(Mapping::new(), |val| { - val.as_mapping().cloned().unwrap_or(Mapping::new()) + let mut tun_val = tun_val.map_or_else(Mapping::new, |val| { + val.as_mapping().cloned().unwrap_or_else(Mapping::new) }); if enable { - #[cfg(target_os = "linux")] - { - let stack_key = Value::from("stack"); - let should_override = match tun_val.get(&stack_key) { - Some(value) => value - .as_str() - .map(|stack| stack.eq_ignore_ascii_case("gvisor")) - .unwrap_or(false), - None => true, - }; - - if should_override { - revise!(tun_val, "stack", "mixed"); - log::warn!( - target: "app", - "gVisor TUN stack detected on Linux; falling back to 'mixed' for compatibility" - ); - } - } - // 读取DNS配置 let dns_key = Value::from("dns"); let dns_val = config.get(&dns_key); - let mut dns_val = dns_val.map_or(Mapping::new(), |val| { - val.as_mapping().cloned().unwrap_or(Mapping::new()) + let mut dns_val = dns_val.map_or_else(Mapping::new, |val| { + val.as_mapping().cloned().unwrap_or_else(Mapping::new) }); let ipv6_key = Value::from("ipv6"); let ipv6_val = config diff --git a/clash-verge-rev/src-tauri/src/feat/backup.rs b/clash-verge-rev/src-tauri/src/feat/backup.rs index f5c9687e64..055b1d6804 100644 --- a/clash-verge-rev/src-tauri/src/feat/backup.rs +++ b/clash-verge-rev/src-tauri/src/feat/backup.rs @@ -1,17 +1,33 @@ use crate::{ config::{Config, IVerge}, core::backup, - logging_error, - utils::{dirs::app_home_dir, logging::Type}, + logging, logging_error, + process::AsyncHandler, + utils::{ + dirs::{PathBufExec as _, app_home_dir, local_backup_dir}, + logging::Type, + }, }; -use anyhow::Result; +use anyhow::{Result, anyhow}; +use chrono::Utc; use reqwest_dav::list_cmd::ListFile; -use std::fs; +use serde::Serialize; +use smartstring::alias::String; +use std::path::PathBuf; +use tokio::fs; + +#[derive(Debug, Serialize)] +pub struct LocalBackupFile { + pub filename: String, + pub path: String, + pub last_modified: String, + pub content_length: u64, +} /// 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().map_err(|err| { - log::error!(target: "app", "Failed to create backup: {err:#?}"); + let (file_name, temp_file_path) = backup::create_backup().await.map_err(|err| { + logging!(error, Type::Backup, "Failed to create backup: {err:#?}"); err })?; @@ -19,14 +35,14 @@ pub async fn create_backup_and_upload_webdav() -> Result<()> { .upload(temp_file_path.clone(), file_name) .await { - log::error!(target: "app", "Failed to upload to WebDAV: {err:#?}"); + logging!(error, Type::Backup, "Failed to upload to WebDAV: {err:#?}"); // 上传失败时重置客户端缓存 backup::WebDavClient::global().reset(); return Err(err); } - if let Err(err) = std::fs::remove_file(&temp_file_path) { - log::warn!(target: "app", "Failed to remove temp file: {err:#?}"); + if let Err(err) = temp_file_path.remove_if_exists().await { + logging!(warn, Type::Backup, "Failed to remove temp file: {err:#?}"); } Ok(()) @@ -35,7 +51,11 @@ pub async fn create_backup_and_upload_webdav() -> Result<()> { /// List WebDAV backups pub async fn list_wevdav_backup() -> Result> { backup::WebDavClient::global().list().await.map_err(|err| { - log::error!(target: "app", "Failed to list WebDAV backup files: {err:#?}"); + logging!( + error, + Type::Backup, + "Failed to list WebDAV backup files: {err:#?}" + ); err }) } @@ -46,7 +66,11 @@ pub async fn delete_webdav_backup(filename: String) -> Result<()> { .delete(filename) .await .map_err(|err| { - log::error!(target: "app", "Failed to delete WebDAV backup file: {err:#?}"); + logging!( + error, + Type::Backup, + "Failed to delete WebDAV backup file: {err:#?}" + ); err }) } @@ -54,29 +78,35 @@ 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_ref().clone(); + let verge_data = verge.latest_arc(); let webdav_url = verge_data.webdav_url.clone(); let webdav_username = verge_data.webdav_username.clone(); let webdav_password = verge_data.webdav_password.clone(); let backup_storage_path = app_home_dir() .map_err(|e| anyhow::anyhow!("Failed to get app home dir: {e}"))? - .join(&filename); + .join(filename.as_str()); backup::WebDavClient::global() .download(filename, backup_storage_path.clone()) .await .map_err(|err| { - log::error!(target: "app", "Failed to download WebDAV backup file: {err:#?}"); + logging!( + error, + Type::Backup, + "Failed to download WebDAV backup file: {err:#?}" + ); err })?; // extract zip file - let mut zip = zip::ZipArchive::new(fs::File::open(backup_storage_path.clone())?)?; + 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)?; zip.extract(app_home_dir()?)?; logging_error!( Type::Backup, super::patch_verge( - IVerge { + &IVerge { webdav_url, webdav_username, webdav_password, @@ -87,6 +117,185 @@ pub async fn restore_webdav_backup(filename: String) -> Result<()> { .await ); // 最后删除临时文件 - fs::remove_file(backup_storage_path)?; + backup_storage_path.remove_if_exists().await?; + Ok(()) +} + +/// 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 + .map(|_| ()) +} + +pub async fn create_local_backup_with_namer(namer: F) -> Result +where + F: FnOnce(&str) -> String, +{ + let (file_name, temp_file_path) = backup::create_backup().await.map_err(|err| { + logging!( + error, + Type::Backup, + "Failed to create local backup: {err:#?}" + ); + err + })?; + + let backup_dir = local_backup_dir()?; + let final_name = namer(file_name.as_str()); + let target_path = backup_dir.join(final_name.as_str()); + + 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, + Type::Backup, + "Failed to remove temp backup file after move error: {clean_err:#?}" + ); + } + return Err(err); + } + + Ok(final_name) +} + +async fn move_file(from: PathBuf, to: PathBuf) -> Result<()> { + if let Some(parent) = to.parent() { + fs::create_dir_all(parent).await?; + } + + match fs::rename(&from, &to).await { + Ok(_) => Ok(()), + Err(rename_err) => { + // Attempt copy + remove as fallback, covering cross-device moves + logging!( + warn, + Type::Backup, + "Failed to rename backup file directly, fallback to copy/remove: {rename_err:#?}" + ); + fs::copy(&from, &to) + .await + .map_err(|err| anyhow!("Failed to copy backup file: {err:#?}"))?; + fs::remove_file(&from) + .await + .map_err(|err| anyhow!("Failed to remove temp backup file: {err:#?}"))?; + Ok(()) + } + } +} + +/// List local backups +pub async fn list_local_backup() -> Result> { + let backup_dir = local_backup_dir()?; + if !backup_dir.exists() { + return Ok(vec![]); + } + + let mut backups = Vec::new(); + let mut dir = fs::read_dir(&backup_dir).await?; + while let Some(entry) = dir.next_entry().await? { + let path = entry.path(); + let metadata = entry.metadata().await?; + if !metadata.is_file() { + continue; + } + + let file_name = match path.file_name().and_then(|name| name.to_str()) { + Some(name) => name, + None => continue, + }; + let last_modified = metadata + .modified() + .map(|time| chrono::DateTime::::from(time).to_rfc3339()) + .unwrap_or_default(); + backups.push(LocalBackupFile { + filename: file_name.into(), + path: path.to_string_lossy().into(), + last_modified: last_modified.into(), + content_length: metadata.len(), + }); + } + + backups.sort_by(|a, b| b.filename.cmp(&a.filename)); + 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()); + if !target_path.exists() { + logging!( + warn, + Type::Backup, + "Local backup file not found: {}", + filename + ); + return Ok(()); + } + target_path.remove_if_exists().await?; + 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()); + if !target_path.exists() { + return Err(anyhow!("Backup file not found: {}", filename)); + } + + let (webdav_url, webdav_username, webdav_password) = { + let verge = Config::verge().await; + let verge = verge.latest_arc(); + ( + verge.webdav_url.clone(), + verge.webdav_username.clone(), + verge.webdav_password.clone(), + ) + }; + + let file = AsyncHandler::spawn_blocking(move || std::fs::File::open(&target_path)).await??; + let mut zip = zip::ZipArchive::new(file)?; + zip.extract(app_home_dir()?)?; + logging_error!( + Type::Backup, + super::patch_verge( + &IVerge { + webdav_url, + webdav_username, + webdav_password, + ..IVerge::default() + }, + false + ) + .await + ); + 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()); + if !source_path.exists() { + return Err(anyhow!("Backup file not found: {}", filename)); + } + + let dest_path = PathBuf::from(destination.as_str()); + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent).await?; + } + + fs::copy(&source_path, &dest_path) + .await + .map(|_| ()) + .map_err(|err| anyhow!("Failed to export backup file: {err:#?}"))?; Ok(()) } diff --git a/clash-verge-rev/src-tauri/src/feat/clash.rs b/clash-verge-rev/src-tauri/src/feat/clash.rs index 4bb474fd56..43e76157a7 100644 --- a/clash-verge-rev/src-tauri/src/feat/clash.rs +++ b/clash-verge-rev/src-tauri/src/feat/clash.rs @@ -1,11 +1,12 @@ use crate::{ config::Config, core::{CoreManager, handle, tray}, - logging_error, + logging, logging_error, process::AsyncHandler, utils::{self, logging::Type, resolve}, }; use serde_yaml_ng::{Mapping, Value}; +use smartstring::alias::String; /// Restart the Clash core pub async fn restart_clash_core() { @@ -16,7 +17,7 @@ pub async fn restart_clash_core() { } Err(err) => { handle::Handle::notice_message("set_config::error", format!("{err}")); - log::error!(target:"app", "{err}"); + logging!(error, Type::Core, "{err}"); } } } @@ -29,7 +30,7 @@ pub async fn restart_app() { "restart_app::error", format!("Failed to cleanup resources: {err}"), ); - log::error!(target:"app", "Restart failed during cleanup: {err}"); + logging!(error, Type::Core, "Restart failed during cleanup: {err}"); return; } @@ -46,10 +47,11 @@ fn after_change_clash_mode() { for connection in connections_array { let _ = mihomo.close_connection(&connection.id).await; } + drop(mihomo); } } Err(err) => { - log::error!(target: "app", "Failed to get connections: {err}"); + logging!(error, Type::Core, "Failed to get connections: {err}"); } } }); @@ -58,12 +60,12 @@ fn after_change_clash_mode() { /// Change Clash mode (rule/global/direct/script) pub async fn change_clash_mode(mode: String) { let mut mapping = Mapping::new(); - mapping.insert(Value::from("mode"), mode.clone().into()); + mapping.insert(Value::from("mode"), Value::from(mode.as_str())); // Convert YAML mapping to JSON Value let json_value = serde_json::json!({ "mode": mode }); - log::debug!(target: "app", "change clash mode to {mode}"); + logging!(debug, Type::Core, "change clash mode to {mode}"); match handle::Handle::mihomo() .await .patch_base_config(&json_value) @@ -71,26 +73,33 @@ pub async fn change_clash_mode(mode: String) { { Ok(_) => { // 更新订阅 - Config::clash().await.data_mut().patch_config(mapping); + Config::clash() + .await + .edit_draft(|d| d.patch_config(mapping)); // 分离数据获取和异步调用 - let clash_data = Config::clash().await.data_mut().clone(); + let clash_data = Config::clash().await.data_arc(); if clash_data.save_config().await.is_ok() { handle::Handle::refresh_clash(); logging_error!(Type::Tray, tray::Tray::global().update_menu().await); - logging_error!(Type::Tray, tray::Tray::global().update_icon().await); + logging_error!( + Type::Tray, + tray::Tray::global() + .update_icon(&Config::verge().await.data_arc()) + .await + ); } let is_auto_close_connection = Config::verge() .await - .data_mut() + .data_arc() .auto_close_connection .unwrap_or(false); if is_auto_close_connection { after_change_clash_mode(); } } - Err(err) => log::error!(target: "app", "{err}"), + Err(err) => logging!(error, Type::Core, "{err}"), } } @@ -101,7 +110,7 @@ pub async fn test_delay(url: String) -> anyhow::Result { let tun_mode = Config::verge() .await - .latest_ref() + .latest_arc() .enable_tun_mode .unwrap_or(false); @@ -112,7 +121,7 @@ pub async fn test_delay(url: String) -> anyhow::Result { ProxyType::None }; - let user_agent = Some("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0".to_string()); + let user_agent = Some("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0".into()); let start = Instant::now(); @@ -122,7 +131,7 @@ pub async fn test_delay(url: String) -> anyhow::Result { match response { Ok(response) => { - log::trace!(target: "app", "test_delay response: {response:#?}"); + logging!(trace, Type::Network, "test_delay response: {response:#?}"); if response.status().is_success() { Ok(start.elapsed().as_millis() as u32) } else { @@ -130,7 +139,7 @@ pub async fn test_delay(url: String) -> anyhow::Result { } } Err(err) => { - log::trace!(target: "app", "test_delay error: {err:#?}"); + logging!(trace, Type::Network, "test_delay error: {err:#?}"); Err(err) } } diff --git a/clash-verge-rev/src-tauri/src/feat/config.rs b/clash-verge-rev/src-tauri/src/feat/config.rs index 506f5fa6dd..cfa9aef00f 100644 --- a/clash-verge-rev/src-tauri/src/feat/config.rs +++ b/clash-verge-rev/src-tauri/src/feat/config.rs @@ -2,8 +2,8 @@ use crate::{ config::{Config, IVerge}, core::{CoreManager, handle, hotkey, sysopt, tray}, logging_error, - module::lightweight, - utils::logging::Type, + module::{auto_backup::AutoBackupManager, lightweight}, + utils::{draft::SharedBox, logging::Type}, }; use anyhow::Result; use serde_yaml_ng::Mapping; @@ -12,8 +12,7 @@ use serde_yaml_ng::Mapping; pub async fn patch_clash(patch: Mapping) -> Result<()> { Config::clash() .await - .draft_mut() - .patch_config(patch.clone()); + .edit_draft(|d| d.patch_config(patch.clone())); let res = { // 激活订阅 @@ -23,9 +22,16 @@ pub async fn patch_clash(patch: Mapping) -> Result<()> { } else { if patch.get("mode").is_some() { logging_error!(Type::Tray, tray::Tray::global().update_menu().await); - logging_error!(Type::Tray, tray::Tray::global().update_icon().await); + logging_error!( + Type::Tray, + tray::Tray::global() + .update_icon(&Config::verge().await.data_arc()) + .await + ); } - Config::runtime().await.draft_mut().patch_config(patch); + Config::runtime() + .await + .edit_draft(|d| d.patch_config(patch)); CoreManager::global().update_config().await?; } handle::Handle::refresh_clash(); @@ -35,7 +41,7 @@ pub async fn patch_clash(patch: Mapping) -> Result<()> { Ok(()) => { Config::clash().await.apply(); // 分离数据获取和异步调用 - let clash_data = Config::clash().await.data_mut().clone(); + let clash_data = Config::clash().await.data_arc(); clash_data.save_config().await?; Ok(()) } @@ -63,23 +69,19 @@ enum UpdateFlags { LighteWeight = 1 << 10, } -/// Patch Verge configuration -pub async fn patch_verge(patch: IVerge, not_save_file: bool) -> Result<()> { - Config::verge() - .await - .draft_mut() - .patch_config(patch.clone()); +fn determine_update_flags(patch: &IVerge) -> i32 { + let mut update_flags: i32 = UpdateFlags::None as i32; let tun_mode = patch.enable_tun_mode; let auto_launch = patch.enable_auto_launch; let system_proxy = patch.enable_system_proxy; let pac = patch.proxy_auto_config; - let pac_content = patch.pac_file_content; - let proxy_bypass = patch.system_proxy_bypass; - let language = patch.language; + let pac_content = &patch.pac_file_content; + let proxy_bypass = &patch.system_proxy_bypass; + let language = &patch.language; let mixed_port = patch.verge_mixed_port; #[cfg(target_os = "macos")] - let tray_icon = patch.tray_icon; + let tray_icon = &patch.tray_icon; #[cfg(not(target_os = "macos"))] let tray_icon: Option = None; let common_tray_icon = patch.common_tray_icon; @@ -98,147 +100,171 @@ pub async fn patch_verge(patch: IVerge, not_save_file: bool) -> Result<()> { let http_enabled = patch.verge_http_enabled; let http_port = patch.verge_port; let enable_tray_speed = patch.enable_tray_speed; - let enable_tray_icon = patch.enable_tray_icon; + // let enable_tray_icon = patch.enable_tray_icon; let enable_global_hotkey = patch.enable_global_hotkey; - let tray_event = patch.tray_event; + let tray_event = &patch.tray_event; let home_cards = patch.home_cards.clone(); let enable_auto_light_weight = patch.enable_auto_light_weight_mode; let enable_external_controller = patch.enable_external_controller; - let res: std::result::Result<(), anyhow::Error> = { - // Initialize with no flags set - let mut update_flags: i32 = UpdateFlags::None as i32; + let tray_inline_proxy_groups = patch.tray_inline_proxy_groups; - if tun_mode.is_some() { - update_flags |= UpdateFlags::ClashConfig as i32; - update_flags |= UpdateFlags::SystrayMenu as i32; - update_flags |= UpdateFlags::SystrayTooltip as i32; - update_flags |= UpdateFlags::SystrayIcon as i32; - } - if enable_global_hotkey.is_some() || home_cards.is_some() { - update_flags |= UpdateFlags::VergeConfig as i32; - } - #[cfg(not(target_os = "windows"))] - if redir_enabled.is_some() || redir_port.is_some() { - update_flags |= UpdateFlags::RestartCore as i32; - } - #[cfg(target_os = "linux")] - if tproxy_enabled.is_some() || tproxy_port.is_some() { - update_flags |= UpdateFlags::RestartCore as i32; - } - if socks_enabled.is_some() - || http_enabled.is_some() - || socks_port.is_some() - || http_port.is_some() - || mixed_port.is_some() - { - update_flags |= UpdateFlags::RestartCore as i32; - } - if auto_launch.is_some() { - update_flags |= UpdateFlags::Launch as i32; - } + if tun_mode.is_some() { + update_flags |= UpdateFlags::ClashConfig as i32; + update_flags |= UpdateFlags::SystrayMenu as i32; + update_flags |= UpdateFlags::SystrayTooltip as i32; + update_flags |= UpdateFlags::SystrayIcon as i32; + } + if enable_global_hotkey.is_some() || home_cards.is_some() { + update_flags |= UpdateFlags::VergeConfig as i32; + } + #[cfg(not(target_os = "windows"))] + if redir_enabled.is_some() || redir_port.is_some() { + update_flags |= UpdateFlags::RestartCore as i32; + } + #[cfg(target_os = "linux")] + if tproxy_enabled.is_some() || tproxy_port.is_some() { + update_flags |= UpdateFlags::RestartCore as i32; + } + if socks_enabled.is_some() + || http_enabled.is_some() + || socks_port.is_some() + || http_port.is_some() + || mixed_port.is_some() + { + update_flags |= UpdateFlags::RestartCore as i32; + } + if auto_launch.is_some() { + update_flags |= UpdateFlags::Launch as i32; + } - if system_proxy.is_some() { - update_flags |= UpdateFlags::SysProxy as i32; - update_flags |= UpdateFlags::SystrayMenu as i32; - update_flags |= UpdateFlags::SystrayTooltip as i32; - update_flags |= UpdateFlags::SystrayIcon as i32; - } + if system_proxy.is_some() { + update_flags |= UpdateFlags::SysProxy as i32; + update_flags |= UpdateFlags::SystrayMenu as i32; + update_flags |= UpdateFlags::SystrayTooltip as i32; + update_flags |= UpdateFlags::SystrayIcon as i32; + } - if proxy_bypass.is_some() || pac_content.is_some() || pac.is_some() { - update_flags |= UpdateFlags::SysProxy as i32; - } + if proxy_bypass.is_some() || pac_content.is_some() || pac.is_some() { + update_flags |= UpdateFlags::SysProxy as i32; + } - if language.is_some() { - update_flags |= UpdateFlags::SystrayMenu as i32; - } - if common_tray_icon.is_some() - || sysproxy_tray_icon.is_some() - || tun_tray_icon.is_some() - || tray_icon.is_some() - || enable_tray_speed.is_some() - || enable_tray_icon.is_some() - { - update_flags |= UpdateFlags::SystrayIcon as i32; - } + if language.is_some() { + update_flags |= UpdateFlags::SystrayMenu as i32; + } + if common_tray_icon.is_some() + || sysproxy_tray_icon.is_some() + || tun_tray_icon.is_some() + || tray_icon.is_some() + || enable_tray_speed.is_some() + // || enable_tray_icon.is_some() + { + update_flags |= UpdateFlags::SystrayIcon as i32; + } - if patch.hotkeys.is_some() { - update_flags |= UpdateFlags::Hotkey as i32; - update_flags |= UpdateFlags::SystrayMenu as i32; - } + if patch.hotkeys.is_some() { + update_flags |= UpdateFlags::Hotkey as i32; + update_flags |= UpdateFlags::SystrayMenu as i32; + } - if tray_event.is_some() { - update_flags |= UpdateFlags::SystrayClickBehavior as i32; - } + if tray_event.is_some() { + update_flags |= UpdateFlags::SystrayClickBehavior as i32; + } - if enable_auto_light_weight.is_some() { - update_flags |= UpdateFlags::LighteWeight as i32; - } + if enable_auto_light_weight.is_some() { + update_flags |= UpdateFlags::LighteWeight as i32; + } - // 处理 external-controller 的开关 - if enable_external_controller.is_some() { - update_flags |= UpdateFlags::RestartCore as i32; - } + if enable_external_controller.is_some() { + update_flags |= UpdateFlags::RestartCore as i32; + } - // Process updates based on flags - if (update_flags & (UpdateFlags::RestartCore as i32)) != 0 { - Config::generate().await?; - CoreManager::global().restart_core().await?; - } - if (update_flags & (UpdateFlags::ClashConfig as i32)) != 0 { - CoreManager::global().update_config().await?; - handle::Handle::refresh_clash(); - } - if (update_flags & (UpdateFlags::VergeConfig as i32)) != 0 { - Config::verge().await.draft_mut().enable_global_hotkey = enable_global_hotkey; - handle::Handle::refresh_verge(); - } - if (update_flags & (UpdateFlags::Launch as i32)) != 0 { - sysopt::Sysopt::global().update_launch().await?; - } - if (update_flags & (UpdateFlags::SysProxy as i32)) != 0 { - sysopt::Sysopt::global().update_sysproxy().await?; - } - if (update_flags & (UpdateFlags::Hotkey as i32)) != 0 - && let Some(hotkeys) = patch.hotkeys - { - hotkey::Hotkey::global().update(hotkeys).await?; - } - if (update_flags & (UpdateFlags::SystrayMenu as i32)) != 0 { - tray::Tray::global().update_menu().await?; - } - if (update_flags & (UpdateFlags::SystrayIcon as i32)) != 0 { - tray::Tray::global().update_icon().await?; - } - if (update_flags & (UpdateFlags::SystrayTooltip as i32)) != 0 { - tray::Tray::global().update_tooltip().await?; - } - if (update_flags & (UpdateFlags::SystrayClickBehavior as i32)) != 0 { - tray::Tray::global().update_click_behavior().await?; - } - if (update_flags & (UpdateFlags::LighteWeight as i32)) != 0 { - if enable_auto_light_weight.unwrap_or(false) { - lightweight::enable_auto_light_weight_mode().await; - } else { - lightweight::disable_auto_light_weight_mode(); - } - } + if tray_inline_proxy_groups.is_some() { + update_flags |= UpdateFlags::SystrayMenu as i32; + } - >::Ok(()) - }; - match res { - Ok(()) => { - Config::verge().await.apply(); - if !not_save_file { - // 分离数据获取和异步调用 - let verge_data = Config::verge().await.data_mut().clone(); - verge_data.save_file().await?; - } + update_flags +} - Ok(()) - } - Err(err) => { - Config::verge().await.discard(); - Err(err) +#[allow(clippy::cognitive_complexity)] +async fn process_terminated_flags(update_flags: i32, patch: &IVerge) -> Result<()> { + // Process updates based on flags + if (update_flags & (UpdateFlags::RestartCore as i32)) != 0 { + Config::generate().await?; + CoreManager::global().restart_core().await?; + } + if (update_flags & (UpdateFlags::ClashConfig as i32)) != 0 { + CoreManager::global().update_config().await?; + handle::Handle::refresh_clash(); + } + if (update_flags & (UpdateFlags::VergeConfig as i32)) != 0 { + Config::verge() + .await + .edit_draft(|d| d.enable_global_hotkey = patch.enable_global_hotkey); + handle::Handle::refresh_verge(); + } + if (update_flags & (UpdateFlags::Launch as i32)) != 0 { + sysopt::Sysopt::global().update_launch().await?; + } + if (update_flags & (UpdateFlags::SysProxy as i32)) != 0 { + sysopt::Sysopt::global().update_sysproxy().await?; + } + if (update_flags & (UpdateFlags::Hotkey as i32)) != 0 + && let Some(hotkeys) = &patch.hotkeys + { + hotkey::Hotkey::global().update(hotkeys.to_owned()).await?; + } + if (update_flags & (UpdateFlags::SystrayMenu as i32)) != 0 { + tray::Tray::global().update_menu().await?; + } + if (update_flags & (UpdateFlags::SystrayIcon as i32)) != 0 { + tray::Tray::global() + .update_icon(&Config::verge().await.latest_arc()) + .await?; + } + if (update_flags & (UpdateFlags::SystrayTooltip as i32)) != 0 { + tray::Tray::global().update_tooltip().await?; + } + if (update_flags & (UpdateFlags::SystrayClickBehavior as i32)) != 0 { + tray::Tray::global().update_click_behavior().await?; + } + if (update_flags & (UpdateFlags::LighteWeight as i32)) != 0 { + if patch.enable_auto_light_weight_mode.unwrap_or(false) { + lightweight::enable_auto_light_weight_mode().await; + } else { + lightweight::disable_auto_light_weight_mode(); } } + Ok(()) +} + +pub async fn patch_verge(patch: &IVerge, not_save_file: bool) -> Result<()> { + Config::verge().await.edit_draft(|d| d.patch_config(patch)); + + let update_flags = determine_update_flags(patch); + let process_flag_result: std::result::Result<(), anyhow::Error> = { + process_terminated_flags(update_flags, patch).await?; + Ok(()) + }; + + if let Err(err) = process_flag_result { + Config::verge().await.discard(); + return Err(err); + } + Config::verge().await.apply(); + logging_error!( + Type::Backup, + AutoBackupManager::global().refresh_settings().await + ); + if !not_save_file { + // 分离数据获取和异步调用 + let verge_data = Config::verge().await.data_arc(); + verge_data.save_file().await?; + } + Ok(()) +} + +pub async fn fetch_verge_config() -> Result> { + let draft = Config::verge().await; + let data = draft.data_arc(); + Ok(data) } diff --git a/clash-verge-rev/src-tauri/src/feat/profile.rs b/clash-verge-rev/src-tauri/src/feat/profile.rs index 3728a067ff..83446aeb9d 100644 --- a/clash-verge-rev/src-tauri/src/feat/profile.rs +++ b/clash-verge-rev/src-tauri/src/feat/profile.rs @@ -2,161 +2,256 @@ use crate::{ cmd, config::{Config, PrfItem, PrfOption, profiles::profiles_draft_update_item_safe}, core::{CoreManager, handle, tray}, - logging, + logging, logging_error, utils::logging::Type, }; use anyhow::{Result, bail}; +use smartstring::alias::String; +use tauri::Emitter as _; /// Toggle proxy profile pub async fn toggle_proxy_profile(profile_index: String) { - match cmd::patch_profiles_config_by_profile_index(profile_index).await { + logging_error!( + Type::Config, + cmd::patch_profiles_config_by_profile_index(profile_index).await + ); +} + +pub async fn switch_proxy_node(group_name: &str, proxy_name: &str) { + match handle::Handle::mihomo() + .await + .select_node_for_group(group_name, proxy_name) + .await + { Ok(_) => { - let result = tray::Tray::global().update_menu().await; - if let Err(err) = result { - logging!(error, Type::Tray, "更新菜单失败: {}", err); - } + logging!( + info, + Type::Tray, + "切换代理成功: {} -> {}", + group_name, + proxy_name + ); + let _ = handle::Handle::app_handle().emit("verge://refresh-proxy-config", ()); + let _ = tray::Tray::global().update_menu().await; + return; } Err(err) => { - log::error!(target: "app", "{err}"); + logging!( + error, + Type::Tray, + "切换代理失败: {} -> {}, 错误: {:?}", + group_name, + proxy_name, + err + ); + } + } + + match handle::Handle::mihomo() + .await + .select_node_for_group(group_name, proxy_name) + .await + { + Ok(_) => { + logging!( + info, + Type::Tray, + "代理切换回退成功: {} -> {}", + group_name, + proxy_name + ); + let _ = tray::Tray::global().update_menu().await; + } + Err(err) => { + logging!( + error, + Type::Tray, + "代理切换最终失败: {} -> {}, 错误: {:?}", + group_name, + proxy_name, + err + ); } } } -/// Update a profile -/// If updating current profile, activate it -/// auto_refresh: 是否自动更新配置和刷新前端 +async fn should_update_profile( + uid: &String, + ignore_auto_update: bool, +) -> Result)>> { + let profiles = Config::profiles().await; + let profiles = profiles.latest_arc(); + let item = profiles.get_item(uid)?; + let is_remote = item.itype.as_ref().is_some_and(|s| s == "remote"); + + if !is_remote { + logging!( + info, + Type::Config, + "[订阅更新] {uid} 不是远程订阅,跳过更新" + ); + Ok(None) + } else if item.url.is_none() { + logging!( + warn, + Type::Config, + "Warning: [订阅更新] {uid} 缺少URL,无法更新" + ); + bail!("failed to get the profile item url"); + } else if !ignore_auto_update + && !item + .option + .as_ref() + .and_then(|o| o.allow_auto_update) + .unwrap_or(true) + { + logging!( + info, + Type::Config, + "[订阅更新] {} 禁止自动更新,跳过更新", + uid + ); + Ok(None) + } else { + logging!( + info, + Type::Config, + "[订阅更新] {} 是远程订阅,URL: {}", + uid, + item.url + .clone() + .ok_or_else(|| anyhow::anyhow!("Profile URL is None"))? + ); + Ok(Some(( + item.url + .clone() + .ok_or_else(|| anyhow::anyhow!("Profile URL is None"))?, + item.option.clone(), + ))) + } +} + +async fn perform_profile_update( + uid: &String, + url: &String, + opt: Option<&PrfOption>, + option: Option<&PrfOption>, +) -> Result { + logging!(info, Type::Config, "[订阅更新] 开始下载新的订阅内容"); + let mut merged_opt = PrfOption::merge(opt, option); + let is_current = { + let profiles = Config::profiles().await; + profiles.latest_arc().is_current_profile_index(uid) + }; + let profiles = Config::profiles().await; + let profiles_arc = profiles.latest_arc(); + let profile_name = profiles_arc + .get_name_by_uid(uid) + .cloned() + .unwrap_or_else(|| String::from("UnKown Profile")); + + let mut last_err; + + match PrfItem::from_url(url, None, None, merged_opt.as_ref()).await { + Ok(mut item) => { + logging!(info, Type::Config, "[订阅更新] 更新订阅配置成功"); + profiles_draft_update_item_safe(uid, &mut item).await?; + return Ok(is_current); + } + Err(err) => { + logging!( + warn, + Type::Config, + "Warning: [订阅更新] 正常更新失败: {err},尝试使用Clash代理更新" + ); + last_err = err; + } + } + + merged_opt.get_or_insert_with(PrfOption::default).self_proxy = Some(true); + merged_opt.get_or_insert_with(PrfOption::default).with_proxy = Some(false); + + match PrfItem::from_url(url, None, None, merged_opt.as_ref()).await { + Ok(mut item) => { + logging!( + info, + Type::Config, + "[订阅更新] 使用 Clash代理 更新订阅配置成功" + ); + profiles_draft_update_item_safe(uid, &mut item).await?; + handle::Handle::notice_message("update_with_clash_proxy", profile_name); + drop(last_err); + return Ok(is_current); + } + Err(err) => { + logging!( + warn, + Type::Config, + "Warning: [订阅更新] 正常更新失败: {err},尝试使用Clash代理更新" + ); + last_err = err; + } + } + + merged_opt.get_or_insert_with(PrfOption::default).self_proxy = Some(false); + merged_opt.get_or_insert_with(PrfOption::default).with_proxy = Some(true); + + match PrfItem::from_url(url, None, None, merged_opt.as_ref()).await { + Ok(mut item) => { + logging!( + info, + Type::Config, + "[订阅更新] 使用 系统代理 更新订阅配置成功" + ); + profiles_draft_update_item_safe(uid, &mut item).await?; + handle::Handle::notice_message("update_with_clash_proxy", profile_name); + drop(last_err); + return Ok(is_current); + } + Err(err) => { + logging!( + warn, + Type::Config, + "Warning: [订阅更新] 正常更新失败: {err},尝试使用系统代理更新" + ); + last_err = err; + } + } + + handle::Handle::notice_message( + "update_failed_even_with_clash", + format!("{profile_name} - {last_err}"), + ); + Ok(is_current) +} + pub async fn update_profile( - uid: String, - option: Option, - auto_refresh: Option, + uid: &String, + option: Option<&PrfOption>, + auto_refresh: bool, + ignore_auto_update: bool, ) -> Result<()> { logging!(info, Type::Config, "[订阅更新] 开始更新订阅 {}", uid); - let auto_refresh = auto_refresh.unwrap_or(true); // 默认为true,保持兼容性 + let url_opt = should_update_profile(uid, ignore_auto_update).await?; - let url_opt = { - let profiles = Config::profiles().await; - let profiles = profiles.latest_ref(); - let item = profiles.get_item(&uid)?; - let is_remote = item.itype.as_ref().is_some_and(|s| s == "remote"); - - if !is_remote { - log::info!(target: "app", "[订阅更新] {uid} 不是远程订阅,跳过更新"); - None // 非远程订阅直接更新 - } else if item.url.is_none() { - log::warn!(target: "app", "[订阅更新] {uid} 缺少URL,无法更新"); - bail!("failed to get the profile item url"); - } else { - log::info!(target: "app", - "[订阅更新] {} 是远程订阅,URL: {}", - uid, - item.url.clone().ok_or_else(|| anyhow::anyhow!("Profile URL is None"))? - ); - Some(( - item.url - .clone() - .ok_or_else(|| anyhow::anyhow!("Profile URL is None"))?, - item.option.clone(), - )) - } - }; - - let should_update = match url_opt { + let should_refresh = match url_opt { Some((url, opt)) => { - log::info!(target: "app", "[订阅更新] 开始下载新的订阅内容"); - let merged_opt = PrfOption::merge(opt.clone(), option.clone()); - - // 尝试使用正常设置更新 - match PrfItem::from_url(&url, None, None, merged_opt.clone()).await { - Ok(item) => { - log::info!(target: "app", "[订阅更新] 更新订阅配置成功"); - let profiles = Config::profiles().await; - - // 使用Send-safe helper函数 - let result = profiles_draft_update_item_safe(uid.clone(), item).await; - result?; - - let is_current = Some(uid.clone()) == profiles.latest_ref().get_current(); - log::info!(target: "app", "[订阅更新] 是否为当前使用的订阅: {is_current}"); - is_current && auto_refresh - } - Err(err) => { - // 首次更新失败,尝试使用Clash代理 - log::warn!(target: "app", "[订阅更新] 正常更新失败: {err},尝试使用Clash代理更新"); - - // 发送通知 - handle::Handle::notice_message("update_retry_with_clash", uid.clone()); - - // 保存原始代理设置 - let original_with_proxy = merged_opt.as_ref().and_then(|o| o.with_proxy); - let original_self_proxy = merged_opt.as_ref().and_then(|o| o.self_proxy); - - // 创建使用Clash代理的选项 - let mut fallback_opt = merged_opt.unwrap_or_default(); - fallback_opt.with_proxy = Some(false); - fallback_opt.self_proxy = Some(true); - - // 使用Clash代理重试 - match PrfItem::from_url(&url, None, None, Some(fallback_opt)).await { - Ok(mut item) => { - log::info!(target: "app", "[订阅更新] 使用Clash代理更新成功"); - - // 恢复原始代理设置到item - if let Some(option) = item.option.as_mut() { - option.with_proxy = original_with_proxy; - option.self_proxy = original_self_proxy; - } - - // 更新到配置 - let profiles = Config::profiles().await; - - // 使用 Send-safe 方法进行数据操作 - profiles_draft_update_item_safe(uid.clone(), item.clone()).await?; - - // 获取配置名称用于通知 - let profile_name = item.name.clone().unwrap_or_else(|| uid.clone()); - - // 发送通知告知用户自动更新使用了回退机制 - handle::Handle::notice_message("update_with_clash_proxy", profile_name); - - let is_current = Some(uid.clone()) == profiles.data_ref().get_current(); - log::info!(target: "app", "[订阅更新] 是否为当前使用的订阅: {is_current}"); - is_current && auto_refresh - } - Err(retry_err) => { - log::error!(target: "app", "[订阅更新] 使用Clash代理更新仍然失败: {retry_err}"); - handle::Handle::notice_message( - "update_failed_even_with_clash", - format!("{retry_err}"), - ); - return Err(retry_err); - } - } - } - } + perform_profile_update(uid, &url, opt.as_ref(), option).await? && auto_refresh } None => auto_refresh, }; - if should_update { + if should_refresh { logging!(info, Type::Config, "[订阅更新] 更新内核配置"); match CoreManager::global().update_config().await { Ok(_) => { logging!(info, Type::Config, "[订阅更新] 更新成功"); handle::Handle::refresh_clash(); - // if let Err(err) = cmd::proxy::force_refresh_proxies().await { - // logging!( - // error, - // Type::Config, - // true, - // "[订阅更新] 代理组刷新失败: {}", - // err - // ); - // } } Err(err) => { logging!(error, Type::Config, "[订阅更新] 更新失败: {}", err); handle::Handle::notice_message("update_failed", format!("{err}")); - log::error!(target: "app", "{err}"); + logging!(error, Type::Config, "{err}"); } } } diff --git a/clash-verge-rev/src-tauri/src/feat/proxy.rs b/clash-verge-rev/src-tauri/src/feat/proxy.rs index 7d520a8971..69d4e03395 100644 --- a/clash-verge-rev/src-tauri/src/feat/proxy.rs +++ b/clash-verge-rev/src-tauri/src/feat/proxy.rs @@ -1,35 +1,32 @@ use crate::{ config::{Config, IVerge}, core::handle, + logging, + utils::logging::Type, }; use std::env; -use tauri_plugin_clipboard_manager::ClipboardExt; +use tauri_plugin_clipboard_manager::ClipboardExt as _; /// Toggle system proxy on/off pub async fn toggle_system_proxy() { - // 获取当前系统代理状态 - let enable = { - let verge = Config::verge().await; - - verge.latest_ref().enable_system_proxy.unwrap_or(false) - }; - // 获取自动关闭连接设置 - let auto_close_connection = { - let verge = Config::verge().await; - - verge.latest_ref().auto_close_connection.unwrap_or(false) - }; + let verge = Config::verge().await; + let enable = verge.latest_arc().enable_system_proxy.unwrap_or(false); + let auto_close_connection = verge.latest_arc().auto_close_connection.unwrap_or(false); // 如果当前系统代理即将关闭,且自动关闭连接设置为true,则关闭所有连接 if enable && auto_close_connection && let Err(err) = handle::Handle::mihomo().await.close_all_connections().await { - log::error!(target: "app", "Failed to close all connections: {err}"); + logging!( + error, + Type::ProxyMode, + "Failed to close all connections: {err}" + ); } let patch_result = super::patch_verge( - IVerge { + &IVerge { enable_system_proxy: Some(!enable), ..IVerge::default() }, @@ -39,17 +36,17 @@ pub async fn toggle_system_proxy() { match patch_result { Ok(_) => handle::Handle::refresh_verge(), - Err(err) => log::error!(target: "app", "{err}"), + Err(err) => logging!(error, Type::ProxyMode, "{err}"), } } /// Toggle TUN mode on/off pub async fn toggle_tun_mode(not_save_file: Option) { - let enable = Config::verge().await.data_mut().enable_tun_mode; + let enable = Config::verge().await.latest_arc().enable_tun_mode; let enable = enable.unwrap_or(false); match super::patch_verge( - IVerge { + &IVerge { enable_tun_mode: Some(!enable), ..IVerge::default() }, @@ -58,7 +55,7 @@ pub async fn toggle_tun_mode(not_save_file: Option) { .await { Ok(_) => handle::Handle::refresh_verge(), - Err(err) => log::error!(target: "app", "{err}"), + Err(err) => logging!(error, Type::ProxyMode, "{err}"), } } @@ -66,20 +63,20 @@ pub async fn toggle_tun_mode(not_save_file: Option) { pub async fn copy_clash_env() { // 从环境变量获取IP地址,如果没有则从配置中获取 proxy_host,默认为 127.0.0.1 let clash_verge_rev_ip = match env::var("CLASH_VERGE_REV_IP") { - Ok(ip) => ip, + Ok(ip) => ip.into(), Err(_) => Config::verge() .await - .latest_ref() + .latest_arc() .proxy_host .clone() - .unwrap_or_else(|| "127.0.0.1".to_string()), + .unwrap_or_else(|| "127.0.0.1".into()), }; let app_handle = handle::Handle::app_handle(); let port = { Config::verge() .await - .latest_ref() + .latest_arc() .verge_mixed_port .unwrap_or(7897) }; @@ -87,7 +84,7 @@ pub async fn copy_clash_env() { let socks5_proxy = format!("socks5://{clash_verge_rev_ip}:{port}"); let cliboard = app_handle.clipboard(); - let env_type = { Config::verge().await.latest_ref().env_type.clone() }; + let env_type = { Config::verge().await.latest_arc().env_type.clone() }; let env_type = match env_type { Some(env_type) => env_type, None => { @@ -96,7 +93,7 @@ pub async fn copy_clash_env() { #[cfg(target_os = "windows")] let default = "powershell"; - default.to_string() + default.into() } }; @@ -113,12 +110,16 @@ pub async fn copy_clash_env() { } "fish" => format!("set -x http_proxy {http_proxy}; set -x https_proxy {http_proxy}"), _ => { - log::error!(target: "app", "copy_clash_env: Invalid env type! {env_type}"); + logging!( + error, + Type::ProxyMode, + "copy_clash_env: Invalid env type! {env_type}" + ); return; } }; if cliboard.write_text(export_text).is_err() { - log::error!(target: "app", "Failed to write to clipboard"); + logging!(error, Type::ProxyMode, "Failed to write to clipboard"); } } diff --git a/clash-verge-rev/src-tauri/src/feat/window.rs b/clash-verge-rev/src-tauri/src/feat/window.rs index 2717e040fb..876ba44322 100644 --- a/clash-verge-rev/src-tauri/src/feat/window.rs +++ b/clash-verge-rev/src-tauri/src/feat/window.rs @@ -14,7 +14,7 @@ pub async fn open_or_close_dashboard() { async fn open_or_close_dashboard_internal() { let _ = lightweight::exit_lightweight_mode().await; let result = WindowManager::toggle_main_window().await; - log::info!(target: "app", "Window toggle result: {result:?}"); + logging!(info, Type::Window, "Window toggle result: {result:?}"); } pub async fn quit() { @@ -26,12 +26,6 @@ pub async fn quit() { handle::Handle::global().set_is_exiting(); EventDrivenProxyManager::global().notify_app_stopping(); - // 优先关闭窗口,提供立即反馈 - if let Some(window) = handle::Handle::get_window() { - let _ = window.hide(); - log::info!(target: "app", "窗口已隐藏"); - } - logging!(info, Type::System, "开始异步清理资源"); let cleanup_result = clean_async().await; @@ -44,26 +38,27 @@ pub async fn quit() { app_handle.exit(if cleanup_result { 0 } else { 1 }); } -async fn clean_async() -> bool { +pub async fn clean_async() -> bool { use tokio::time::{Duration, timeout}; logging!(info, Type::System, "开始执行异步清理操作..."); // 1. 处理TUN模式 - let tun_success = if Config::verge() - .await - .data_mut() - .enable_tun_mode - .unwrap_or(false) - { - let disable_tun = serde_json::json!({"tun": {"enable": false}}); - #[cfg(target_os = "windows")] - let tun_timeout = Duration::from_secs(2); - #[cfg(not(target_os = "windows"))] - let tun_timeout = Duration::from_secs(2); + let tun_task = async { + let tun_enabled = Config::verge() + .await + .data_arc() + .enable_tun_mode + .unwrap_or(false); + + if !tun_enabled { + return true; + } + + let disable_tun = serde_json::json!({ "tun": { "enable": false } }); match timeout( - tun_timeout, + Duration::from_millis(1000), handle::Handle::mihomo() .await .patch_base_config(&disable_tun), @@ -71,22 +66,23 @@ async fn clean_async() -> bool { .await { Ok(Ok(_)) => { - log::info!(target: "app", "TUN模式已禁用"); - tokio::time::sleep(Duration::from_millis(300)).await; + logging!(info, Type::Window, "TUN模式已禁用"); true } Ok(Err(e)) => { - log::warn!(target: "app", "禁用TUN模式失败: {e}"); + logging!(warn, Type::Window, "Warning: 禁用TUN模式失败: {e}"); // 超时不阻塞退出 true } Err(_) => { - log::warn!(target: "app", "禁用TUN模式超时(可能系统正在关机),继续退出流程"); + logging!( + warn, + Type::Window, + "Warning: 禁用TUN模式超时(可能系统正在关机),继续退出流程" + ); true } } - } else { - true }; // 2. 系统代理重置 @@ -99,12 +95,12 @@ async fn clean_async() -> bool { // 检查系统代理是否开启 let sys_proxy_enabled = Config::verge() .await - .latest_ref() + .data_arc() .enable_system_proxy .unwrap_or(false); if !sys_proxy_enabled { - log::info!(target: "app", "系统代理未启用,跳过重置"); + logging!(info, Type::Window, "系统代理未启用,跳过重置"); return true; } @@ -113,19 +109,23 @@ async fn clean_async() -> bool { if is_shutting_down { // sysproxy-rs 操作注册表(避免.exe的dll错误) - log::info!(target: "app", "检测到正在关机,syspro-rs操作注册表关闭系统代理"); + logging!( + info, + Type::Window, + "检测到正在关机,syspro-rs操作注册表关闭系统代理" + ); match Sysproxy::get_system_proxy() { Ok(mut sysproxy) => { sysproxy.enable = false; if let Err(e) = sysproxy.set_system_proxy() { - log::warn!(target: "app", "关机时关闭系统代理失败: {e}"); + logging!(warn, Type::Window, "Warning: 关机时关闭系统代理失败: {e}"); } else { - log::info!(target: "app", "系统代理已关闭(通过注册表)"); + logging!(info, Type::Window, "系统代理已关闭(通过注册表)"); } } Err(e) => { - log::warn!(target: "app", "关机时获取代理设置失败: {e}"); + logging!(warn, Type::Window, "Warning: 关机时获取代理设置失败: {e}"); } } @@ -139,7 +139,7 @@ async fn clean_async() -> bool { } // 正常退出:使用 sysproxy.exe 重置代理 - log::info!(target: "app", "sysproxy.exe重置系统代理"); + logging!(info, Type::Window, "sysproxy.exe重置系统代理"); match timeout( Duration::from_secs(2), @@ -148,15 +148,19 @@ async fn clean_async() -> bool { .await { Ok(Ok(_)) => { - log::info!(target: "app", "系统代理已重置"); + logging!(info, Type::Window, "系统代理已重置"); true } Ok(Err(e)) => { - log::warn!(target: "app", "重置系统代理失败: {e}"); + logging!(warn, Type::Window, "Warning: 重置系统代理失败: {e}"); true } Err(_) => { - log::warn!(target: "app", "重置系统代理超时,继续退出流程"); + logging!( + warn, + Type::Window, + "Warning: 重置系统代理超时,继续退出流程" + ); true } } @@ -167,16 +171,16 @@ async fn clean_async() -> bool { { let sys_proxy_enabled = Config::verge() .await - .latest_ref() + .data_arc() .enable_system_proxy .unwrap_or(false); if !sys_proxy_enabled { - log::info!(target: "app", "系统代理未启用,跳过重置"); + logging!(info, Type::Window, "系统代理未启用,跳过重置"); return true; } - log::info!(target: "app", "开始重置系统代理..."); + logging!(info, Type::Window, "开始重置系统代理..."); match timeout( Duration::from_millis(1500), @@ -185,15 +189,15 @@ async fn clean_async() -> bool { .await { Ok(Ok(_)) => { - log::info!(target: "app", "系统代理已重置"); + logging!(info, Type::Window, "系统代理已重置"); true } Ok(Err(e)) => { - log::warn!(target: "app", "重置系统代理失败: {e}"); + logging!(warn, Type::Window, "Warning: 重置系统代理失败: {e}"); true } Err(_) => { - log::warn!(target: "app", "重置系统代理超时,继续退出"); + logging!(warn, Type::Window, "Warning: 重置系统代理超时,继续退出"); true } } @@ -209,11 +213,15 @@ async fn clean_async() -> bool { match timeout(stop_timeout, CoreManager::global().stop_core()).await { Ok(_) => { - log::info!(target: "app", "core已停止"); + logging!(info, Type::Window, "core已停止"); true } Err(_) => { - log::warn!(target: "app", "停止core超时(可能系统正在关机),继续退出"); + logging!( + warn, + Type::Window, + "Warning: 停止core超时(可能系统正在关机),继续退出" + ); true } } @@ -229,23 +237,22 @@ async fn clean_async() -> bool { .await { Ok(_) => { - log::info!(target: "app", "DNS设置已恢复"); + logging!(info, Type::Window, "DNS设置已恢复"); true } Err(_) => { - log::warn!(target: "app", "恢复DNS设置超时"); + logging!(warn, Type::Window, "Warning: 恢复DNS设置超时"); false } } }; - // 并行执行剩余清理任务 - let (proxy_success, core_success) = tokio::join!(proxy_task, core_task); - - #[cfg(target_os = "macos")] - let dns_success = dns_task.await; #[cfg(not(target_os = "macos"))] - let dns_success = true; + let dns_task = async { true }; + + let tun_success = tun_task.await; + // 并行执行清理任务 + let (proxy_success, core_success, dns_success) = tokio::join!(proxy_task, core_task, dns_task); let all_success = tun_success && proxy_success && core_success && dns_success; @@ -304,7 +311,7 @@ pub async fn hide() { let enable_auto_light_weight_mode = Config::verge() .await - .data_mut() + .data_arc() .enable_auto_light_weight_mode .unwrap_or(false); diff --git a/clash-verge-rev/src-tauri/src/lib.rs b/clash-verge-rev/src-tauri/src/lib.rs index f50ffe5ee7..c3b374ca03 100644 --- a/clash-verge-rev/src-tauri/src/lib.rs +++ b/clash-verge-rev/src-tauri/src/lib.rs @@ -3,56 +3,44 @@ mod cmd; pub mod config; +mod constants; mod core; mod enhance; mod feat; mod module; mod process; -mod utils; -#[cfg(target_os = "macos")] -use crate::utils::window_manager::WindowManager; +pub mod utils; +use crate::constants::files; +#[cfg(target_os = "linux")] +use crate::utils::linux; use crate::{ - core::{EventDrivenProxyManager, handle, hotkey}, + core::{EventDrivenProxyManager, handle}, process::AsyncHandler, utils::{resolve, server}, }; -use config::Config; +use anyhow::Result; use once_cell::sync::OnceCell; -use tauri::{AppHandle, Manager}; +use rust_i18n::i18n; +use tauri::{AppHandle, Manager as _}; #[cfg(target_os = "macos")] use tauri_plugin_autostart::MacosLauncher; -use tauri_plugin_deep_link::DeepLinkExt; -use tokio::time::{Duration, timeout}; +use tauri_plugin_deep_link::DeepLinkExt as _; use utils::logging::Type; -pub static APP_HANDLE: OnceCell = OnceCell::new(); +i18n!("locales", fallback = "zh"); +pub static APP_HANDLE: OnceCell = OnceCell::new(); /// Application initialization helper functions mod app_init { use super::*; /// Initialize singleton monitoring for other instances - pub fn init_singleton_check() { - AsyncHandler::spawn_blocking(move || async move { + pub fn init_singleton_check() -> Result<()> { + AsyncHandler::block_on(async move { logging!(info, Type::Setup, "开始检查单例实例..."); - match timeout(Duration::from_millis(500), server::check_singleton()).await { - Ok(result) => { - if result.is_err() { - logging!(info, Type::Setup, "检测到已有应用实例运行"); - if let Some(app_handle) = APP_HANDLE.get() { - app_handle.exit(0); - } else { - std::process::exit(0); - } - } else { - logging!(info, Type::Setup, "未检测到其他应用实例"); - } - } - Err(_) => { - logging!(warn, Type::Setup, "单例检查超时,假定没有其他实例运行"); - } - } - }); + server::check_singleton().await?; + Ok(()) + }) } /// Setup plugins for the Tauri builder @@ -73,6 +61,14 @@ mod app_init { tauri_plugin_mihomo::Builder::new() .protocol(tauri_plugin_mihomo::models::Protocol::LocalSocket) .socket_path(crate::config::IClashTemp::guard_external_controller_ipc()) + .pool_config( + tauri_plugin_mihomo::IpcPoolConfigBuilder::new() + .min_connections(0) + .max_connections(20) + .idle_timeout(std::time::Duration::from_millis(500)) + .health_check_interval(std::time::Duration::from_secs(10)) + .build(), + ) .build(), ); @@ -94,14 +90,14 @@ mod app_init { } app.deep_link().on_open_url(|event| { - let url = event.urls().first().map(|u| u.to_string()); - if let Some(url) = url { - AsyncHandler::spawn(|| async { - if let Err(e) = resolve::resolve_scheme(url).await { - logging!(error, Type::Setup, "Failed to resolve scheme: {}", e); - } - }); - } + let urls = event.urls(); + AsyncHandler::spawn(move || async move { + if let Some(url) = urls.first() + && let Err(e) = resolve::resolve_scheme(url.as_ref()).await + { + logging!(error, Type::Setup, "Failed to resolve scheme: {}", e); + } + }); }); Ok(()) @@ -118,7 +114,7 @@ mod app_init { { auto_start_plugin_builder = auto_start_plugin_builder .macos_launcher(MacosLauncher::LaunchAgent) - .app_name(app.config().identifier.clone()); + .app_name(&app.config().identifier); } app.handle().plugin(auto_start_plugin_builder.build())?; Ok(()) @@ -128,49 +124,44 @@ mod app_init { pub fn setup_window_state(app: &tauri::App) -> Result<(), Box> { logging!(info, Type::Setup, "初始化窗口状态管理..."); let window_state_plugin = tauri_plugin_window_state::Builder::new() - .with_filename("window_state.json") + .with_filename(files::WINDOW_STATE) .with_state_flags(tauri_plugin_window_state::StateFlags::default()) .build(); app.handle().plugin(window_state_plugin)?; Ok(()) } - /// Generate all command handlers for the application pub fn generate_handlers() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static { tauri::generate_handler![ - // Common commands cmd::get_sys_proxy, cmd::get_auto_proxy, cmd::open_app_dir, cmd::open_logs_dir, cmd::open_web_url, cmd::open_core_dir, + cmd::open_app_log, + cmd::open_core_log, cmd::get_portable_flag, cmd::get_network_interfaces, cmd::get_system_hostname, cmd::restart_app, - // Core management cmd::start_core, cmd::stop_core, cmd::restart_core, - // Application lifecycle cmd::notify_ui_ready, cmd::update_ui_stage, cmd::get_running_mode, cmd::get_app_uptime, cmd::get_auto_launch_status, cmd::is_admin, - // Lightweight mode cmd::entry_lightweight_mode, cmd::exit_lightweight_mode, - // Service management cmd::install_service, cmd::uninstall_service, cmd::reinstall_service, cmd::repair_service, cmd::is_service_available, - // Clash core commands cmd::get_clash_info, cmd::patch_clash_config, cmd::patch_clash_mode, @@ -190,7 +181,6 @@ mod app_init { cmd::get_dns_config_content, cmd::validate_dns_config, cmd::get_clash_logs, - // Verge configuration cmd::get_verge_config, cmd::patch_verge_config, cmd::test_delay, @@ -200,7 +190,6 @@ mod app_init { cmd::open_devtools, cmd::exit_app, cmd::get_network_interfaces_info, - // Profile management cmd::get_profiles, cmd::enhance_profiles, cmd::patch_profiles_config, @@ -214,19 +203,20 @@ mod app_init { cmd::read_profile_file, cmd::save_profile_file, cmd::get_next_update_time, - // Script validation cmd::script_validate_notice, cmd::validate_script_file, - // Backup and WebDAV + cmd::create_local_backup, + cmd::list_local_backup, + cmd::delete_local_backup, + cmd::restore_local_backup, + cmd::export_local_backup, cmd::create_webdav_backup, cmd::save_webdav_config, cmd::list_webdav_backup, cmd::delete_webdav_backup, cmd::restore_webdav_backup, - // Diagnostics and system info cmd::export_diagnostic_info, cmd::get_system_info, - // Media unlock checker cmd::get_unlock_items, cmd::check_media_unlock, ] @@ -234,98 +224,15 @@ mod app_init { } pub fn run() { - // Setup singleton check - app_init::init_singleton_check(); + if app_init::init_singleton_check().is_err() { + return; + } let _ = utils::dirs::init_portable_flag(); - // Set Linux environment variable #[cfg(target_os = "linux")] - { - let desktop_env = std::env::var("XDG_CURRENT_DESKTOP") - .unwrap_or_default() - .to_uppercase(); - let session_desktop = std::env::var("XDG_SESSION_DESKTOP") - .unwrap_or_default() - .to_uppercase(); - let desktop_session = std::env::var("DESKTOP_SESSION") - .unwrap_or_default() - .to_uppercase(); - let is_kde_desktop = desktop_env.contains("KDE"); - let is_plasma_desktop = desktop_env.contains("PLASMA"); - let is_hyprland_desktop = desktop_env.contains("HYPR") - || session_desktop.contains("HYPR") - || desktop_session.contains("HYPR"); + linux::configure_environment(); - let is_wayland_session = std::env::var("XDG_SESSION_TYPE") - .map(|value| value.eq_ignore_ascii_case("wayland")) - .unwrap_or(false) - || std::env::var("WAYLAND_DISPLAY").is_ok(); - let prefer_native_wayland = - is_wayland_session && (is_kde_desktop || is_plasma_desktop || is_hyprland_desktop); - let dmabuf_override = std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER"); - - if prefer_native_wayland { - let compositor_label = if is_hyprland_desktop { - "Hyprland" - } else if is_plasma_desktop { - "KDE Plasma" - } else { - "KDE" - }; - - if matches!(dmabuf_override.as_deref(), Ok("1")) { - unsafe { - std::env::remove_var("WEBKIT_DISABLE_DMABUF_RENDERER"); - } - logging!( - info, - Type::Setup, - "Wayland + {} detected: Re-enabled WebKit DMABUF renderer to avoid Cairo surface failures.", - compositor_label - ); - } else { - logging!( - info, - Type::Setup, - "Wayland + {} detected: Using native Wayland backend for reliable rendering.", - compositor_label - ); - } - } else { - if dmabuf_override.is_err() { - unsafe { - std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); - } - } - - // Force X11 backend for tray icon compatibility on Wayland - if is_wayland_session { - unsafe { - std::env::set_var("GDK_BACKEND", "x11"); - std::env::remove_var("WAYLAND_DISPLAY"); - } - logging!( - info, - Type::Setup, - "Wayland detected: Forcing X11 backend for tray icon compatibility" - ); - } - } - - if is_kde_desktop || is_plasma_desktop { - unsafe { - std::env::set_var("GTK_CSD", "0"); - } - logging!( - info, - Type::Setup, - "KDE detected: Disabled GTK CSD for better titlebar stability." - ); - } - } - - // Create and configure the Tauri builder let builder = app_init::setup_plugins(tauri::Builder::default()) .setup(|app| { logging!(info, Type::Setup, "开始应用初始化..."); @@ -335,88 +242,73 @@ pub fn run() { .set(app.app_handle().clone()) .expect("failed to set global app handle"); - // Setup autostart plugin if let Err(e) = app_init::setup_autostart(app) { logging!(error, Type::Setup, "Failed to setup autostart: {}", e); } - // Setup deep links if let Err(e) = app_init::setup_deep_links(app) { logging!(error, Type::Setup, "Failed to setup deep links: {}", e); } - // Setup window state management if let Err(e) = app_init::setup_window_state(app) { logging!(error, Type::Setup, "Failed to setup window state: {}", e); } - logging!(info, Type::Setup, "执行主要设置操作..."); - resolve::resolve_setup_handle(); resolve::resolve_setup_async(); resolve::resolve_setup_sync(); - logging!(info, Type::Setup, "初始化完成,继续执行"); + logging!(info, Type::Setup, "初始化已启动"); Ok(()) }) .invoke_handler(app_init::generate_handlers()); - /// Event handling helper functions mod event_handlers { - use crate::core::handle; + #[cfg(target_os = "macos")] + use crate::module::lightweight; + #[cfg(target_os = "macos")] + use crate::utils::window_manager::WindowManager; + use crate::{ + config::Config, + core::{self, handle, hotkey}, + logging, + process::AsyncHandler, + utils::logging::Type, + }; + use tauri::AppHandle; + #[cfg(target_os = "macos")] + use tauri::Manager as _; - use super::*; - - /// Handle application ready/resumed events pub fn handle_ready_resumed(_app_handle: &AppHandle) { - // 双重检查:确保不在退出状态 if handle::Handle::global().is_exiting() { - logging!( - debug, - Type::System, - "handle_ready_resumed: 应用正在退出,跳过处理" - ); + logging!(debug, Type::System, "应用正在退出,跳过处理"); return; } - logging!(info, Type::System, "应用就绪或恢复"); + logging!(info, Type::System, "应用就绪"); handle::Handle::global().init(); #[cfg(target_os = "macos")] - { - if let Some(window) = _app_handle.get_webview_window("main") { - logging!(info, Type::Window, "设置macOS窗口标题"); - let _ = window.set_title("Clash Verge"); - } + if let Some(window) = _app_handle.get_webview_window("main") { + let _ = window.set_title("Clash Verge"); } } - /// Handle application reopen events (macOS) #[cfg(target_os = "macos")] pub async fn handle_reopen(has_visible_windows: bool) { - logging!( - info, - Type::System, - "处理 macOS 应用重新打开事件: has_visible_windows={}", - has_visible_windows - ); - handle::Handle::global().init(); + if lightweight::is_in_lightweight_mode() { + lightweight::exit_lightweight_mode().await; + return; + } + if !has_visible_windows { - // 当没有可见窗口时,设置为 regular 模式并显示主窗口 handle::Handle::global().set_activation_policy_regular(); - - logging!(info, Type::System, "没有可见窗口,尝试显示主窗口"); - - let result = WindowManager::show_main_window().await; - logging!(info, Type::System, "窗口显示操作完成,结果: {:?}", result); - } else { - logging!(info, Type::System, "已有可见窗口,无需额外操作"); + let _ = WindowManager::show_main_window().await; } } - /// Handle window close requests pub fn handle_window_close(api: &tauri::WindowEvent) { #[cfg(target_os = "macos")] handle::Handle::global().set_activation_policy_accessory(); @@ -425,23 +317,19 @@ pub fn run() { return; } - log::info!(target: "app", "closing window..."); if let tauri::WindowEvent::CloseRequested { api, .. } = api { api.prevent_close(); if let Some(window) = core::handle::Handle::get_window() { let _ = window.hide(); - } else { - logging!(warn, Type::Window, "尝试隐藏窗口但窗口不存在"); } } } - /// Handle window focus events pub fn handle_window_focus(focused: bool) { AsyncHandler::spawn(move || async move { let is_enable_global_hotkey = Config::verge() .await - .latest_ref() + .data_arc() .enable_global_hotkey .unwrap_or(true); @@ -449,80 +337,62 @@ pub fn run() { #[cfg(target_os = "macos")] { use crate::core::hotkey::SystemHotkey; - if let Err(e) = hotkey::Hotkey::global() + let _ = hotkey::Hotkey::global() .register_system_hotkey(SystemHotkey::CmdQ) - .await - { - logging!(error, Type::Hotkey, "Failed to register CMD+Q: {}", e); - } - if let Err(e) = hotkey::Hotkey::global() + .await; + let _ = hotkey::Hotkey::global() .register_system_hotkey(SystemHotkey::CmdW) - .await - { - logging!(error, Type::Hotkey, "Failed to register CMD+W: {}", e); - } - } - - if !is_enable_global_hotkey - && let Err(e) = hotkey::Hotkey::global().init().await - { - logging!(error, Type::Hotkey, "Failed to init hotkeys: {}", e); + .await; } + let _ = hotkey::Hotkey::global().init(true).await; return; } - // Handle unfocused state #[cfg(target_os = "macos")] { use crate::core::hotkey::SystemHotkey; - if let Err(e) = - hotkey::Hotkey::global().unregister_system_hotkey(SystemHotkey::CmdQ) - { - logging!(error, Type::Hotkey, "Failed to unregister CMD+Q: {}", e); - } - if let Err(e) = - hotkey::Hotkey::global().unregister_system_hotkey(SystemHotkey::CmdW) - { - logging!(error, Type::Hotkey, "Failed to unregister CMD+W: {}", e); - } + let _ = hotkey::Hotkey::global().unregister_system_hotkey(SystemHotkey::CmdQ); + let _ = hotkey::Hotkey::global().unregister_system_hotkey(SystemHotkey::CmdW); } - if !is_enable_global_hotkey && let Err(e) = hotkey::Hotkey::global().reset() { - logging!(error, Type::Hotkey, "Failed to reset hotkeys: {}", e); + if !is_enable_global_hotkey { + let _ = hotkey::Hotkey::global().reset(); } }); } - /// Handle window destroyed events + #[cfg(target_os = "macos")] pub fn handle_window_destroyed() { - #[cfg(target_os = "macos")] - { - use crate::core::hotkey::SystemHotkey; - if let Err(e) = - hotkey::Hotkey::global().unregister_system_hotkey(SystemHotkey::CmdQ) - { - logging!( - error, - Type::Hotkey, - "Failed to unregister CMD+Q on destroy: {}", - e - ); + use crate::core::hotkey::SystemHotkey; + AsyncHandler::spawn(move || async move { + let _ = hotkey::Hotkey::global().unregister_system_hotkey(SystemHotkey::CmdQ); + let _ = hotkey::Hotkey::global().unregister_system_hotkey(SystemHotkey::CmdW); + let is_enable_global_hotkey = Config::verge() + .await + .data_arc() + .enable_global_hotkey + .unwrap_or(true); + if !is_enable_global_hotkey { + let _ = hotkey::Hotkey::global().reset(); } - if let Err(e) = - hotkey::Hotkey::global().unregister_system_hotkey(SystemHotkey::CmdW) - { - logging!( - error, - Type::Hotkey, - "Failed to unregister CMD+W on destroy: {}", - e - ); - } - } + }); } } - // Build the application + #[cfg(feature = "clippy")] + let context = tauri::test::mock_context(tauri::test::noop_assets()); + #[cfg(feature = "clippy")] + let app = builder.build(context).unwrap_or_else(|e| { + logging!( + error, + Type::Setup, + "Failed to build Tauri application: {}", + e + ); + std::process::exit(1); + }); + + #[cfg(not(feature = "clippy"))] let app = builder .build(tauri::generate_context!()) .unwrap_or_else(|e| { @@ -535,87 +405,62 @@ pub fn run() { std::process::exit(1); }); - app.run(|app_handle, e| { - match e { - tauri::RunEvent::Ready | tauri::RunEvent::Resumed => { - // 如果正在退出,忽略 Ready/Resumed 事件 - if core::handle::Handle::global().is_exiting() { - logging!(debug, Type::System, "忽略 Ready/Resumed 事件,应用正在退出"); - return; - } - event_handlers::handle_ready_resumed(app_handle); + app.run(|app_handle, e| match e { + tauri::RunEvent::Ready | tauri::RunEvent::Resumed => { + if core::handle::Handle::global().is_exiting() { + return; + } + event_handlers::handle_ready_resumed(app_handle); + } + #[cfg(target_os = "macos")] + tauri::RunEvent::Reopen { + has_visible_windows, + .. + } => { + if core::handle::Handle::global().is_exiting() { + return; + } + AsyncHandler::spawn(move || async move { + event_handlers::handle_reopen(has_visible_windows).await; + }); + } + tauri::RunEvent::ExitRequested { api, code, .. } => { + AsyncHandler::block_on(async { + let _ = handle::Handle::mihomo() + .await + .clear_all_ws_connections() + .await; + }); + + if core::handle::Handle::global().is_exiting() { + return; + } + + if code.is_none() { + api.prevent_exit(); + } + } + tauri::RunEvent::Exit => { + let handle = core::handle::Handle::global(); + if !handle.is_exiting() { + handle.set_is_exiting(); + EventDrivenProxyManager::global().notify_app_stopping(); + feat::clean(); + } + } + tauri::RunEvent::WindowEvent { label, event, .. } if label == "main" => match event { + tauri::WindowEvent::CloseRequested { .. } => { + event_handlers::handle_window_close(&event); + } + tauri::WindowEvent::Focused(focused) => { + event_handlers::handle_window_focus(focused); } #[cfg(target_os = "macos")] - tauri::RunEvent::Reopen { - has_visible_windows, - .. - } => { - // 如果正在退出,忽略 Reopen 事件 - if core::handle::Handle::global().is_exiting() { - logging!(debug, Type::System, "忽略 Reopen 事件,应用正在退出"); - return; - } - AsyncHandler::spawn(move || async move { - event_handlers::handle_reopen(has_visible_windows).await; - }); - } - tauri::RunEvent::ExitRequested { api, code, .. } => { - tauri::async_runtime::block_on(async { - let _ = handle::Handle::mihomo() - .await - .clear_all_ws_connections() - .await; - }); - // 如果已经在退出流程中,不要阻止退出 - if core::handle::Handle::global().is_exiting() { - logging!( - info, - Type::System, - "应用正在退出,允许 ExitRequested (code: {:?})", - code - ); - return; - } - - // 只阻止外部的无退出码请求(如用户取消系统关机) - if code.is_none() { - logging!(debug, Type::System, "阻止外部退出请求"); - api.prevent_exit(); - } - } - tauri::RunEvent::Exit => { - let handle = core::handle::Handle::global(); - - if handle.is_exiting() { - logging!( - debug, - Type::System, - "Exit事件触发,但退出流程已执行,跳过重复清理" - ); - } else { - logging!(debug, Type::System, "Exit事件触发,执行清理流程"); - handle.set_is_exiting(); - EventDrivenProxyManager::global().notify_app_stopping(); - feat::clean(); - } - } - tauri::RunEvent::WindowEvent { label, event, .. } => { - if label == "main" { - match event { - tauri::WindowEvent::CloseRequested { .. } => { - event_handlers::handle_window_close(&event); - } - tauri::WindowEvent::Focused(focused) => { - event_handlers::handle_window_focus(focused); - } - tauri::WindowEvent::Destroyed => { - event_handlers::handle_window_destroyed(); - } - _ => {} - } - } + tauri::WindowEvent::Destroyed => { + event_handlers::handle_window_destroyed(); } _ => {} - } + }, + _ => {} }); } diff --git a/clash-verge-rev/src-tauri/src/main.rs b/clash-verge-rev/src-tauri/src/main.rs index a2a91c7ed6..1066ad119a 100755 --- a/clash-verge-rev/src-tauri/src/main.rs +++ b/clash-verge-rev/src-tauri/src/main.rs @@ -4,12 +4,11 @@ fn main() { console_subscriber::init(); // Check for --no-tray command line argument - let args: Vec = std::env::args().collect(); - if args.contains(&"--no-tray".to_string()) { + #[cfg(target_os = "linux")] + if std::env::args().any(|x| x == "--no-tray") { unsafe { std::env::set_var("CLASH_VERGE_DISABLE_TRAY", "1"); } } - app_lib::run(); } diff --git a/clash-verge-rev/src-tauri/src/module/auto_backup.rs b/clash-verge-rev/src-tauri/src/module/auto_backup.rs new file mode 100644 index 0000000000..2cb9c89bbd --- /dev/null +++ b/clash-verge-rev/src-tauri/src/module/auto_backup.rs @@ -0,0 +1,332 @@ +use crate::{ + config::{Config, IVerge}, + feat::create_local_backup_with_namer, + logging, + process::AsyncHandler, + utils::{dirs::local_backup_dir, logging::Type}, +}; +use anyhow::Result; +use chrono::Local; +use once_cell::sync::OnceCell; +use parking_lot::RwLock; +use std::{ + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicBool, AtomicI64, Ordering}, + }, + time::{Duration, UNIX_EPOCH}, +}; +use tokio::{ + fs, + sync::{Mutex, watch}, +}; + +const DEFAULT_INTERVAL_HOURS: u64 = 24; +const MIN_INTERVAL_HOURS: u64 = 1; +const MAX_INTERVAL_HOURS: u64 = 168; +const MIN_BACKUP_INTERVAL_SECS: i64 = 60; +const AUTO_BACKUP_KEEP: usize = 20; +const AUTO_MARKER: &str = "-auto-"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AutoBackupTrigger { + Scheduled, + GlobalMerge, + GlobalScript, + ProfileChange, +} + +impl AutoBackupTrigger { + const fn slug(self) -> &'static str { + match self { + Self::Scheduled => "scheduled", + Self::GlobalMerge => "merge", + Self::GlobalScript => "script", + Self::ProfileChange => "profile", + } + } + + const fn is_schedule(self) -> bool { + matches!(self, Self::Scheduled) + } +} + +#[derive(Clone, Copy, Debug)] +struct AutoBackupSettings { + schedule_enabled: bool, + interval_hours: u64, + change_enabled: bool, +} + +impl AutoBackupSettings { + fn from_verge(verge: &IVerge) -> Self { + let interval = verge + .auto_backup_interval_hours + .unwrap_or(DEFAULT_INTERVAL_HOURS) + .clamp(MIN_INTERVAL_HOURS, MAX_INTERVAL_HOURS); + + Self { + schedule_enabled: verge.enable_auto_backup_schedule.unwrap_or(false), + interval_hours: interval, + change_enabled: verge.auto_backup_on_change.unwrap_or(true), + } + } +} + +impl Default for AutoBackupSettings { + fn default() -> Self { + Self { + schedule_enabled: false, + interval_hours: DEFAULT_INTERVAL_HOURS, + change_enabled: true, + } + } +} + +pub struct AutoBackupManager { + settings: Arc>, + settings_tx: watch::Sender, + runner_started: AtomicBool, + exec_lock: Mutex<()>, + last_backup: AtomicI64, +} + +impl AutoBackupManager { + pub fn global() -> &'static Self { + static INSTANCE: OnceCell = OnceCell::new(); + INSTANCE.get_or_init(|| { + let (tx, _rx) = watch::channel(AutoBackupSettings::default()); + Self { + settings: Arc::new(RwLock::new(AutoBackupSettings::default())), + settings_tx: tx, + runner_started: AtomicBool::new(false), + exec_lock: Mutex::new(()), + last_backup: AtomicI64::new(0), + } + }) + } + + pub async fn init(&self) -> Result<()> { + let settings = Self::load_settings().await; + { + *self.settings.write() = settings; + } + let _ = self.settings_tx.send(settings); + self.maybe_start_runner(settings); + Ok(()) + } + + pub async fn refresh_settings(&self) -> Result<()> { + let settings = Self::load_settings().await; + { + *self.settings.write() = settings; + } + let _ = self.settings_tx.send(settings); + self.maybe_start_runner(settings); + Ok(()) + } + + pub fn trigger_backup(trigger: AutoBackupTrigger) { + AsyncHandler::spawn(move || async move { + if let Err(err) = Self::global().execute_trigger(trigger).await { + logging!( + warn, + Type::Backup, + "Auto backup execution failed ({:?}): {err:#?}", + trigger + ); + } + }); + } + + fn maybe_start_runner(&self, settings: AutoBackupSettings) { + if settings.schedule_enabled { + self.ensure_runner(); + } + } + + fn ensure_runner(&self) { + if self.runner_started.swap(true, Ordering::SeqCst) { + return; + } + + let mut rx = self.settings_tx.subscribe(); + AsyncHandler::spawn(move || async move { + Self::run_scheduler(&mut rx).await; + }); + } + + async fn run_scheduler(rx: &mut watch::Receiver) { + let mut current = *rx.borrow(); + loop { + if !current.schedule_enabled { + if rx.changed().await.is_err() { + break; + } + current = *rx.borrow(); + continue; + } + + let duration = Duration::from_secs(current.interval_hours.saturating_mul(3600)); + let sleeper = tokio::time::sleep(duration); + tokio::pin!(sleeper); + + tokio::select! { + _ = &mut sleeper => { + if let Err(err) = Self::global() + .execute_trigger(AutoBackupTrigger::Scheduled) + .await + { + logging!( + warn, + Type::Backup, + "Scheduled auto backup failed: {err:#?}" + ); + } + } + changed = rx.changed() => { + if changed.is_err() { + break; + } + current = *rx.borrow(); + } + } + } + } + + async fn execute_trigger(&self, trigger: AutoBackupTrigger) -> Result<()> { + let snapshot = *self.settings.read(); + + if trigger.is_schedule() && !snapshot.schedule_enabled { + return Ok(()); + } + if !trigger.is_schedule() && !snapshot.change_enabled { + return Ok(()); + } + + if !self.should_run_now() { + return Ok(()); + } + + let _guard = self.exec_lock.lock().await; + if !self.should_run_now() { + return Ok(()); + } + + let file_name = + create_local_backup_with_namer(|name| append_auto_suffix(name, trigger.slug()).into()) + .await?; + self.last_backup + .store(Local::now().timestamp(), Ordering::Release); + + if let Err(err) = cleanup_auto_backups().await { + logging!( + warn, + Type::Backup, + "Failed to cleanup old auto backups: {err:#?}" + ); + } + + logging!( + info, + Type::Backup, + "Auto backup created ({:?}): {}", + trigger, + file_name + ); + Ok(()) + } + + fn should_run_now(&self) -> bool { + let last = self.last_backup.load(Ordering::Acquire); + if last == 0 { + return true; + } + let now = Local::now().timestamp(); + now.saturating_sub(last) >= MIN_BACKUP_INTERVAL_SECS + } + + async fn load_settings() -> AutoBackupSettings { + let verge = Config::verge().await; + AutoBackupSettings::from_verge(&verge.latest_arc()) + } +} + +fn append_auto_suffix(file_name: &str, slug: &str) -> String { + match file_name.rsplit_once('.') { + Some((stem, ext)) => format!("{stem}{AUTO_MARKER}{slug}.{ext}"), + None => format!("{file_name}{AUTO_MARKER}{slug}"), + } +} + +async fn cleanup_auto_backups() -> Result<()> { + if AUTO_BACKUP_KEEP == 0 { + return Ok(()); + } + + let backup_dir = local_backup_dir()?; + if !backup_dir.exists() { + return Ok(()); + } + + let mut entries = match fs::read_dir(&backup_dir).await { + Ok(dir) => dir, + Err(err) => { + logging!( + warn, + Type::Backup, + "Failed to read backup directory: {err:#?}" + ); + return Ok(()); + } + }; + + let mut files: Vec<(PathBuf, u64)> = Vec::new(); + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if !path.is_file() { + continue; + } + + let file_name = match entry.file_name().into_string() { + Ok(name) => name, + Err(_) => continue, + }; + + if !file_name.contains(AUTO_MARKER) { + continue; + } + + let modified = entry + .metadata() + .await + .and_then(|meta| meta.modified()) + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|dur| dur.as_secs()) + .unwrap_or(0); + + files.push((path, modified)); + } + + if files.len() <= AUTO_BACKUP_KEEP { + return Ok(()); + } + + files.sort_by_key(|(_, ts)| *ts); + let remove_count = files.len() - AUTO_BACKUP_KEEP; + for (path, _) in files.into_iter().take(remove_count) { + if let Err(err) = fs::remove_file(&path).await { + logging!( + warn, + Type::Backup, + "Failed to remove auto backup {}: {err:#?}", + path.display() + ); + } + } + + Ok(()) +} diff --git a/clash-verge-rev/src-tauri/src/module/lightweight.rs b/clash-verge-rev/src-tauri/src/module/lightweight.rs index 87ec61e6da..25978b1631 100644 --- a/clash-verge-rev/src-tauri/src/module/lightweight.rs +++ b/clash-verge-rev/src-tauri/src/module/lightweight.rs @@ -10,10 +10,10 @@ use crate::{ use crate::logging_error; use crate::utils::window_manager::WindowManager; -use anyhow::{Context, Result}; +use anyhow::{Context as _, Result}; use delay_timer::prelude::TaskBuilder; use std::sync::atomic::{AtomicU8, AtomicU32, Ordering}; -use tauri::Listener; +use tauri::Listener as _; const LIGHT_WEIGHT_TASK_UID: &str = "light_weight_task"; @@ -28,108 +28,75 @@ enum LightweightState { impl From for LightweightState { fn from(v: u8) -> Self { match v { - 1 => LightweightState::In, - 2 => LightweightState::Exiting, - _ => LightweightState::Normal, + 1 => Self::In, + 2 => Self::Exiting, + _ => Self::Normal, } } } impl LightweightState { - fn as_u8(self) -> u8 { + const fn as_u8(self) -> u8 { self as u8 } } static LIGHTWEIGHT_STATE: AtomicU8 = AtomicU8::new(LightweightState::Normal as u8); -static WINDOW_CLOSE_HANDLER: AtomicU32 = AtomicU32::new(0); -static WEBVIEW_FOCUS_HANDLER: AtomicU32 = AtomicU32::new(0); - -fn set_state(new: LightweightState) { - LIGHTWEIGHT_STATE.store(new.as_u8(), Ordering::Release); - match new { - LightweightState::Normal => { - logging!(info, Type::Lightweight, "轻量模式已关闭"); - } - LightweightState::In => { - logging!(info, Type::Lightweight, "轻量模式已开启"); - } - LightweightState::Exiting => { - logging!(info, Type::Lightweight, "正在退出轻量模式"); - } - } -} +static WINDOW_CLOSE_HANDLER_ID: AtomicU32 = AtomicU32::new(0); +static WEBVIEW_FOCUS_HANDLER_ID: AtomicU32 = AtomicU32::new(0); +#[inline] fn get_state() -> LightweightState { LIGHTWEIGHT_STATE.load(Ordering::Acquire).into() } -// 检查是否处于轻量模式 +#[inline] +fn try_transition(from: LightweightState, to: LightweightState) -> bool { + LIGHTWEIGHT_STATE + .compare_exchange( + from.as_u8(), + to.as_u8(), + Ordering::AcqRel, + Ordering::Relaxed, + ) + .is_ok() +} + +#[inline] +fn record_state_and_log(state: LightweightState) { + LIGHTWEIGHT_STATE.store(state.as_u8(), Ordering::Release); + match state { + LightweightState::Normal => logging!(info, Type::Lightweight, "轻量模式已关闭"), + LightweightState::In => logging!(info, Type::Lightweight, "轻量模式已开启"), + LightweightState::Exiting => logging!(info, Type::Lightweight, "正在退出轻量模式"), + } +} + +#[inline] pub fn is_in_lightweight_mode() -> bool { get_state() == LightweightState::In } -// 设置轻量模式状态(仅 Normal <-> In) -async fn set_lightweight_mode(value: bool) { - let current = get_state(); - if value && current != LightweightState::In { - set_state(LightweightState::In); - } else if !value && current != LightweightState::Normal { - set_state(LightweightState::Normal); - } - - // 只有在状态可用时才触发托盘更新 - if let Err(e) = Tray::global().update_part().await { - log::warn!("Failed to update tray: {e}"); +async fn refresh_lightweight_tray_state() { + if let Err(err) = Tray::global().update_menu().await { + logging!(warn, Type::Lightweight, "更新托盘轻量模式状态失败: {err}"); } } -pub async fn run_once_auto_lightweight() { +pub async fn auto_lightweight_boot() -> Result<()> { let verge_config = Config::verge().await; - let enable_auto = verge_config - .data_mut() + let is_enable_auto = verge_config + .data_arc() .enable_auto_light_weight_mode .unwrap_or(false); - let is_silent_start = verge_config - .latest_ref() - .enable_silent_start - .unwrap_or(false); - - if !(enable_auto && is_silent_start) { - logging!( - info, - Type::Lightweight, - "不满足静默启动且自动进入轻量模式的条件,跳过自动进入轻量模式" - ); - return; - } - - set_lightweight_mode(true).await; - enable_auto_light_weight_mode().await; -} - -pub async fn auto_lightweight_mode_init() -> Result<()> { - let is_silent_start = - { Config::verge().await.latest_ref().enable_silent_start }.unwrap_or(false); - let enable_auto = { - Config::verge() - .await - .latest_ref() - .enable_auto_light_weight_mode - } - .unwrap_or(false); - - if enable_auto && !is_silent_start { - logging!( - info, - Type::Lightweight, - "非静默启动直接挂载自动进入轻量模式监听器!" - ); - set_state(LightweightState::Normal); + let is_silent_start = verge_config.data_arc().enable_silent_start.unwrap_or(false); + if is_enable_auto { enable_auto_light_weight_mode().await; } - + if is_silent_start { + entry_lightweight_mode().await; + } Ok(()) } @@ -151,60 +118,33 @@ pub fn disable_auto_light_weight_mode() { } pub async fn entry_lightweight_mode() -> bool { - // 尝试从 Normal -> In - if LIGHTWEIGHT_STATE - .compare_exchange( - LightweightState::Normal as u8, - LightweightState::In as u8, - Ordering::Acquire, - Ordering::Relaxed, - ) - .is_err() - { - logging!(info, Type::Lightweight, "无需进入轻量模式,跳过调用"); + if !try_transition(LightweightState::Normal, LightweightState::In) { + logging!(debug, Type::Lightweight, "无需进入轻量模式,跳过调用"); + refresh_lightweight_tray_state().await; return false; } - + record_state_and_log(LightweightState::In); WindowManager::destroy_main_window(); - - set_lightweight_mode(true).await; let _ = cancel_light_weight_timer(); - - // 回到 In - set_state(LightweightState::In); - + refresh_lightweight_tray_state().await; true } -// 添加从轻量模式恢复的函数 pub async fn exit_lightweight_mode() -> bool { - // 尝试从 In -> Exiting - if LIGHTWEIGHT_STATE - .compare_exchange( - LightweightState::In as u8, - LightweightState::Exiting as u8, - Ordering::Acquire, - Ordering::Relaxed, - ) - .is_err() - { + if !try_transition(LightweightState::In, LightweightState::Exiting) { logging!( - info, + debug, Type::Lightweight, "轻量模式不在退出条件(可能已退出或正在退出),跳过调用" ); + refresh_lightweight_tray_state().await; return false; } - + record_state_and_log(LightweightState::Exiting); WindowManager::show_main_window().await; - - set_lightweight_mode(false).await; let _ = cancel_light_weight_timer(); - - // 回到 Normal - set_state(LightweightState::Normal); - - logging!(info, Type::Lightweight, "轻量模式退出完成"); + record_state_and_log(LightweightState::Normal); + refresh_lightweight_tray_state().await; true } @@ -215,70 +155,81 @@ pub async fn add_light_weight_timer() { fn setup_window_close_listener() { if let Some(window) = handle::Handle::get_window() { - let handler = window.listen("tauri://close-requested", move |_event| { + let handler_id = window.listen("tauri://close-requested", move |_event| { std::mem::drop(AsyncHandler::spawn(|| async { if let Err(e) = setup_light_weight_timer().await { - log::warn!("Failed to setup light weight timer: {e}"); + logging!( + warn, + Type::Lightweight, + "Warning: Failed to setup light weight timer: {e}" + ); } })); logging!(info, Type::Lightweight, "监听到关闭请求,开始轻量模式计时"); }); - - WINDOW_CLOSE_HANDLER.store(handler, Ordering::Release); + WINDOW_CLOSE_HANDLER_ID.store(handler_id, Ordering::Release); } } fn cancel_window_close_listener() { if let Some(window) = handle::Handle::get_window() { - let handler = WINDOW_CLOSE_HANDLER.swap(0, Ordering::AcqRel); - if handler != 0 { - window.unlisten(handler); - logging!(info, Type::Lightweight, "取消了窗口关闭监听"); + let id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel); + if id != 0 { + window.unlisten(id); + logging!(debug, Type::Lightweight, "取消了窗口关闭监听"); } } } fn setup_webview_focus_listener() { if let Some(window) = handle::Handle::get_window() { - let handler = window.listen("tauri://focus", move |_event| { + let handler_id = window.listen("tauri://focus", move |_event| { log_err!(cancel_light_weight_timer()); logging!( - info, + debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时" ); }); - - WEBVIEW_FOCUS_HANDLER.store(handler, Ordering::Release); + WEBVIEW_FOCUS_HANDLER_ID.store(handler_id, Ordering::Release); } } fn cancel_webview_focus_listener() { if let Some(window) = handle::Handle::get_window() { - let handler = WEBVIEW_FOCUS_HANDLER.swap(0, Ordering::AcqRel); - if handler != 0 { - window.unlisten(handler); - logging!(info, Type::Lightweight, "取消了窗口焦点监听"); + let id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel); + if id != 0 { + window.unlisten(id); + logging!(debug, Type::Lightweight, "取消了窗口焦点监听"); } } } async fn setup_light_weight_timer() -> Result<()> { - Timer::global().init().await?; + if let Err(e) = Timer::global().init().await { + return Err(e).context("failed to initialize timer"); + } + let once_by_minutes = Config::verge() .await - .latest_ref() + .data_arc() .auto_light_weight_minutes .unwrap_or(10); - // 获取task_id + { + let timer_map = Timer::global().timer_map.read(); + if timer_map.contains_key(LIGHT_WEIGHT_TASK_UID) { + logging!(debug, Type::Timer, "轻量模式计时器已存在,跳过创建"); + return Ok(()); + } + } + let task_id = { Timer::global() .timer_count .fetch_add(1, std::sync::atomic::Ordering::Relaxed) }; - // 创建任务 let task = TaskBuilder::default() .set_task_id(task_id) .set_maximum_parallel_runnable_num(1) @@ -289,7 +240,6 @@ async fn setup_light_weight_timer() -> Result<()> { }) .context("failed to create timer task")?; - // 添加任务到定时器 { let delay_timer = Timer::global().delay_timer.write(); delay_timer @@ -297,7 +247,6 @@ async fn setup_light_weight_timer() -> Result<()> { .context("failed to add timer task")?; } - // 更新任务映射 { let mut timer_map = Timer::global().timer_map.write(); let timer_task = crate::core::timer::TimerTask { @@ -305,7 +254,7 @@ async fn setup_light_weight_timer() -> Result<()> { interval_minutes: once_by_minutes, last_run: chrono::Local::now().timestamp(), }; - timer_map.insert(LIGHT_WEIGHT_TASK_UID.to_string(), timer_task); + timer_map.insert(LIGHT_WEIGHT_TASK_UID.into(), timer_task); } logging!( @@ -319,14 +268,17 @@ async fn setup_light_weight_timer() -> Result<()> { } fn cancel_light_weight_timer() -> Result<()> { - let mut timer_map = Timer::global().timer_map.write(); - let delay_timer = Timer::global().delay_timer.write(); - - if let Some(task) = timer_map.remove(LIGHT_WEIGHT_TASK_UID) { - delay_timer + let value = Timer::global() + .timer_map + .write() + .remove(LIGHT_WEIGHT_TASK_UID); + if let Some(task) = value { + Timer::global() + .delay_timer + .write() .remove_task(task.task_id) .context("failed to remove timer task")?; - logging!(info, Type::Timer, "计时器已取消"); + logging!(debug, Type::Timer, "计时器已取消"); } Ok(()) diff --git a/clash-verge-rev/src-tauri/src/module/mod.rs b/clash-verge-rev/src-tauri/src/module/mod.rs index 64373b9d92..87055c6caa 100644 --- a/clash-verge-rev/src-tauri/src/module/mod.rs +++ b/clash-verge-rev/src-tauri/src/module/mod.rs @@ -1,2 +1,4 @@ +pub mod auto_backup; pub mod lightweight; +pub mod signal; pub mod sysinfo; diff --git a/clash-verge-rev/src-tauri/src/module/signal/mod.rs b/clash-verge-rev/src-tauri/src/module/signal/mod.rs new file mode 100644 index 0000000000..10f3d69cf5 --- /dev/null +++ b/clash-verge-rev/src-tauri/src/module/signal/mod.rs @@ -0,0 +1,12 @@ +#[cfg(unix)] +mod unix; +#[cfg(windows)] +mod windows; + +pub fn register() { + #[cfg(windows)] + windows::register(); + + #[cfg(unix)] + unix::register(); +} diff --git a/clash-verge-rev/src-tauri/src/module/signal/unix.rs b/clash-verge-rev/src-tauri/src/module/signal/unix.rs new file mode 100644 index 0000000000..866dce4e24 --- /dev/null +++ b/clash-verge-rev/src-tauri/src/module/signal/unix.rs @@ -0,0 +1,36 @@ +use signal_hook::{ + consts::{SIGHUP, SIGINT, SIGTERM}, + iterator::Signals, + low_level, +}; + +use crate::{feat, logging, logging_error, utils::logging::Type}; + +pub fn register() { + tauri::async_runtime::spawn(async { + let signals = [SIGTERM, SIGINT, SIGHUP]; + match Signals::new(signals) { + Ok(mut sigs) => { + for signal in &mut sigs { + let signal_to_str = |signal: i32| match signal { + SIGTERM => "SIGTERM", + SIGINT => "SIGINT", + SIGHUP => "SIGHUP", + _ => "UNKNOWN", + }; + logging!(info, Type::System, "捕获到信号 {}", signal_to_str(signal)); + feat::clean_async().await; + // After printing it, do whatever the signal was supposed to do in the first place + logging_error!( + Type::System, + "信号 {:?} 默认处理失败", + low_level::emulate_default_handler(signal) + ); + } + } + Err(e) => { + logging!(error, Type::System, "注册信号处理器失败: {}", e); + } + } + }); +} diff --git a/clash-verge-rev/src-tauri/src/module/signal/windows.rs b/clash-verge-rev/src-tauri/src/module/signal/windows.rs new file mode 100644 index 0000000000..f10b415cc2 --- /dev/null +++ b/clash-verge-rev/src-tauri/src/module/signal/windows.rs @@ -0,0 +1,126 @@ +use tauri::Manager as _; +use windows_sys::Win32::{ + Foundation::{HWND, LPARAM, LRESULT, WPARAM}, + UI::WindowsAndMessaging::{ + CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DestroyWindow, RegisterClassW, + WM_ENDSESSION, WM_QUERYENDSESSION, WNDCLASSW, WS_EX_LAYERED, WS_EX_NOACTIVATE, + WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT, WS_OVERLAPPED, + }, +}; + +use crate::{core::handle, feat, logging, utils::logging::Type}; + +// code refer to: +// global-hotkey (https://github.com/tauri-apps/global-hotkey) +// Global Shortcut (https://github.com/tauri-apps/plugins-workspace/tree/v2/plugins/global-shortcut) + +struct ShutdownState { + hwnd: HWND, +} + +unsafe impl Send for ShutdownState {} +unsafe impl Sync for ShutdownState {} + +impl Drop for ShutdownState { + fn drop(&mut self) { + // this log not be printed, I don't know why. + logging!(info, Type::System, "正在销毁系统关闭监听窗口"); + unsafe { + DestroyWindow(self.hwnd); + } + } +} + +unsafe extern "system" fn shutdown_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + // refer: https://learn.microsoft.com/zh-cn/windows/win32/shutdown/shutting-down#shutdown-notifications + // only perform reset operations in `WM_ENDSESSION` + match msg { + WM_QUERYENDSESSION => { + logging!( + info, + Type::System, + "System is shutting down or user is logging off." + ); + } + WM_ENDSESSION => { + tauri::async_runtime::block_on(async move { + logging!(info, Type::System, "Session ended, system shutting down."); + feat::clean_async().await; + logging!(info, Type::System, "resolved reset finished"); + }); + } + _ => {} + }; + unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } +} + +fn encode_wide>(string: S) -> Vec { + std::os::windows::prelude::OsStrExt::encode_wide(string.as_ref()) + .chain(std::iter::once(0)) + .collect::>() +} + +fn get_instance_handle() -> windows_sys::Win32::Foundation::HMODULE { + // Gets the instance handle by taking the address of the + // pseudo-variable created by the microsoft linker: + // https://devblogs.microsoft.com/oldnewthing/20041025-00/?p=37483 + + // This is preferred over GetModuleHandle(NULL) because it also works in DLLs: + // https://stackoverflow.com/questions/21718027/getmodulehandlenull-vs-hinstance + + unsafe extern "C" { + static __ImageBase: windows_sys::Win32::System::SystemServices::IMAGE_DOS_HEADER; + } + + unsafe { &__ImageBase as *const _ as _ } +} + +pub fn register() { + let app_handle = handle::Handle::app_handle(); + let class_name = encode_wide("global_shutdown_app"); + unsafe { + let hinstance = get_instance_handle(); + + let wnd_class = WNDCLASSW { + lpfnWndProc: Some(shutdown_proc), + lpszClassName: class_name.as_ptr(), + hInstance: hinstance, + ..std::mem::zeroed() + }; + + RegisterClassW(&wnd_class); + + let hwnd = CreateWindowExW( + WS_EX_NOACTIVATE | WS_EX_TRANSPARENT | WS_EX_LAYERED | + // WS_EX_TOOLWINDOW prevents this window from ever showing up in the taskbar, which + // we want to avoid. If you remove this style, this window won't show up in the + // taskbar *initially*, but it can show up at some later point. This can sometimes + // happen on its own after several hours have passed, although this has proven + // difficult to reproduce. Alternatively, it can be manually triggered by killing + // `explorer.exe` and then starting the process back up. + // It is unclear why the bug is triggered by waiting for several hours. + WS_EX_TOOLWINDOW, + class_name.as_ptr(), + std::ptr::null(), + WS_OVERLAPPED, + CW_USEDEFAULT, + 0, + CW_USEDEFAULT, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + hinstance, + std::ptr::null_mut(), + ); + if hwnd.is_null() { + logging!(error, Type::System, "failed to create shutdown window"); + } else { + app_handle.manage(ShutdownState { hwnd }); + } + } +} diff --git a/clash-verge-rev/src-tauri/src/module/sysinfo.rs b/clash-verge-rev/src-tauri/src/module/sysinfo.rs index fb2b25791c..74ebe1f3ea 100644 --- a/clash-verge-rev/src-tauri/src/module/sysinfo.rs +++ b/clash-verge-rev/src-tauri/src/module/sysinfo.rs @@ -33,9 +33,9 @@ impl Debug for PlatformSpecification { impl PlatformSpecification { pub fn new() -> Self { - let system_name = System::name().unwrap_or("Null".into()); - let system_version = System::long_os_version().unwrap_or("Null".into()); - let system_kernel_version = System::kernel_version().unwrap_or("Null".into()); + let system_name = System::name().unwrap_or_else(|| "Null".into()); + let system_version = System::long_os_version().unwrap_or_else(|| "Null".into()); + let system_kernel_version = System::kernel_version().unwrap_or_else(|| "Null".into()); let system_arch = System::cpu_arch(); let handler = handle::Handle::app_handle(); diff --git a/clash-verge-rev/src-tauri/src/process/async_handler.rs b/clash-verge-rev/src-tauri/src/process/async_handler.rs index 1b796485ef..8c79662077 100644 --- a/clash-verge-rev/src-tauri/src/process/async_handler.rs +++ b/clash-verge-rev/src-tauri/src/process/async_handler.rs @@ -8,10 +8,11 @@ use tauri::{async_runtime, async_runtime::JoinHandle}; pub struct AsyncHandler; impl AsyncHandler { - pub fn handle() -> async_runtime::RuntimeHandle { - async_runtime::handle() - } + // pub fn handle() -> async_runtime::RuntimeHandle { + // async_runtime::handle() + // } + #[inline] #[track_caller] pub fn spawn(f: F) -> JoinHandle<()> where @@ -23,6 +24,7 @@ impl AsyncHandler { async_runtime::spawn(f()) } + #[inline] #[track_caller] pub fn spawn_blocking(f: F) -> JoinHandle where @@ -34,7 +36,7 @@ impl AsyncHandler { async_runtime::spawn_blocking(f) } - #[allow(dead_code)] + #[inline] #[track_caller] pub fn block_on(fut: Fut) -> Fut::Output where diff --git a/clash-verge-rev/src-tauri/src/process/guard.rs b/clash-verge-rev/src-tauri/src/process/guard.rs index e4a48909de..d1a41ab505 100644 --- a/clash-verge-rev/src-tauri/src/process/guard.rs +++ b/clash-verge-rev/src-tauri/src/process/guard.rs @@ -1,22 +1,32 @@ use anyhow::Result; use tauri_plugin_shell::process::CommandChild; +use crate::{logging, utils::logging::Type}; + #[derive(Debug)] pub struct CommandChildGuard(Option); impl Drop for CommandChildGuard { + #[inline] fn drop(&mut self) { if let Err(err) = self.kill() { - log::error!(target: "app", "Failed to kill child process: {}", err); + logging!( + error, + Type::Service, + "Failed to kill child process: {}", + err + ); } } } impl CommandChildGuard { - pub fn new(child: CommandChild) -> Self { + #[inline] + pub const fn new(child: CommandChild) -> Self { Self(Some(child)) } + #[inline] pub fn kill(&mut self) -> Result<()> { if let Some(child) = self.0.take() { let _ = child.kill(); @@ -24,6 +34,7 @@ impl CommandChildGuard { Ok(()) } + #[inline] pub fn pid(&self) -> Option { self.0.as_ref().map(|c| c.pid()) } diff --git a/clash-verge-rev/src-tauri/src/utils/autostart.rs b/clash-verge-rev/src-tauri/src/utils/autostart.rs index 9ce10abcc6..d898a6d09d 100644 --- a/clash-verge-rev/src-tauri/src/utils/autostart.rs +++ b/clash-verge-rev/src-tauri/src/utils/autostart.rs @@ -1,10 +1,10 @@ #[cfg(target_os = "windows")] +use crate::{logging, utils::logging::Type}; +#[cfg(target_os = "windows")] use anyhow::{Result, anyhow}; -#[cfg(target_os = "windows")] -use log::info; #[cfg(target_os = "windows")] -use std::{fs, os::windows::process::CommandExt, path::Path, path::PathBuf}; +use std::{os::windows::process::CommandExt as _, path::Path, path::PathBuf}; /// Windows 下的开机启动文件夹路径 #[cfg(target_os = "windows")] @@ -36,24 +36,28 @@ pub fn get_exe_path() -> Result { /// 创建快捷方式 #[cfg(target_os = "windows")] -pub fn create_shortcut() -> Result<()> { +pub async fn create_shortcut() -> Result<()> { + use crate::utils::dirs::PathBufExec as _; + let exe_path = get_exe_path()?; let startup_dir = get_startup_dir()?; let old_shortcut_path = startup_dir.join("Clash-Verge.lnk"); let new_shortcut_path = startup_dir.join("Clash Verge.lnk"); // 移除旧的快捷方式 - if old_shortcut_path.exists() { - if let Err(e) = fs::remove_file(&old_shortcut_path) { - info!(target: "app", "移除旧快捷方式失败: {e}"); - } else { - info!(target: "app", "成功移除旧快捷方式"); - } - } + let _ = old_shortcut_path + .remove_if_exists() + .await + .inspect(|_| { + logging!(info, Type::Setup, "成功移除旧启动快捷方式"); + }) + .inspect_err(|err| { + logging!(error, Type::Setup, "移除旧启动快捷方式失败: {err}"); + }); // 如果新快捷方式已存在,直接返回成功 if new_shortcut_path.exists() { - info!(target: "app", "启动快捷方式已存在"); + logging!(info, Type::Setup, "启动快捷方式已存在"); return Ok(()); } @@ -79,36 +83,42 @@ pub fn create_shortcut() -> Result<()> { return Err(anyhow!("创建快捷方式失败: {}", error_msg)); } - info!(target: "app", "成功创建启动快捷方式"); + logging!(info, Type::Setup, "成功创建启动快捷方式"); Ok(()) } /// 删除快捷方式 #[cfg(target_os = "windows")] -pub fn remove_shortcut() -> Result<()> { +pub async fn remove_shortcut() -> Result<()> { + use crate::utils::dirs::PathBufExec as _; + let startup_dir = get_startup_dir()?; let old_shortcut_path = startup_dir.join("Clash-Verge.lnk"); let new_shortcut_path = startup_dir.join("Clash Verge.lnk"); let mut removed_any = false; - // 删除旧的快捷方式 - if old_shortcut_path.exists() { - fs::remove_file(&old_shortcut_path).map_err(|e| anyhow!("删除旧快捷方式失败: {}", e))?; - info!(target: "app", "成功删除旧启动快捷方式"); - removed_any = true; - } + let _ = old_shortcut_path + .remove_if_exists() + .await + .inspect(|_| { + logging!(info, Type::Setup, "成功删除旧启动快捷方式"); + removed_any = true; + }) + .inspect_err(|err| { + logging!(error, Type::Setup, "删除旧启动快捷方式失败: {err}"); + }); - // 删除新的快捷方式 - if new_shortcut_path.exists() { - fs::remove_file(&new_shortcut_path).map_err(|e| anyhow!("删除快捷方式失败: {}", e))?; - info!(target: "app", "成功删除启动快捷方式"); - removed_any = true; - } - - if !removed_any { - info!(target: "app", "启动快捷方式不存在,无需删除"); - } + let _ = new_shortcut_path + .remove_if_exists() + .await + .inspect(|_| { + logging!(info, Type::Setup, "成功删除启动快捷方式"); + removed_any = true; + }) + .inspect_err(|err| { + logging!(error, Type::Setup, "删除启动快捷方式失败: {err}"); + }); Ok(()) } @@ -121,19 +131,3 @@ pub fn is_shortcut_enabled() -> Result { Ok(new_shortcut_path.exists()) } - -// 非 Windows 平台使用的空方法 -// #[cfg(not(target_os = "windows"))] -// pub fn create_shortcut() -> Result<()> { -// Ok(()) -// } - -// #[cfg(not(target_os = "windows"))] -// pub fn remove_shortcut() -> Result<()> { -// Ok(()) -// } - -// #[cfg(not(target_os = "windows"))] -// pub fn is_shortcut_enabled() -> Result { -// Ok(false) -// } diff --git a/clash-verge-rev/src-tauri/src/utils/dirs.rs b/clash-verge-rev/src-tauri/src/utils/dirs.rs index 8b39425e68..f00e33f336 100644 --- a/clash-verge-rev/src-tauri/src/utils/dirs.rs +++ b/clash-verge-rev/src-tauri/src/utils/dirs.rs @@ -1,8 +1,15 @@ -use crate::core::handle; +use crate::{ + core::{CoreManager, handle, manager::RunningMode}, + logging, + utils::logging::Type, +}; use anyhow::Result; +use async_trait::async_trait; use once_cell::sync::OnceCell; +#[cfg(unix)] +use std::iter; use std::{fs, path::PathBuf}; -use tauri::Manager; +use tauri::Manager as _; #[cfg(not(feature = "verge-dev"))] pub static APP_ID: &str = "io.github.clash-verge-rev.clash-verge-rev"; @@ -46,7 +53,7 @@ pub fn app_home_dir() -> Result { let app_exe = dunce::canonicalize(app_exe)?; let app_dir = app_exe .parent() - .ok_or(anyhow::anyhow!("failed to get the portable app dir"))?; + .ok_or_else(|| anyhow::anyhow!("failed to get the portable app dir"))?; return Ok(PathBuf::from(app_dir).join(".config").join(APP_ID)); } @@ -56,7 +63,11 @@ pub fn app_home_dir() -> Result { match app_handle.path().data_dir() { Ok(dir) => Ok(dir.join(APP_ID)), Err(e) => { - log::error!(target: "app", "Failed to get the app home directory: {e}"); + logging!( + error, + Type::File, + "Failed to get the app home directory: {e}" + ); Err(anyhow::anyhow!("Failed to get the app homedirectory")) } } @@ -70,7 +81,11 @@ pub fn app_resources_dir() -> Result { match app_handle.path().resource_dir() { Ok(dir) => Ok(dir.join("resources")), Err(e) => { - log::error!(target: "app", "Failed to get the resource directory: {e}"); + logging!( + error, + Type::File, + "Failed to get the resource directory: {e}" + ); Err(anyhow::anyhow!("Failed to get the resource directory")) } } @@ -88,31 +103,25 @@ pub fn app_icons_dir() -> Result { pub fn find_target_icons(target: &str) -> Result> { let icons_dir = app_icons_dir()?; - let mut matching_files = Vec::new(); + let icon_path = fs::read_dir(&icons_dir)? + .filter_map(|entry| entry.ok().map(|e| e.path())) + .find(|path| { + let prefix_matches = path + .file_prefix() + .and_then(|p| p.to_str()) + .is_some_and(|prefix| prefix.starts_with(target)); + let ext_matches = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|ext| { + ext.eq_ignore_ascii_case("ico") || ext.eq_ignore_ascii_case("png") + }); + prefix_matches && ext_matches + }); - for entry in fs::read_dir(icons_dir)? { - let entry = entry?; - let path = entry.path(); - - if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) - && file_name.starts_with(target) - && (file_name.ends_with(".ico") || file_name.ends_with(".png")) - { - matching_files.push(path); - } - } - - if matching_files.is_empty() { - Ok(None) - } else { - match matching_files.first() { - Some(first_path) => { - let first = path_to_str(first_path)?; - Ok(Some(first.to_string())) - } - None => Ok(None), - } - } + icon_path + .map(|path| path_to_str(&path).map(|s| s.into())) + .transpose() } /// logs dir @@ -120,6 +129,18 @@ pub fn app_logs_dir() -> Result { Ok(app_home_dir()?.join("logs")) } +// 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)?; + Ok(dir) +} + pub fn clash_path() -> Result { Ok(app_home_dir()?.join(CLASH_CONFIG)) } @@ -158,11 +179,20 @@ pub fn service_log_dir() -> Result { Ok(log_dir) } +pub fn clash_latest_log() -> Result { + match *CoreManager::global().get_running_mode() { + RunningMode::Service => Ok(service_log_dir()?.join("service_latest.log")), + RunningMode::Sidecar | RunningMode::NotRunning => { + Ok(sidecar_log_dir()?.join("sidecar_latest.log")) + } + } +} + pub fn path_to_str(path: &PathBuf) -> Result<&str> { let path_str = path .as_os_str() .to_str() - .ok_or(anyhow::anyhow!("failed to get path from {:?}", path))?; + .ok_or_else(|| anyhow::anyhow!("failed to get path from {:?}", path))?; Ok(path_str) } @@ -192,8 +222,7 @@ pub fn get_encryption_key() -> Result> { #[cfg(unix)] pub fn ensure_mihomo_safe_dir() -> Option { - ["/tmp"] - .iter() + iter::once("/tmp") .map(PathBuf::from) .find(|path| path.exists()) .or_else(|| { @@ -202,7 +231,11 @@ pub fn ensure_mihomo_safe_dir() -> Option { if home_config.exists() || fs::create_dir_all(&home_config).is_ok() { Some(home_config) } else { - log::error!(target: "app", "Failed to create safe directory: {home_config:?}"); + logging!( + error, + Type::File, + "Failed to create safe directory: {home_config:?}" + ); None } }) @@ -225,3 +258,18 @@ pub fn ipc_path() -> Result { pub fn ipc_path() -> Result { Ok(PathBuf::from(r"\\.\pipe\verge-mihomo")) } +#[async_trait] +pub trait PathBufExec { + async fn remove_if_exists(&self) -> Result<()>; +} + +#[async_trait] +impl PathBufExec for PathBuf { + async fn remove_if_exists(&self) -> Result<()> { + if self.exists() { + tokio::fs::remove_file(self).await?; + logging!(info, Type::File, "Removed file: {:?}", self); + } + Ok(()) + } +} diff --git a/clash-verge-rev/src-tauri/src/utils/draft.rs b/clash-verge-rev/src-tauri/src/utils/draft.rs new file mode 100644 index 0000000000..07782f4bd2 --- /dev/null +++ b/clash-verge-rev/src-tauri/src/utils/draft.rs @@ -0,0 +1,372 @@ +use parking_lot::RwLock; +use std::sync::Arc; + +pub type SharedBox = Arc>; +type DraftInner = (SharedBox, Option>); + +/// Draft 管理:committed 与 optional draft 都以 Arc> 存储, +// (committed_snapshot, optional_draft_snapshot) +#[derive(Debug, Clone)] +pub struct Draft { + inner: Arc>>, +} + +impl Draft { + #[inline] + pub fn new(data: T) -> Self { + Self { + inner: Arc::new(RwLock::new((Arc::new(Box::new(data)), None))), + } + } + /// 以 Arc> 的形式获取当前“已提交(正式)”数据的快照(零拷贝,仅 clone Arc) + #[inline] + pub fn data_arc(&self) -> SharedBox { + let guard = self.inner.read(); + Arc::clone(&guard.0) + } + + /// 获取当前(草稿若存在则返回草稿,否则返回已提交)的快照 + /// 这也是零拷贝:只 clone Arc,不 clone T + #[inline] + pub fn latest_arc(&self) -> SharedBox { + let guard = self.inner.read(); + guard.1.clone().unwrap_or_else(|| Arc::clone(&guard.0)) + } + + /// 通过闭包以可变方式编辑草稿(在闭包中我们给出 &mut T) + /// - 延迟拷贝:如果只有这一个 Arc 引用,则直接修改,不会克隆 T; + /// - 若草稿被其他读者共享,Arc::make_mut 会做一次 T.clone(最小必要拷贝)。 + #[inline] + pub fn edit_draft(&self, f: F) -> R + where + F: FnOnce(&mut T) -> R, + { + // 先获得写锁以创建或取出草稿 Arc 的可变引用位置 + let mut guard = self.inner.write(); + let mut draft_arc = if guard.1.is_none() { + Arc::clone(&guard.0) + } else { + #[allow(clippy::unwrap_used)] + guard.1.take().unwrap() + }; + drop(guard); + // Arc::make_mut: 如果只有一个引用则返回可变引用;否则会克隆底层 Box(要求 T: Clone) + let boxed = Arc::make_mut(&mut draft_arc); // &mut Box + // 对 Box 解引用得到 &mut T + let result = f(&mut **boxed); + // 恢复修改后的草稿 Arc + self.inner.write().1 = Some(draft_arc); + result + } + + /// 将草稿提交到已提交位置(替换),并清除草稿 + #[inline] + pub fn apply(&self) { + let mut guard = self.inner.write(); + if let Some(d) = guard.1.take() { + guard.0 = d; + } + } + + /// 丢弃草稿(如果存在) + #[inline] + pub fn discard(&self) { + let mut guard = self.inner.write(); + guard.1 = None; + } + + /// 异步地以拥有 Box 的方式修改已提交数据:将克隆一次已提交数据到本地, + /// 异步闭包返回新的 Box(替换已提交数据)和业务返回值 R。 + #[inline] + pub async fn with_data_modify(&self, f: F) -> Result + where + T: Send + Sync + 'static, + F: FnOnce(Box) -> Fut + Send, + Fut: std::future::Future, R), anyhow::Error>> + Send, + { + // 读取已提交快照(cheap Arc clone, 然后得到 Box 所有权 via clone) + // 注意:为了让闭包接收 Box 所有权,我们需要 clone 底层 T(不可避免) + let local: Box = { + let guard = self.inner.read(); + // 将 Arc> 的 Box clone 出来(会调用 T: Clone) + (*guard.0).clone() + }; + + let (new_local, res) = f(local).await?; + + // 将新的 Box 放到已提交位置(包进 Arc) + self.inner.write().0 = Arc::new(new_local); + + Ok(res) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::anyhow; + use std::future::Future; + use std::pin::Pin; + use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; + + #[derive(Clone, Debug, Default, PartialEq)] + struct IVerge { + enable_auto_launch: Option, + enable_tun_mode: Option, + } + + // Minimal single-threaded executor for immediately-ready futures + fn block_on_ready(fut: F) -> F::Output { + fn no_op_raw_waker() -> RawWaker { + fn clone(_: *const ()) -> RawWaker { + no_op_raw_waker() + } + fn wake(_: *const ()) {} + fn wake_by_ref(_: *const ()) {} + fn drop(_: *const ()) {} + static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake_by_ref, drop); + RawWaker::new(std::ptr::null(), &VTABLE) + } + + let waker = unsafe { Waker::from_raw(no_op_raw_waker()) }; + let mut cx = Context::from_waker(&waker); + let mut fut = Box::pin(fut); + loop { + match Pin::as_mut(&mut fut).poll(&mut cx) { + Poll::Ready(v) => return v, + Poll::Pending => std::thread::yield_now(), + } + } + } + + #[test] + fn test_draft_basic_flow() { + let verge = IVerge { + enable_auto_launch: Some(true), + enable_tun_mode: Some(false), + }; + let draft = Draft::new(verge); + + // 读取正式数据(data_arc) + { + let data = draft.data_arc(); + assert_eq!(data.enable_auto_launch, Some(true)); + assert_eq!(data.enable_tun_mode, Some(false)); + } + + // 修改草稿(使用 edit_draft) + draft.edit_draft(|d| { + d.enable_auto_launch = Some(false); + d.enable_tun_mode = Some(true); + }); + + // 正式数据未变 + { + let data = draft.data_arc(); + assert_eq!(data.enable_auto_launch, Some(true)); + assert_eq!(data.enable_tun_mode, Some(false)); + } + + // 草稿已变 + { + let latest = draft.latest_arc(); + assert_eq!(latest.enable_auto_launch, Some(false)); + assert_eq!(latest.enable_tun_mode, Some(true)); + } + + // 提交草稿 + draft.apply(); + + // 正式数据已更新 + { + let data = draft.data_arc(); + assert_eq!(data.enable_auto_launch, Some(false)); + assert_eq!(data.enable_tun_mode, Some(true)); + } + + // 新一轮草稿并修改 + draft.edit_draft(|d| { + d.enable_auto_launch = Some(true); + }); + { + let latest = draft.latest_arc(); + assert_eq!(latest.enable_auto_launch, Some(true)); + assert_eq!(latest.enable_tun_mode, Some(true)); + } + + // 丢弃草稿 + draft.discard(); + + // 丢弃后再次创建草稿,会从已提交重新 clone + { + draft.edit_draft(|d| { + // 原 committed 是 enable_auto_launch = Some(false) + assert_eq!(d.enable_auto_launch, Some(false)); + // 再修改一下 + d.enable_tun_mode = Some(false); + }); + // 草稿中值已修改,但正式数据仍是 apply 后的值 + let data = draft.data_arc(); + assert_eq!(data.enable_auto_launch, Some(false)); + assert_eq!(data.enable_tun_mode, Some(true)); + } + } + + #[test] + fn test_arc_pointer_behavior_on_edit_and_apply() { + let draft = Draft::new(IVerge { + enable_auto_launch: Some(true), + enable_tun_mode: Some(false), + }); + + // 初始 latest == committed + let committed = draft.data_arc(); + let latest = draft.latest_arc(); + assert!(std::sync::Arc::ptr_eq(&committed, &latest)); + + // 第一次 edit:由于与 committed 共享,Arc::make_mut 会克隆 + draft.edit_draft(|d| d.enable_tun_mode = Some(true)); + let committed_after_first_edit = draft.data_arc(); + let draft_after_first_edit = draft.latest_arc(); + assert!(!std::sync::Arc::ptr_eq( + &committed_after_first_edit, + &draft_after_first_edit + )); + // 提交会把 committed 指向草稿的 Arc + let prev_draft_ptr = std::sync::Arc::as_ptr(&draft_after_first_edit); + draft.apply(); + let committed_after_apply = draft.data_arc(); + assert_eq!( + std::sync::Arc::as_ptr(&committed_after_apply), + prev_draft_ptr + ); + + // 第二次编辑:此时草稿唯一持有(无其它引用),不应再克隆 + // 获取草稿 Arc 的指针并立即丢弃本地引用,避免增加 strong_count + draft.edit_draft(|d| d.enable_auto_launch = Some(false)); + let latest1 = draft.latest_arc(); + let latest1_ptr = std::sync::Arc::as_ptr(&latest1); + drop(latest1); // 确保只有 Draft 内部持有草稿 Arc + + // 再次编辑(unique,Arc::make_mut 不应克隆) + draft.edit_draft(|d| d.enable_tun_mode = Some(false)); + let latest2 = draft.latest_arc(); + let latest2_ptr = std::sync::Arc::as_ptr(&latest2); + + assert_eq!(latest1_ptr, latest2_ptr, "Unique edit should not clone Arc"); + assert_eq!(latest2.enable_auto_launch, Some(false)); + assert_eq!(latest2.enable_tun_mode, Some(false)); + } + + #[test] + fn test_discard_restores_latest_to_committed() { + let draft = Draft::new(IVerge { + enable_auto_launch: Some(false), + enable_tun_mode: Some(false), + }); + + // 创建草稿并修改 + draft.edit_draft(|d| d.enable_auto_launch = Some(true)); + let committed = draft.data_arc(); + let latest = draft.latest_arc(); + assert!(!std::sync::Arc::ptr_eq(&committed, &latest)); + + // 丢弃草稿后 latest 应回到 committed + draft.discard(); + let committed2 = draft.data_arc(); + let latest2 = draft.latest_arc(); + assert!(std::sync::Arc::ptr_eq(&committed2, &latest2)); + assert_eq!(latest2.enable_auto_launch, Some(false)); + } + + #[test] + fn test_edit_draft_returns_closure_result() { + let draft = Draft::new(IVerge::default()); + let ret = draft.edit_draft(|d| { + d.enable_tun_mode = Some(true); + 123usize + }); + assert_eq!(ret, 123); + let latest = draft.latest_arc(); + assert_eq!(latest.enable_tun_mode, Some(true)); + } + + #[test] + fn test_with_data_modify_ok_and_replaces_committed() { + let draft = Draft::new(IVerge { + enable_auto_launch: Some(false), + enable_tun_mode: Some(false), + }); + + // 使用 with_data_modify 异步(立即就绪)地更新 committed + let res = block_on_ready(draft.with_data_modify(|mut v| async move { + v.enable_auto_launch = Some(true); + Ok((Box::new(*v), "done")) // Dereference v to get Box + })); + assert_eq!( + { + #[allow(clippy::unwrap_used)] + res.unwrap() + }, + "done" + ); + + let committed = draft.data_arc(); + assert_eq!(committed.enable_auto_launch, Some(true)); + assert_eq!(committed.enable_tun_mode, Some(false)); + } + + #[test] + fn test_with_data_modify_error_propagation() { + let draft = Draft::new(IVerge::default()); + + #[allow(clippy::unwrap_used)] + let err = block_on_ready(draft.with_data_modify(|v| async move { + drop(v); + Err::<(Box, ()), _>(anyhow!("boom")) + })) + .unwrap_err(); + + assert_eq!(format!("{err}"), "boom"); + } + + #[test] + fn test_with_data_modify_does_not_touch_existing_draft() { + let draft = Draft::new(IVerge { + enable_auto_launch: Some(false), + enable_tun_mode: Some(false), + }); + + // 创建草稿并修改 + draft.edit_draft(|d| { + d.enable_auto_launch = Some(true); + d.enable_tun_mode = Some(true); + }); + let draft_before = draft.latest_arc(); + let draft_before_ptr = std::sync::Arc::as_ptr(&draft_before); + + // 同时通过 with_data_modify 修改 committed + #[allow(clippy::unwrap_used)] + block_on_ready(draft.with_data_modify(|mut v| async move { + v.enable_auto_launch = Some(false); // 与草稿不同 + Ok((Box::new(*v), ())) // Dereference v to get Box + })) + .unwrap(); + + // 草稿应保持不变 + let draft_after = draft.latest_arc(); + assert_eq!( + std::sync::Arc::as_ptr(&draft_after), + draft_before_ptr, + "Existing draft should not be replaced by with_data_modify" + ); + assert_eq!(draft_after.enable_auto_launch, Some(true)); + assert_eq!(draft_after.enable_tun_mode, Some(true)); + + // 丢弃草稿后 latest == committed,且 committed 为异步修改结果 + draft.discard(); + let latest = draft.latest_arc(); + assert_eq!(latest.enable_auto_launch, Some(false)); + assert_eq!(latest.enable_tun_mode, Some(false)); + } +} diff --git a/clash-verge-rev/src-tauri/src/utils/help.rs b/clash-verge-rev/src-tauri/src/utils/help.rs index e06c7ed7cc..a33b365a44 100644 --- a/clash-verge-rev/src-tauri/src/utils/help.rs +++ b/clash-verge-rev/src-tauri/src/utils/help.rs @@ -1,5 +1,5 @@ -use crate::{enhance::seq::SeqMap, logging, utils::logging::Type}; -use anyhow::{Context, Result, anyhow, bail}; +use crate::{config::with_encryption, enhance::seq::SeqMap, logging, utils::logging::Type}; +use anyhow::{Context as _, Result, anyhow, bail}; use nanoid::nanoid; use serde::{Serialize, de::DeserializeOwned}; use serde_yaml_ng::Mapping; @@ -13,7 +13,7 @@ pub async fn read_yaml(path: &PathBuf) -> Result { let yaml_str = tokio::fs::read_to_string(path).await?; - Ok(serde_yaml_ng::from_str::(&yaml_str)?) + Ok(with_encryption(|| async { serde_yaml_ng::from_str::(&yaml_str) }).await?) } /// read mapping from yaml @@ -34,10 +34,9 @@ pub async fn read_mapping(path: &PathBuf) -> Result { Ok(val .as_mapping() - .ok_or(anyhow!( - "failed to transform to yaml mapping \"{}\"", - path.display() - ))? + .ok_or_else(|| { + anyhow!("failed to transform to yaml mapping \"{}\"", path.display()) + })? .to_owned()) } Err(err) => { @@ -66,7 +65,7 @@ pub async fn save_yaml( data: &T, prefix: Option<&str>, ) -> Result<()> { - let data_str = serde_yaml_ng::to_string(data)?; + let data_str = with_encryption(|| async { serde_yaml_ng::to_string(data) }).await?; let yaml_str = match prefix { Some(prefix) => format!("{prefix}\n\n{data_str}"), diff --git a/clash-verge-rev/src-tauri/src/utils/i18n.rs b/clash-verge-rev/src-tauri/src/utils/i18n.rs index 9f80c56330..0579082837 100644 --- a/clash-verge-rev/src-tauri/src/utils/i18n.rs +++ b/clash-verge-rev/src-tauri/src/utils/i18n.rs @@ -1,98 +1,99 @@ -use crate::{config::Config, utils::dirs}; -use once_cell::sync::Lazy; -use serde_json::Value; -use std::{fs, path::PathBuf, sync::RwLock}; +use crate::config::Config; use sys_locale; const DEFAULT_LANGUAGE: &str = "zh"; -fn get_locales_dir() -> Option { - dirs::app_resources_dir() - .map(|resource_path| resource_path.join("locales")) - .ok() +fn supported_languages_internal() -> Vec<&'static str> { + rust_i18n::available_locales!() +} + +const fn fallback_language() -> &'static str { + DEFAULT_LANGUAGE +} + +fn locale_alias(locale: &str) -> Option<&'static str> { + match locale { + "ja" | "ja-jp" | "jp" => Some("jp"), + "zh" | "zh-cn" | "zh-hans" | "zh-sg" | "zh-my" | "zh-chs" => Some("zh"), + "zh-tw" | "zh-hk" | "zh-hant" | "zh-mo" | "zh-cht" => Some("zhtw"), + _ => None, + } +} + +fn resolve_supported_language(language: &str) -> Option { + if language.is_empty() { + return None; + } + + let normalized = language.to_lowercase().replace('_', "-"); + + let mut candidates: Vec = Vec::new(); + let mut push_candidate = |candidate: String| { + if !candidate.is_empty() + && !candidates + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&candidate)) + { + candidates.push(candidate); + } + }; + + let segments: Vec<&str> = normalized.split('-').collect(); + + for i in (1..=segments.len()).rev() { + let prefix = segments[..i].join("-"); + if let Some(alias) = locale_alias(&prefix) { + push_candidate(alias.to_string()); + } + push_candidate(prefix); + } + + let supported = supported_languages_internal(); + + candidates.into_iter().find(|candidate| { + supported + .iter() + .any(|&lang| lang.eq_ignore_ascii_case(candidate)) + }) +} + +fn system_language() -> String { + sys_locale::get_locale() + .as_deref() + .and_then(resolve_supported_language) + .unwrap_or_else(|| fallback_language().to_string()) } pub fn get_supported_languages() -> Vec { - let mut languages = Vec::new(); - - if let Some(locales_dir) = get_locales_dir() - && let Ok(entries) = fs::read_dir(locales_dir) - { - for entry in entries.flatten() { - if let Some(file_name) = entry.file_name().to_str() - && let Some(lang) = file_name.strip_suffix(".json") - { - languages.push(lang.to_string()); - } - } - } - - if languages.is_empty() { - languages.push(DEFAULT_LANGUAGE.to_string()); - } - languages + supported_languages_internal() + .into_iter() + .map(|lang| lang.to_string()) + .collect() } -static TRANSLATIONS: Lazy> = Lazy::new(|| { - let lang = get_system_language(); - let json = load_lang_file(&lang).unwrap_or_else(|| Value::Object(Default::default())); - RwLock::new((lang, json)) -}); - -fn load_lang_file(lang: &str) -> Option { - let locales_dir = get_locales_dir()?; - let file_path = locales_dir.join(format!("{lang}.json")); - fs::read_to_string(file_path) - .ok() - .and_then(|content| serde_json::from_str(&content).ok()) +pub fn set_locale(language: &str) { + let lang = + resolve_supported_language(language).unwrap_or_else(|| fallback_language().to_string()); + rust_i18n::set_locale(&lang); } -fn get_system_language() -> String { - sys_locale::get_locale() - .map(|locale| locale.to_lowercase()) - .and_then(|locale| locale.split(['_', '-']).next().map(String::from)) - .filter(|lang| get_supported_languages().contains(lang)) - .unwrap_or_else(|| DEFAULT_LANGUAGE.to_string()) -} - -pub async fn t(key: &str) -> String { - let current_lang = Config::verge() +pub async fn current_language() -> String { + Config::verge() .await - .latest_ref() + .latest_arc() .language - .as_deref() - .map(String::from) - .unwrap_or_else(get_system_language); - - { - if let Ok(cache) = TRANSLATIONS.read() - && cache.0 == current_lang - && let Some(text) = cache.1.get(key).and_then(|val| val.as_str()) - { - return text.to_string(); - } - } - - if let Some(new_json) = load_lang_file(¤t_lang) - && let Ok(mut cache) = TRANSLATIONS.write() - { - *cache = (current_lang.clone(), new_json); - - if let Some(text) = cache.1.get(key).and_then(|val| val.as_str()) { - return text.to_string(); - } - } - - if current_lang != DEFAULT_LANGUAGE - && let Some(default_json) = load_lang_file(DEFAULT_LANGUAGE) - && let Ok(mut cache) = TRANSLATIONS.write() - { - *cache = (DEFAULT_LANGUAGE.to_string(), default_json); - - if let Some(text) = cache.1.get(key).and_then(|val| val.as_str()) { - return text.to_string(); - } - } - - key.to_string() + .clone() + .filter(|lang| !lang.is_empty()) + .and_then(|lang| resolve_supported_language(&lang)) + .unwrap_or_else(system_language) +} + +pub async fn sync_locale() -> String { + let language = current_language().await; + set_locale(&language); + language +} + +pub const fn default_language() -> &'static str { + fallback_language() } diff --git a/clash-verge-rev/src-tauri/src/utils/init.rs b/clash-verge-rev/src-tauri/src/utils/init.rs index 1758dfb06f..66503ff631 100644 --- a/clash-verge-rev/src-tauri/src/utils/init.rs +++ b/clash-verge-rev/src-tauri/src/utils/init.rs @@ -1,25 +1,27 @@ +// #[cfg(not(feature = "tracing"))] #[cfg(not(feature = "tauri-dev"))] use crate::utils::logging::NoModuleFilter; use crate::{ - config::*, + config::{Config, IClashTemp, IProfiles, IVerge}, + constants, core::handle, logging, process::AsyncHandler, utils::{ - dirs::{self, service_log_dir, sidecar_log_dir}, + dirs::{self, PathBufExec as _, service_log_dir, sidecar_log_dir}, help, logging::Type, }, }; use anyhow::Result; -use chrono::{Local, TimeZone}; +use chrono::{Local, TimeZone as _}; use clash_verge_service_ipc::WriterConfig; use flexi_logger::writers::FileLogWriter; use flexi_logger::{Cleanup, Criterion, FileSpec}; #[cfg(not(feature = "tauri-dev"))] use flexi_logger::{Duplicate, LogSpecBuilder, Logger}; -use std::{path::PathBuf, str::FromStr}; -use tauri_plugin_shell::ShellExt; +use std::{path::PathBuf, str::FromStr as _}; +use tauri_plugin_shell::ShellExt as _; use tokio::fs; use tokio::fs::DirEntry; @@ -29,7 +31,7 @@ pub async fn init_logger() -> Result<()> { // TODO 提供 runtime 级别实时修改 let (log_level, log_max_size, log_max_count) = { let verge_guard = Config::verge().await; - let verge = verge_guard.latest_ref(); + let verge = verge_guard.data_arc(); ( verge.get_log_level(), verge.app_log_max_size.unwrap_or(128), @@ -38,7 +40,19 @@ pub async fn init_logger() -> Result<()> { }; let log_dir = dirs::app_logs_dir()?; - let spec = LogSpecBuilder::new().default(log_level).build(); + let mut spec = LogSpecBuilder::new(); + let level = std::env::var("RUST_LOG") + .ok() + .and_then(|v| log::LevelFilter::from_str(&v).ok()) + .unwrap_or(log_level); + spec.default(level); + #[cfg(feature = "tracing")] + spec.module("tauri", log::LevelFilter::Debug); + #[cfg(feature = "tracing")] + spec.module("wry", log::LevelFilter::Off); + #[cfg(feature = "tracing")] + spec.module("tauri_plugin_mihomo", log::LevelFilter::Off); + let spec = spec.build(); let logger = Logger::with(spec) .log_to_file(FileSpec::default().directory(log_dir).basename("")) @@ -52,8 +66,15 @@ pub async fn init_logger() -> Result<()> { format: "%Y-%m-%d_%H-%M-%S", }, Cleanup::KeepLogFiles(log_max_count), - ) - .filter(Box::new(NoModuleFilter(&["wry", "tauri"]))); + ); + #[cfg(not(feature = "tracing"))] + let logger = logger.filter(Box::new(NoModuleFilter(&["wry", "tauri"]))); + #[cfg(feature = "tracing")] + let logger = logger.filter(Box::new(NoModuleFilter(&[ + "wry", + "tauri_plugin_mihomo", + "kode_bridge", + ]))); let _handle = logger.start()?; @@ -68,7 +89,7 @@ pub async fn init_logger() -> Result<()> { pub async fn sidecar_writer() -> Result { let (log_max_size, log_max_count) = { let verge_guard = Config::verge().await; - let verge = verge_guard.latest_ref(); + let verge = verge_guard.data_arc(); ( verge.app_log_max_size.unwrap_or(128), verge.app_log_max_count.unwrap_or(8), @@ -96,13 +117,13 @@ pub async fn sidecar_writer() -> Result { pub async fn service_writer_config() -> Result { let (log_max_size, log_max_count) = { let verge_guard = Config::verge().await; - let verge = verge_guard.latest_ref(); + let verge = verge_guard.data_arc(); ( verge.app_log_max_size.unwrap_or(128), verge.app_log_max_count.unwrap_or(8), ) }; - let service_log_dir = dirs::path_to_str(&service_log_dir()?)?.to_string(); + let service_log_dir = dirs::path_to_str(&service_log_dir()?)?.into(); Ok(WriterConfig { directory: service_log_dir, @@ -121,7 +142,7 @@ pub async fn delete_log() -> Result<()> { let auto_log_clean = { let verge = Config::verge().await; - let verge = verge.latest_ref(); + let verge = verge.data_arc(); verge.auto_log_clean.unwrap_or(0) }; @@ -147,9 +168,9 @@ pub async fn delete_log() -> Result<()> { let month = u32::from_str(sa[1])?; let day = u32::from_str(sa[2])?; let time = chrono::NaiveDate::from_ymd_opt(year, month, day) - .ok_or(anyhow::anyhow!("invalid time str"))? + .ok_or_else(|| anyhow::anyhow!("invalid time str"))? .and_hms_opt(0, 0, 0) - .ok_or(anyhow::anyhow!("invalid time str"))?; + .ok_or_else(|| anyhow::anyhow!("invalid time str"))?; Ok(time) }; @@ -163,12 +184,11 @@ pub async fn delete_log() -> Result<()> { let file_time = Local .from_local_datetime(&created_time) .single() - .ok_or(anyhow::anyhow!("invalid local datetime"))?; + .ok_or_else(|| anyhow::anyhow!("invalid local datetime"))?; let duration = now.signed_duration_since(file_time); if duration.num_days() > day { - let file_path = file.path(); - let _ = fs::remove_file(file_path).await; + let _ = file.path().remove_if_exists().await; logging!(info, Type::Setup, "delete log file: {}", file_name); } } @@ -293,7 +313,7 @@ async fn init_dns_config() -> Result<()> { // 检查DNS配置文件是否存在 let app_dir = dirs::app_home_dir()?; - let dns_path = app_dir.join("dns_config.yaml"); + let dns_path = app_dir.join(constants::files::DNS_CONFIG); if !dns_path.exists() { logging!(info, Type::Setup, "Creating default DNS config file"); @@ -353,7 +373,7 @@ async fn initialize_config_files() -> Result<()> { if let Ok(path) = dirs::profiles_path() && !path.exists() { - let template = IProfiles::template(); + let template = IProfiles::default(); help::save_yaml(&path, &template, Some("# Clash Verge")) .await .map_err(|e| anyhow::anyhow!("Failed to create profiles config: {}", e))?; @@ -418,26 +438,8 @@ pub async fn init_resources() -> Result<()> { let src_path = res_dir.join(file); let dest_path = app_dir.join(file); - let handle_copy = |src: PathBuf, dest: PathBuf, file: String| async move { - match fs::copy(&src, &dest).await { - Ok(_) => { - logging!(debug, Type::Setup, "resources copied '{}'", file); - } - Err(err) => { - logging!( - error, - Type::Setup, - "failed to copy resources '{}' to '{:?}', {}", - file, - dest, - err - ); - } - }; - }; - if src_path.exists() && !dest_path.exists() { - handle_copy(src_path.clone(), dest_path.clone(), file.to_string()).await; + handle_copy(&src_path, &dest_path, file).await; continue; } @@ -447,12 +449,12 @@ pub async fn init_resources() -> Result<()> { match (src_modified, dest_modified) { (Ok(src_modified), Ok(dest_modified)) => { if src_modified > dest_modified { - handle_copy(src_path.clone(), dest_path.clone(), file.to_string()).await; + handle_copy(&src_path, &dest_path, file).await; } } _ => { logging!(debug, Type::Setup, "failed to get modified '{}'", file); - handle_copy(src_path.clone(), dest_path.clone(), file.to_string()).await; + handle_copy(&src_path, &dest_path, file).await; } }; } @@ -464,7 +466,7 @@ pub async fn init_resources() -> Result<()> { #[cfg(target_os = "windows")] pub fn init_scheme() -> Result<()> { use tauri::utils::platform::current_exe; - use winreg::{RegKey, enums::*}; + use winreg::{RegKey, enums::HKEY_CURRENT_USER}; let app_exe = current_exe()?; let app_exe = dunce::canonicalize(app_exe)?; @@ -483,30 +485,40 @@ pub fn init_scheme() -> Result<()> { } #[cfg(target_os = "linux")] pub fn init_scheme() -> Result<()> { - let output = std::process::Command::new("xdg-mime") - .arg("default") - .arg("clash-verge.desktop") - .arg("x-scheme-handler/clash") - .output()?; - if !output.status.success() { - return Err(anyhow::anyhow!( - "failed to set clash scheme, {}", - String::from_utf8_lossy(&output.stderr) - )); + const DESKTOP_FILE: &str = "clash-verge.desktop"; + + for scheme in DEEP_LINK_SCHEMES { + let handler = format!("x-scheme-handler/{scheme}"); + let output = std::process::Command::new("xdg-mime") + .arg("default") + .arg(DESKTOP_FILE) + .arg(&handler) + .output()?; + if !output.status.success() { + return Err(anyhow::anyhow!( + "failed to set {handler}, {}", + String::from_utf8_lossy(&output.stderr) + )); + } } + + crate::utils::linux::ensure_mimeapps_entries(DESKTOP_FILE, DEEP_LINK_SCHEMES)?; Ok(()) } #[cfg(target_os = "macos")] -pub fn init_scheme() -> Result<()> { +pub const fn init_scheme() -> Result<()> { Ok(()) } +#[cfg(target_os = "linux")] +const DEEP_LINK_SCHEMES: &[&str] = &["clash", "clash-verge"]; + pub async fn startup_script() -> Result<()> { let app_handle = handle::Handle::app_handle(); let script_path = { let verge = Config::verge().await; - let verge = verge.latest_ref(); - verge.startup_script.clone().unwrap_or("".to_string()) + let verge = verge.data_arc(); + verge.startup_script.clone().unwrap_or_else(|| "".into()) }; if script_path.is_empty() { @@ -524,21 +536,39 @@ pub async fn startup_script() -> Result<()> { )); }; - let script_dir = PathBuf::from(&script_path); + let script_dir = PathBuf::from(script_path.as_str()); if !script_dir.exists() { return Err(anyhow::anyhow!("script not found: {}", script_path)); } let parent_dir = script_dir.parent(); - let working_dir = parent_dir.unwrap_or(script_dir.as_ref()); + let working_dir = parent_dir.unwrap_or_else(|| script_dir.as_ref()); app_handle .shell() .command(shell_type) .current_dir(working_dir) - .args(&[script_path]) + .args([script_path.as_str()]) .output() .await?; Ok(()) } + +async fn handle_copy(src: &PathBuf, dest: &PathBuf, file: &str) { + match fs::copy(src, dest).await { + Ok(_) => { + logging!(debug, Type::Setup, "resources copied '{}'", file); + } + Err(err) => { + logging!( + error, + Type::Setup, + "failed to copy resources '{}' to '{:?}', {}", + file, + dest, + err + ); + } + }; +} diff --git a/clash-verge-rev/src-tauri/src/utils/linux.rs b/clash-verge-rev/src-tauri/src/utils/linux.rs new file mode 100644 index 0000000000..e3ced3871c --- /dev/null +++ b/clash-verge-rev/src-tauri/src/utils/linux.rs @@ -0,0 +1,792 @@ +use crate::logging; +use crate::utils::logging::Type; +use anyhow::Result; +use std::collections::{HashMap, HashSet}; +use std::env; +use std::fs; +use std::path::PathBuf; + +const DRM_PATH: &str = "/sys/class/drm"; +const INTEL_VENDOR_ID: &str = "0x8086"; +const NVIDIA_VENDOR_ID: &str = "0x10de"; +const NVIDIA_VERSION_PATH: &str = "/proc/driver/nvidia/version"; + +#[derive(Debug, Default, Clone, Copy)] +struct IntelGpuDetection { + has_intel: bool, + intel_is_primary: bool, + inconclusive: bool, +} + +impl IntelGpuDetection { + const fn should_disable_dmabuf(&self) -> bool { + self.intel_is_primary || self.inconclusive + } +} + +#[derive(Debug, Default, Clone)] +struct NvidiaGpuDetection { + has_nvidia: bool, + nvidia_is_primary: bool, + missing_boot_vga: bool, + open_kernel_module: bool, + driver_summary: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NvidiaDmabufDisableReason { + PrimaryOpenKernelModule, + MissingBootVga, + PreferNativeWayland, +} + +impl NvidiaGpuDetection { + const fn disable_reason(&self, session: &SessionEnv) -> Option { + if !session.is_wayland { + return None; + } + + if !self.has_nvidia { + return None; + } + + if !self.open_kernel_module { + return None; + } + + if self.nvidia_is_primary { + return Some(NvidiaDmabufDisableReason::PrimaryOpenKernelModule); + } + + if self.missing_boot_vga { + return Some(NvidiaDmabufDisableReason::MissingBootVga); + } + + if session.prefer_native_wayland { + return Some(NvidiaDmabufDisableReason::PreferNativeWayland); + } + + None + } +} + +#[derive(Debug)] +struct SessionEnv { + is_kde_plasma: bool, + is_wayland: bool, + prefer_native_wayland: bool, + compositor_label: String, +} + +impl SessionEnv { + fn gather() -> Self { + let desktop_env = env::var("XDG_CURRENT_DESKTOP") + .unwrap_or_default() + .to_uppercase(); + let session_desktop = env::var("XDG_SESSION_DESKTOP") + .unwrap_or_default() + .to_uppercase(); + let desktop_session = env::var("DESKTOP_SESSION") + .unwrap_or_default() + .to_uppercase(); + + let is_kde_plasma = desktop_env.contains("KDE") + || session_desktop.contains("KDE") + || desktop_session.contains("KDE") + || desktop_env.contains("PLASMA") + || session_desktop.contains("PLASMA") + || desktop_session.contains("PLASMA"); + let is_hyprland = desktop_env.contains("HYPR") + || session_desktop.contains("HYPR") + || desktop_session.contains("HYPR"); + let is_wayland = env::var("XDG_SESSION_TYPE") + .map(|value| value.eq_ignore_ascii_case("wayland")) + .unwrap_or(false) + || env::var("WAYLAND_DISPLAY").is_ok(); + let prefer_native_wayland = is_wayland && (is_kde_plasma || is_hyprland); + let compositor_label = if is_hyprland { + String::from("Hyprland") + } else if is_kde_plasma { + String::from("KDE Plasma") + } else { + String::from("Wayland compositor") + }; + + Self { + is_kde_plasma, + is_wayland, + prefer_native_wayland, + compositor_label, + } + } +} + +#[derive(Debug)] +struct DmabufOverrides { + user_preference: Option, + dmabuf_override: Option, +} + +impl DmabufOverrides { + fn gather() -> Self { + let user_preference = env::var("CLASH_VERGE_DMABUF").ok().and_then(|value| { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "enable" | "on" => Some(true), + "0" | "false" | "disable" | "off" => Some(false), + _ => None, + } + }); + let dmabuf_override = env::var("WEBKIT_DISABLE_DMABUF_RENDERER").ok(); + + Self { + user_preference, + dmabuf_override, + } + } + + const fn has_env_override(&self) -> bool { + self.dmabuf_override.is_some() + } + + const fn should_override_env(&self, decision: &DmabufDecision) -> bool { + if self.user_preference.is_some() { + return true; + } + + if decision.enable_dmabuf { + return true; + } + + !self.has_env_override() + } +} + +#[derive(Debug)] +struct DmabufDecision { + enable_dmabuf: bool, + force_x11_backend: bool, + warn: bool, + message: Option, +} + +impl DmabufDecision { + fn resolve( + session: &SessionEnv, + overrides: &DmabufOverrides, + intel_gpu: IntelGpuDetection, + nvidia_gpu: &NvidiaGpuDetection, + ) -> Self { + let mut decision = Self { + enable_dmabuf: true, + force_x11_backend: false, + warn: false, + message: None, + }; + + match overrides.user_preference { + Some(true) => { + decision.enable_dmabuf = true; + decision.message = + Some("CLASH_VERGE_DMABUF=1: 强制启用 WebKit DMABUF 渲染。".into()); + } + Some(false) => { + decision.enable_dmabuf = false; + decision.message = + Some("CLASH_VERGE_DMABUF=0: 强制禁用 WebKit DMABUF 渲染。".into()); + if session.is_wayland && !session.prefer_native_wayland { + decision.force_x11_backend = true; + } + } + None => { + if overrides.has_env_override() { + if overrides.dmabuf_override.as_deref() == Some("1") { + decision.enable_dmabuf = false; + decision.message = Some( + "检测到 WEBKIT_DISABLE_DMABUF_RENDERER=1,沿用用户的软件渲染配置。" + .into(), + ); + if session.is_wayland && !session.prefer_native_wayland { + decision.force_x11_backend = true; + } + } else { + decision.enable_dmabuf = true; + let value = overrides.dmabuf_override.clone().unwrap_or_default(); + decision.message = Some(format!( + "检测到 WEBKIT_DISABLE_DMABUF_RENDERER={},沿用用户配置。", + value + )); + } + } else if let Some(reason) = nvidia_gpu.disable_reason(session) { + decision.enable_dmabuf = false; + decision.warn = true; + if session.is_wayland && !session.prefer_native_wayland { + decision.force_x11_backend = true; + } + let summary = nvidia_gpu + .driver_summary + .as_deref() + .and_then(|line| { + extract_nvidia_driver_version(line) + .map(|version| format!("NVIDIA Open Kernel Module {}", version)) + }) + .unwrap_or_else(|| String::from("NVIDIA Open Kernel Module")); + let message = match reason { + NvidiaDmabufDisableReason::PrimaryOpenKernelModule => format!( + "Wayland 会话检测到 {}:禁用 WebKit DMABUF 渲染以规避协议错误。", + summary + ), + NvidiaDmabufDisableReason::MissingBootVga => format!( + "Wayland 会话检测到 {},但缺少 boot_vga 信息:预防性禁用 WebKit DMABUF。", + summary + ), + NvidiaDmabufDisableReason::PreferNativeWayland => format!( + "Wayland ({}) + {}:检测到 NVIDIA Open Kernel Module 在辅 GPU 上运行,预防性禁用 WebKit DMABUF。", + session.compositor_label, summary + ), + }; + decision.message = Some(message); + } else if session.prefer_native_wayland && !intel_gpu.should_disable_dmabuf() { + decision.enable_dmabuf = true; + decision.message = Some(format!( + "Wayland + {} detected: 使用原生 DMABUF 渲染。", + session.compositor_label + )); + } else { + decision.enable_dmabuf = false; + if session.is_wayland && !session.prefer_native_wayland { + decision.force_x11_backend = true; + } + + if intel_gpu.should_disable_dmabuf() && session.is_wayland { + decision.warn = true; + if intel_gpu.inconclusive { + decision.message = Some("Wayland 上检测到 Intel GPU,但缺少 boot_vga 信息:预防性禁用 WebKit DMABUF,若确认非主 GPU 可通过 CLASH_VERGE_DMABUF=1 覆盖。".into()); + } else { + decision.message = Some( + "Wayland 上检测到 Intel 主 GPU (0x8086):禁用 WebKit DMABUF 以避免帧缓冲失败。".into(), + ); + } + } else if session.is_wayland { + decision.message = Some( + "Wayland 会话未匹配受支持的合成器:禁用 WebKit DMABUF 渲染。".into(), + ); + } else { + decision.message = + Some("禁用 WebKit DMABUF 渲染以获得更稳定的输出。".into()); + } + } + } + } + + decision + } +} + +fn detect_intel_gpu() -> IntelGpuDetection { + let Ok(entries) = fs::read_dir(DRM_PATH) else { + return IntelGpuDetection::default(); + }; + + let mut detection = IntelGpuDetection::default(); + let mut seen_devices: HashSet = HashSet::new(); + let mut missing_boot_vga = false; + + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + + if !(name.starts_with("renderD") || name.starts_with("card")) { + continue; + } + + let device_path = entry.path().join("device"); + let device_key = fs::canonicalize(&device_path).unwrap_or(device_path); + + if !seen_devices.insert(device_key.clone()) { + continue; + } + + let vendor_path = device_key.join("vendor"); + let Ok(vendor) = fs::read_to_string(&vendor_path) else { + continue; + }; + + if !vendor.trim().eq_ignore_ascii_case(INTEL_VENDOR_ID) { + continue; + } + + detection.has_intel = true; + + let boot_vga_path = device_key.join("boot_vga"); + match fs::read_to_string(&boot_vga_path) { + Ok(flag) => { + if flag.trim() == "1" { + detection.intel_is_primary = true; + } + } + Err(_) => { + missing_boot_vga = true; + } + } + } + + if detection.has_intel && !detection.intel_is_primary && missing_boot_vga { + detection.inconclusive = true; + } + + detection +} + +fn detect_nvidia_gpu() -> NvidiaGpuDetection { + let mut detection = NvidiaGpuDetection::default(); + let entries = match fs::read_dir(DRM_PATH) { + Ok(entries) => entries, + Err(err) => { + logging!( + info, + Type::Setup, + "无法读取 DRM 设备目录 {}({}),尝试通过 NVIDIA 驱动摘要进行降级检测。", + DRM_PATH, + err + ); + detection.driver_summary = read_nvidia_driver_summary(); + if let Some(summary) = detection.driver_summary.as_ref() { + detection.open_kernel_module = summary_indicates_open_kernel_module(summary); + detection.has_nvidia = true; + detection.missing_boot_vga = true; + } else { + logging!( + info, + Type::Setup, + "降级检测失败:未能读取 NVIDIA 驱动摘要,保留 WebKit DMABUF。" + ); + } + return detection; + } + }; + + let mut seen_devices: HashSet = HashSet::new(); + + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + + if !(name.starts_with("renderD") || name.starts_with("card")) { + continue; + } + + let device_path = entry.path().join("device"); + let device_key = fs::canonicalize(&device_path).unwrap_or(device_path); + + if !seen_devices.insert(device_key.clone()) { + continue; + } + + let vendor_path = device_key.join("vendor"); + let Ok(vendor) = fs::read_to_string(&vendor_path) else { + continue; + }; + + if !vendor.trim().eq_ignore_ascii_case(NVIDIA_VENDOR_ID) { + continue; + } + + detection.has_nvidia = true; + + let boot_vga_path = device_key.join("boot_vga"); + match fs::read_to_string(&boot_vga_path) { + Ok(flag) => { + if flag.trim() == "1" { + detection.nvidia_is_primary = true; + } + } + Err(_) => { + detection.missing_boot_vga = true; + } + } + } + + if detection.has_nvidia { + detection.driver_summary = read_nvidia_driver_summary(); + match detection.driver_summary.as_ref() { + Some(summary) => { + detection.open_kernel_module = summary_indicates_open_kernel_module(summary); + } + None => { + logging!( + info, + Type::Setup, + "检测到 NVIDIA 设备,但无法读取 {},默认视为未启用开源内核模块。", + NVIDIA_VERSION_PATH + ); + } + } + } + + detection +} + +fn read_nvidia_driver_summary() -> Option { + match fs::read_to_string(NVIDIA_VERSION_PATH) { + Ok(content) => content + .lines() + .next() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()), + Err(err) => { + logging!( + info, + Type::Setup, + "读取 {} 失败:{}", + NVIDIA_VERSION_PATH, + err + ); + None + } + } +} + +fn summary_indicates_open_kernel_module(summary: &str) -> bool { + let normalized = summary.to_ascii_lowercase(); + const PATTERNS: [&str; 4] = [ + "open kernel module", + "open kernel modules", + "open gpu kernel module", + "open gpu kernel modules", + ]; + + let is_open = PATTERNS.iter().any(|pattern| normalized.contains(pattern)); + + if !is_open && normalized.contains("open") { + logging!( + info, + Type::Setup, + "检测到 NVIDIA 驱动摘要包含 open 关键字但未匹配已知开源模块格式:{}", + summary + ); + } + + is_open +} + +fn extract_nvidia_driver_version(summary: &str) -> Option<&str> { + summary + .split_whitespace() + .find(|token| token.chars().all(|c| c.is_ascii_digit() || c == '.')) +} + +pub fn ensure_mimeapps_entries(desktop_file: &str, schemes: &[&str]) -> Result<()> { + let Some(path) = mimeapps_list_path() else { + return Ok(()); + }; + + if !path.exists() { + return Ok(()); + } + + let original = fs::read_to_string(&path)?; + let mut changed = false; + + let mut output_lines: Vec = Vec::new(); + let mut current_section: Option = None; + let mut section_buffer: Vec = Vec::new(); + let mut default_present = false; + + for line in original.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + if let Some(kind) = current_section.take() { + flush_section( + &mut output_lines, + &mut section_buffer, + desktop_file, + schemes, + kind, + &mut changed, + ); + } + + if trimmed.eq_ignore_ascii_case("[Default Applications]") { + default_present = true; + current_section = Some(SectionKind::DefaultApplications); + output_lines.push("[Default Applications]".to_string()); + continue; + } else if trimmed.eq_ignore_ascii_case("[Added Associations]") { + current_section = Some(SectionKind::AddedAssociations); + output_lines.push("[Added Associations]".to_string()); + continue; + } + } + + if current_section.is_some() { + section_buffer.push(line.to_string()); + } else { + output_lines.push(line.to_string()); + } + } + + if let Some(kind) = current_section.take() { + flush_section( + &mut output_lines, + &mut section_buffer, + desktop_file, + schemes, + kind, + &mut changed, + ); + } + + if !default_present { + changed = true; + if output_lines.last().is_some_and(|line| !line.is_empty()) { + output_lines.push(String::new()); + } + output_lines.push("[Default Applications]".to_string()); + for &scheme in schemes { + output_lines.push(format!("x-scheme-handler/{scheme}={desktop_file};")); + } + } + + if !changed { + return Ok(()); + } + + let mut new_content = output_lines.join("\n"); + if !new_content.ends_with('\n') { + new_content.push('\n'); + } + + fs::write(path, new_content)?; + Ok(()) +} + +fn mimeapps_list_path() -> Option { + let config_path = env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| { + env::var_os("HOME").map(PathBuf::from).map(|mut home| { + home.push(".config"); + home + }) + }) + .map(|mut dir| { + dir.push("mimeapps.list"); + dir + }); + + if config_path.as_ref().is_some_and(|path| path.exists()) { + return config_path; + } + + let data_path = env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| { + env::var_os("HOME").map(PathBuf::from).map(|mut home| { + home.push(".local"); + home.push("share"); + home + }) + }) + .map(|mut dir| { + dir.push("applications"); + dir.push("mimeapps.list"); + dir + }); + + if data_path.as_ref().is_some_and(|path| path.exists()) { + return data_path; + } + + config_path +} + +#[derive(Clone, Copy)] +enum SectionKind { + DefaultApplications, + AddedAssociations, +} + +fn flush_section( + output: &mut Vec, + section: &mut Vec, + desktop_file: &str, + schemes: &[&str], + kind: SectionKind, + changed: &mut bool, +) { + let mut seen: HashMap<&str, usize> = HashMap::new(); + let mut processed: Vec = Vec::with_capacity(section.len()); + + for line in section.drain(..) { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + processed.push(line); + continue; + } + + let Some((raw_key, raw_value)) = trimmed.split_once('=') else { + processed.push(line); + continue; + }; + + if let Some(scheme) = match_scheme(raw_key.trim(), schemes) { + let mut values: Vec = raw_value + .split(';') + .filter_map(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) + .collect(); + + if let Some(&index) = seen.get(scheme) { + let existing_line = &mut processed[index]; + let existing_prefix: String = existing_line + .chars() + .take_while(|c| c.is_whitespace()) + .collect(); + let Some((_, existing_raw_value)) = existing_line.trim().split_once('=') else { + processed.push(line); + continue; + }; + + let mut merged_values: Vec = existing_raw_value + .split(';') + .filter_map(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) + .collect(); + + for value in values { + if !merged_values.iter().any(|existing| existing == &value) { + merged_values.push(value); + } + } + + if let Some(pos) = merged_values.iter().position(|value| value == desktop_file) { + if pos != 0 { + let moved = merged_values.remove(pos); + merged_values.insert(0, moved); + } + } else { + merged_values.insert(0, desktop_file.to_string()); + } + + let mut merged_line = format!("{existing_prefix}x-scheme-handler/{scheme}="); + merged_line.push_str(&merged_values.join(";")); + merged_line.push(';'); + + if *existing_line != merged_line { + *existing_line = merged_line; + } + + // Dropping the duplicate entry alters the section even if nothing new was added. + *changed = true; + continue; + } + + if let Some(pos) = values.iter().position(|value| value == desktop_file) { + if pos != 0 { + values.remove(pos); + values.insert(0, desktop_file.to_string()); + *changed = true; + } + } else { + values.insert(0, desktop_file.to_string()); + *changed = true; + } + + let prefix = line + .chars() + .take_while(|c| c.is_whitespace()) + .collect::(); + let mut new_line = format!("{prefix}x-scheme-handler/{scheme}="); + new_line.push_str(&values.join(";")); + new_line.push(';'); + + if new_line != line { + *changed = true; + } + + let index = processed.len(); + processed.push(new_line); + seen.insert(scheme, index); + continue; + } + + processed.push(line); + } + + let ensure_all = matches!( + kind, + SectionKind::DefaultApplications | SectionKind::AddedAssociations + ); + + if ensure_all { + for &scheme in schemes { + if !seen.contains_key(scheme) { + processed.push(format!("x-scheme-handler/{scheme}={desktop_file};")); + *changed = true; + } + } + } + + output.extend(processed); +} + +fn match_scheme<'a>(key: &str, schemes: &'a [&str]) -> Option<&'a str> { + if let Some(rest) = key.strip_prefix("x-scheme-handler/") { + return schemes.iter().copied().find(|candidate| *candidate == rest); + } + + schemes.iter().copied().find(|candidate| *candidate == key) +} + +pub fn configure_environment() { + let session = SessionEnv::gather(); + let overrides = DmabufOverrides::gather(); + let intel_gpu = detect_intel_gpu(); + let nvidia_gpu = detect_nvidia_gpu(); + let decision = DmabufDecision::resolve(&session, &overrides, intel_gpu, &nvidia_gpu); + + if overrides.should_override_env(&decision) { + unsafe { + if decision.enable_dmabuf { + env::remove_var("WEBKIT_DISABLE_DMABUF_RENDERER"); + } else { + env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } + } + } + + if let Some(message) = decision.message { + if decision.warn { + logging!(warn, Type::Setup, "{}", message); + } else { + logging!(info, Type::Setup, "{}", message); + } + } + + if decision.force_x11_backend { + unsafe { + env::set_var("GDK_BACKEND", "x11"); + env::remove_var("WAYLAND_DISPLAY"); + } + logging!( + info, + Type::Setup, + "Wayland detected: Forcing X11 backend for WebKit stability." + ); + } + + if session.is_kde_plasma { + unsafe { + env::set_var("GTK_CSD", "0"); + } + logging!( + info, + Type::Setup, + "KDE/Plasma detected: Disabled GTK CSD for better titlebar stability." + ); + } +} diff --git a/clash-verge-rev/src-tauri/src/utils/logging.rs b/clash-verge-rev/src-tauri/src/utils/logging.rs index dd8d8cf7b5..7b5de419ba 100644 --- a/clash-verge-rev/src-tauri/src/utils/logging.rs +++ b/clash-verge-rev/src-tauri/src/utils/logging.rs @@ -1,14 +1,17 @@ +use compact_str::CompactString; +use flexi_logger::DeferredNow; +#[cfg(not(feature = "tauri-dev"))] +use flexi_logger::filter::LogLineFilter; use flexi_logger::writers::FileLogWriter; -#[cfg(not(feature = "tauri-dev"))] -use flexi_logger::{DeferredNow, filter::LogLineFilter}; -#[cfg(not(feature = "tauri-dev"))] +use flexi_logger::writers::LogWriter as _; +use log::Level; use log::Record; use std::{fmt, sync::Arc}; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, MutexGuard}; pub type SharedWriter = Arc>; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq)] pub enum Type { Cmd, Core, @@ -22,33 +25,35 @@ pub enum Type { Timer, Frontend, Backup, + File, Lightweight, Network, ProxyMode, - // Cache, + Validate, ClashVergeRev, } impl fmt::Display for Type { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Type::Cmd => write!(f, "[Cmd]"), - Type::Core => write!(f, "[Core]"), - Type::Config => write!(f, "[Config]"), - Type::Setup => write!(f, "[Setup]"), - Type::System => write!(f, "[System]"), - Type::Service => write!(f, "[Service]"), - Type::Hotkey => write!(f, "[Hotkey]"), - Type::Window => write!(f, "[Window]"), - Type::Tray => write!(f, "[Tray]"), - Type::Timer => write!(f, "[Timer]"), - Type::Frontend => write!(f, "[Frontend]"), - Type::Backup => write!(f, "[Backup]"), - Type::Lightweight => write!(f, "[Lightweight]"), - Type::Network => write!(f, "[Network]"), - Type::ProxyMode => write!(f, "[ProxMode]"), - // Type::Cache => write!(f, "[Cache]"), - Type::ClashVergeRev => write!(f, "[ClashVergeRev]"), + Self::Cmd => write!(f, "[Cmd]"), + Self::Core => write!(f, "[Core]"), + Self::Config => write!(f, "[Config]"), + Self::Setup => write!(f, "[Setup]"), + Self::System => write!(f, "[System]"), + Self::Service => write!(f, "[Service]"), + Self::Hotkey => write!(f, "[Hotkey]"), + Self::Window => write!(f, "[Window]"), + Self::Tray => write!(f, "[Tray]"), + Self::Timer => write!(f, "[Timer]"), + Self::Frontend => write!(f, "[Frontend]"), + Self::Backup => write!(f, "[Backup]"), + Self::File => write!(f, "[File]"), + Self::Lightweight => write!(f, "[Lightweight]"), + Self::Network => write!(f, "[Network]"), + Self::ProxyMode => write!(f, "[ProxMode]"), + Self::Validate => write!(f, "[Validate]"), + Self::ClashVergeRev => write!(f, "[ClashVergeRev]"), } } } @@ -75,15 +80,6 @@ macro_rules! log_err { }; } -#[macro_export] -macro_rules! trace_err { - ($result: expr, $err_str: expr) => { - if let Err(err) = $result { - log::trace!(target: "app", "{}, err {}", $err_str, err); - } - } -} - /// wrap the anyhow error /// transform the error to String #[macro_export] @@ -91,21 +87,10 @@ macro_rules! wrap_err { // Case 1: Future> ($stat:expr, async) => {{ match $stat.await { - Ok(a) => Ok(a), + Ok(a) => Ok::<_, ::anyhow::Error>(a), Err(err) => { log::error!(target: "app", "{}", err); - Err(err.to_string()) - } - } - }}; - - // Case 2: Result - ($stat:expr) => {{ - match $stat { - Ok(a) => Ok(a), - Err(err) => { - log::error!(target: "app", "{}", err); - Err(err.to_string()) + Err(::anyhow::Error::msg(err.to_string())) } } }}; @@ -115,7 +100,7 @@ macro_rules! wrap_err { macro_rules! logging { // 不带 print 参数的版本(默认不打印) ($level:ident, $type:expr, $($arg:tt)*) => { - log::$level!(target: "app", "{} {}", $type, format_args!($($arg)*)); + log::$level!(target: "app", "{} {}", $type, format_args!($($arg)*)) }; } @@ -134,6 +119,23 @@ macro_rules! logging_error { }; } +pub fn write_sidecar_log( + writer: MutexGuard<'_, FileLogWriter>, + now: &mut DeferredNow, + level: Level, + message: &CompactString, +) { + let args = format_args!("{}", message); + + let record = Record::builder() + .args(args) + .level(level) + .target("sidecar") + .build(); + + let _ = writer.write(now, &record); +} + #[cfg(not(feature = "tauri-dev"))] pub struct NoModuleFilter<'a>(pub &'a [&'a str]); diff --git a/clash-verge-rev/src-tauri/src/utils/mod.rs b/clash-verge-rev/src-tauri/src/utils/mod.rs index 74b895bdb9..7c3e1abac1 100644 --- a/clash-verge-rev/src-tauri/src/utils/mod.rs +++ b/clash-verge-rev/src-tauri/src/utils/mod.rs @@ -1,9 +1,12 @@ pub mod autostart; pub mod dirs; +pub mod draft; pub mod format; pub mod help; pub mod i18n; pub mod init; +#[cfg(target_os = "linux")] +pub mod linux; pub mod logging; pub mod network; pub mod notification; @@ -12,3 +15,5 @@ pub mod server; pub mod singleton; pub mod tmpl; pub mod window_manager; + +pub use draft::Draft; diff --git a/clash-verge-rev/src-tauri/src/utils/network.rs b/clash-verge-rev/src-tauri/src/utils/network.rs index 1df28399a2..d48bb2a6f9 100644 --- a/clash-verge-rev/src-tauri/src/utils/network.rs +++ b/clash-verge-rev/src-tauri/src/utils/network.rs @@ -1,21 +1,15 @@ +use crate::config::Config; use anyhow::Result; use base64::{Engine as _, engine::general_purpose}; -use isahc::prelude::*; -use isahc::{HttpClient, config::SslOption}; -use isahc::{ - config::RedirectPolicy, - http::{ - StatusCode, Uri, - header::{HeaderMap, HeaderValue, USER_AGENT}, - }, +use reqwest::{ + Client, Proxy, StatusCode, + header::{HeaderMap, HeaderValue, USER_AGENT}, }; +use smartstring::alias::String; use std::time::{Duration, Instant}; use sysproxy::Sysproxy; use tauri::Url; use tokio::sync::Mutex; -use tokio::time::timeout; - -use crate::config::Config; #[derive(Debug)] pub struct HttpResponse { @@ -25,7 +19,7 @@ pub struct HttpResponse { } impl HttpResponse { - pub fn new(status: StatusCode, headers: HeaderMap, body: String) -> Self { + pub const fn new(status: StatusCode, headers: HeaderMap, body: String) -> Self { Self { status, headers, @@ -33,11 +27,11 @@ impl HttpResponse { } } - pub fn status(&self) -> StatusCode { + pub const fn status(&self) -> StatusCode { self.status } - pub fn headers(&self) -> &HeaderMap { + pub const fn headers(&self) -> &HeaderMap { &self.headers } @@ -54,13 +48,19 @@ pub enum ProxyType { } pub struct NetworkManager { - self_proxy_client: Mutex>, - system_proxy_client: Mutex>, - no_proxy_client: Mutex>, + self_proxy_client: Mutex>, + system_proxy_client: Mutex>, + no_proxy_client: Mutex>, last_connection_error: Mutex>, connection_error_count: Mutex, } +impl Default for NetworkManager { + fn default() -> Self { + Self::new() + } +} + impl NetworkManager { pub fn new() -> Self { Self { @@ -73,8 +73,7 @@ impl NetworkManager { } async fn record_connection_error(&self, error: &str) { - let mut last_error = self.last_connection_error.lock().await; - *last_error = Some((Instant::now(), error.to_string())); + *self.last_connection_error.lock().await = Some((Instant::now(), error.into())); let mut count = self.connection_error_count.lock().await; *count += 1; @@ -82,13 +81,11 @@ impl NetworkManager { async fn should_reset_clients(&self) -> bool { let count = *self.connection_error_count.lock().await; - let last_error_guard = self.last_connection_error.lock().await; - if count > 5 { return true; } - if let Some((time, _)) = &*last_error_guard + if let Some((time, _)) = &*self.last_connection_error.lock().await && time.elapsed() < Duration::from_secs(30) && count > 2 { @@ -107,38 +104,42 @@ impl NetworkManager { fn build_client( &self, - proxy_uri: Option, + proxy_url: Option, default_headers: HeaderMap, accept_invalid_certs: bool, timeout_secs: Option, - ) -> Result { - let proxy_uri_clone = proxy_uri.clone(); - let headers_clone = default_headers.clone(); + ) -> Result { + let mut builder = Client::builder() + .redirect(reqwest::redirect::Policy::limited(10)) + .tcp_keepalive(Duration::from_secs(60)) + .pool_max_idle_per_host(0) + .pool_idle_timeout(None); - { - let mut builder = HttpClient::builder(); - - builder = match proxy_uri_clone { - Some(uri) => builder.proxy(Some(uri)), - None => builder.proxy(None), - }; - - for (name, value) in headers_clone.iter() { - builder = builder.default_header(name, value); - } - - if accept_invalid_certs { - builder = builder.ssl_options(SslOption::DANGER_ACCEPT_INVALID_CERTS); - } - - if let Some(secs) = timeout_secs { - builder = builder.timeout(Duration::from_secs(secs)); - } - - builder = builder.redirect_policy(RedirectPolicy::Follow); - - Ok(builder.build()?) + // 设置代理 + if let Some(proxy_str) = proxy_url { + let proxy = Proxy::all(proxy_str)?; + builder = builder.proxy(proxy); + } else { + builder = builder.no_proxy(); } + + builder = builder.default_headers(default_headers); + + // SSL/TLS + if accept_invalid_certs { + builder = builder + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true); + } + + // 超时设置 + if let Some(secs) = timeout_secs { + builder = builder + .timeout(Duration::from_secs(secs)) + .connect_timeout(Duration::from_secs(secs.min(30))); + } + + Ok(builder.build()?) } pub async fn create_request( @@ -147,24 +148,22 @@ impl NetworkManager { timeout_secs: Option, user_agent: Option, accept_invalid_certs: bool, - ) -> Result { - let proxy_uri = match proxy_type { + ) -> Result { + let proxy_url: Option = match proxy_type { ProxyType::None => None, ProxyType::Localhost => { let port = { - let verge_port = Config::verge().await.latest_ref().verge_mixed_port; + let verge_port = Config::verge().await.data_arc().verge_mixed_port; match verge_port { Some(port) => port, - None => Config::clash().await.latest_ref().get_mixed_port(), + None => Config::clash().await.data_arc().get_mixed_port(), } }; - let proxy_scheme = format!("http://127.0.0.1:{port}"); - proxy_scheme.parse::().ok() + Some(format!("http://127.0.0.1:{port}")) } ProxyType::System => { if let Ok(p @ Sysproxy { enable: true, .. }) = Sysproxy::get_system_proxy() { - let proxy_scheme = format!("http://{}:{}", p.host, p.port); - proxy_scheme.parse::().ok() + Some(format!("http://{}:{}", p.host, p.port)) } else { None } @@ -172,15 +171,18 @@ impl NetworkManager { }; let mut headers = HeaderMap::new(); - headers.insert( - USER_AGENT, - HeaderValue::from_str( - &user_agent - .unwrap_or_else(|| format!("clash-verge/v{}", env!("CARGO_PKG_VERSION"))), - )?, - ); - let client = self.build_client(proxy_uri, headers, accept_invalid_certs, timeout_secs)?; + // 设置 User-Agent + if let Some(ua) = user_agent { + headers.insert(USER_AGENT, HeaderValue::from_str(ua.as_str())?); + } else { + headers.insert( + USER_AGENT, + HeaderValue::from_str(&format!("clash-verge/v{}", env!("CARGO_PKG_VERSION")))?, + ); + } + + let client = self.build_client(proxy_url, headers, accept_invalid_certs, timeout_secs)?; Ok(client) } @@ -218,37 +220,37 @@ impl NetworkManager { no_auth.to_string() }; + // 创建请求 let client = self .create_request(proxy_type, timeout_secs, user_agent, accept_invalid_certs) .await?; - let timeout_duration = Duration::from_secs(timeout_secs.unwrap_or(20)); - let response = match timeout(timeout_duration, async { - let mut req = isahc::Request::get(&clean_url); + let mut request_builder = client.get(&clean_url); - for (k, v) in extra_headers.iter() { - req = req.header(k, v); - } + for (key, value) in extra_headers.iter() { + request_builder = request_builder.header(key, value); + } - let mut response = client.send_async(req.body(())?).await?; - let status = response.status(); - let headers = response.headers().clone(); - let body = response.text().await?; - Ok::<_, anyhow::Error>(HttpResponse::new(status, headers, body)) - }) - .await - { - Ok(res) => res?, - Err(_) => { - self.record_connection_error(&format!("Request interrupted: {}", url)) + let response = match request_builder.send().await { + Ok(resp) => resp, + Err(e) => { + self.record_connection_error(&format!("Request failed: {}", e)) .await; - return Err(anyhow::anyhow!( - "Request interrupted after {}s", - timeout_duration.as_secs() - )); + return Err(anyhow::anyhow!("Request failed: {}", e)); } }; - Ok(response) + let status = response.status(); + let headers = response.headers().clone(); + let body = match response.text().await { + Ok(text) => text.into(), + Err(e) => { + self.record_connection_error(&format!("Failed to read response body: {}", e)) + .await; + return Err(anyhow::anyhow!("Failed to read response body: {}", e)); + } + }; + + Ok(HttpResponse::new(status, headers, body)) } } diff --git a/clash-verge-rev/src-tauri/src/utils/notification.rs b/clash-verge-rev/src-tauri/src/utils/notification.rs index 0b0b719962..d7ff371a5d 100644 --- a/clash-verge-rev/src-tauri/src/utils/notification.rs +++ b/clash-verge-rev/src-tauri/src/utils/notification.rs @@ -1,7 +1,5 @@ -use crate::utils::i18n::t; - -use tauri::AppHandle; -use tauri_plugin_notification::NotificationExt; +use crate::{core::handle, utils::i18n}; +use tauri_plugin_notification::NotificationExt as _; pub enum NotificationEvent<'a> { DashboardToggled, @@ -16,8 +14,10 @@ pub enum NotificationEvent<'a> { AppHidden, } -fn notify(app: &AppHandle, title: &str, body: &str) { - app.notification() +fn notify(title: &str, body: &str) { + let app_handle = handle::Handle::app_handle(); + app_handle + .notification() .builder() .title(title) .body(body) @@ -25,54 +25,45 @@ fn notify(app: &AppHandle, title: &str, body: &str) { .ok(); } -pub async fn notify_event<'a>(app: AppHandle, event: NotificationEvent<'a>) { +pub async fn notify_event<'a>(event: NotificationEvent<'a>) { + i18n::sync_locale().await; + match event { NotificationEvent::DashboardToggled => { - notify( - &app, - &t("DashboardToggledTitle").await, - &t("DashboardToggledBody").await, - ); + let title = rust_i18n::t!("notifications.dashboardToggled.title").to_string(); + let body = rust_i18n::t!("notifications.dashboardToggled.body").to_string(); + notify(&title, &body); } NotificationEvent::ClashModeChanged { mode } => { - notify( - &app, - &t("ClashModeChangedTitle").await, - &t_with_args("ClashModeChangedBody", mode).await, - ); + let title = rust_i18n::t!("notifications.clashModeChanged.title").to_string(); + let body = rust_i18n::t!("notifications.clashModeChanged.body").replace("{mode}", mode); + notify(&title, &body); } NotificationEvent::SystemProxyToggled => { - notify( - &app, - &t("SystemProxyToggledTitle").await, - &t("SystemProxyToggledBody").await, - ); + let title = rust_i18n::t!("notifications.systemProxyToggled.title").to_string(); + let body = rust_i18n::t!("notifications.systemProxyToggled.body").to_string(); + notify(&title, &body); } NotificationEvent::TunModeToggled => { - notify( - &app, - &t("TunModeToggledTitle").await, - &t("TunModeToggledBody").await, - ); + let title = rust_i18n::t!("notifications.tunModeToggled.title").to_string(); + let body = rust_i18n::t!("notifications.tunModeToggled.body").to_string(); + notify(&title, &body); } NotificationEvent::LightweightModeEntered => { - notify( - &app, - &t("LightweightModeEnteredTitle").await, - &t("LightweightModeEnteredBody").await, - ); + let title = rust_i18n::t!("notifications.lightweightModeEntered.title").to_string(); + let body = rust_i18n::t!("notifications.lightweightModeEntered.body").to_string(); + notify(&title, &body); } NotificationEvent::AppQuit => { - notify(&app, &t("AppQuitTitle").await, &t("AppQuitBody").await); + let title = rust_i18n::t!("notifications.appQuit.title").to_string(); + let body = rust_i18n::t!("notifications.appQuit.body").to_string(); + notify(&title, &body); } #[cfg(target_os = "macos")] NotificationEvent::AppHidden => { - notify(&app, &t("AppHiddenTitle").await, &t("AppHiddenBody").await); + let title = rust_i18n::t!("notifications.appHidden.title").to_string(); + let body = rust_i18n::t!("notifications.appHidden.body").to_string(); + notify(&title, &body); } } } - -// 辅助函数,带参数的i18n -async fn t_with_args(key: &str, mode: &str) -> String { - t(key).await.replace("{mode}", mode) -} diff --git a/clash-verge-rev/src-tauri/src/utils/resolve/dns.rs b/clash-verge-rev/src-tauri/src/utils/resolve/dns.rs index 4b710bdc79..0896bccaf3 100644 --- a/clash-verge-rev/src-tauri/src/utils/resolve/dns.rs +++ b/clash-verge-rev/src-tauri/src/utils/resolve/dns.rs @@ -1,20 +1,27 @@ #[cfg(target_os = "macos")] +use crate::{logging, utils::logging::Type}; pub async fn set_public_dns(dns_server: String) { - use crate::{core::handle, utils::dirs}; - use tauri_plugin_shell::ShellExt; + use crate::utils::logging::Type; + use crate::{core::handle, logging, utils::dirs}; + use tauri_plugin_shell::ShellExt as _; let app_handle = handle::Handle::app_handle(); - log::info!(target: "app", "try to set system dns"); + logging!(info, Type::Config, "try to set system dns"); let resource_dir = match dirs::app_resources_dir() { Ok(dir) => dir, Err(e) => { - log::error!(target: "app", "Failed to get resource directory: {}", e); + logging!( + error, + Type::Config, + "Failed to get resource directory: {}", + e + ); return; } }; let script = resource_dir.join("set_dns.sh"); if !script.exists() { - log::error!(target: "app", "set_dns.sh not found"); + logging!(error, Type::Config, "set_dns.sh not found"); return; } let script = script.to_string_lossy().into_owned(); @@ -28,14 +35,14 @@ pub async fn set_public_dns(dns_server: String) { { Ok(status) => { if status.success() { - log::info!(target: "app", "set system dns successfully"); + logging!(info, Type::Config, "set system dns successfully"); } else { let code = status.code().unwrap_or(-1); - log::error!(target: "app", "set system dns failed: {code}"); + logging!(error, Type::Config, "set system dns failed: {code}"); } } Err(err) => { - log::error!(target: "app", "set system dns failed: {err}"); + logging!(error, Type::Config, "set system dns failed: {err}"); } } } @@ -43,19 +50,24 @@ pub async fn set_public_dns(dns_server: String) { #[cfg(target_os = "macos")] pub async fn restore_public_dns() { use crate::{core::handle, utils::dirs}; - use tauri_plugin_shell::ShellExt; + use tauri_plugin_shell::ShellExt as _; let app_handle = handle::Handle::app_handle(); - log::info!(target: "app", "try to unset system dns"); + logging!(info, Type::Config, "try to unset system dns"); let resource_dir = match dirs::app_resources_dir() { Ok(dir) => dir, Err(e) => { - log::error!(target: "app", "Failed to get resource directory: {}", e); + logging!( + error, + Type::Config, + "Failed to get resource directory: {}", + e + ); return; } }; let script = resource_dir.join("unset_dns.sh"); if !script.exists() { - log::error!(target: "app", "unset_dns.sh not found"); + logging!(error, Type::Config, "unset_dns.sh not found"); return; } let script = script.to_string_lossy().into_owned(); @@ -69,14 +81,14 @@ pub async fn restore_public_dns() { { Ok(status) => { if status.success() { - log::info!(target: "app", "unset system dns successfully"); + logging!(info, Type::Config, "unset system dns successfully"); } else { let code = status.code().unwrap_or(-1); - log::error!(target: "app", "unset system dns failed: {code}"); + logging!(error, Type::Config, "unset system dns failed: {code}"); } } Err(err) => { - log::error!(target: "app", "unset system dns failed: {err}"); + logging!(error, Type::Config, "unset system dns failed: {err}"); } } } diff --git a/clash-verge-rev/src-tauri/src/utils/resolve/mod.rs b/clash-verge-rev/src-tauri/src/utils/resolve/mod.rs index 7883494767..b855b35c45 100644 --- a/clash-verge-rev/src-tauri/src/utils/resolve/mod.rs +++ b/clash-verge-rev/src-tauri/src/utils/resolve/mod.rs @@ -10,7 +10,7 @@ use crate::{ tray::Tray, }, logging, logging_error, - module::lightweight::{auto_lightweight_mode_init, run_once_auto_lightweight}, + module::{auto_backup::AutoBackupManager, lightweight::auto_lightweight_boot, signal}, process::AsyncHandler, utils::{init, logging::Type, server, window_manager::WindowManager}, }; @@ -29,18 +29,11 @@ pub fn resolve_setup_sync() { AsyncHandler::spawn(|| async { AsyncHandler::spawn_blocking(init_scheme); AsyncHandler::spawn_blocking(init_embed_server); + AsyncHandler::spawn_blocking(init_signal); }); } pub fn resolve_setup_async() { - let start_time = std::time::Instant::now(); - logging!( - info, - Type::Setup, - "开始执行异步设置任务... 线程ID: {:?}", - std::thread::current().id() - ); - AsyncHandler::spawn(|| async { #[cfg(not(feature = "tauri-dev"))] resolve_setup_logger().await; @@ -50,60 +43,43 @@ pub fn resolve_setup_async() { "Version: {}", env!("CARGO_PKG_VERSION") ); - futures::join!(init_service_manager()); - futures::join!( - init_work_config(), - init_resources(), - init_startup_script(), - init_hotkey(), - ); + futures::join!(init_work_config(), init_resources(), init_startup_script()); - init_timer().await; - init_once_auto_lightweight().await; - init_auto_lightweight_mode().await; - - // 确保配置完全初始化后再启动核心管理器 init_verge_config().await; - - // 添加配置验证,确保运行时配置已正确生成 Config::verify_config_initialization().await; + init_window().await; - init_core_manager().await; - - init_system_proxy().await; - AsyncHandler::spawn_blocking(|| { - init_system_proxy_guard(); + let core_init = AsyncHandler::spawn(|| async { + init_service_manager().await; + init_core_manager().await; + init_system_proxy().await; + AsyncHandler::spawn_blocking(init_system_proxy_guard); }); - let tray_and_refresh = async { + let tray_init = async { init_tray().await; refresh_tray_menu().await; }; - futures::join!(init_window(), tray_and_refresh,); + + let _ = futures::join!( + core_init, + tray_init, + init_timer(), + init_hotkey(), + init_auto_lightweight_boot(), + init_auto_backup(), + ); }); - - let elapsed = start_time.elapsed(); - logging!(info, Type::Setup, "异步设置任务完成,耗时: {:?}", elapsed); - - if elapsed.as_secs() > 10 { - logging!(warn, Type::Setup, "异步设置任务耗时较长({:?})", elapsed); - } } -// 其它辅助函数不变 pub async fn resolve_reset_async() -> Result<(), anyhow::Error> { - logging!(info, Type::Tray, "Resetting system proxy"); sysopt::Sysopt::global().reset_sysproxy().await?; - - logging!(info, Type::Core, "Stopping core service"); CoreManager::global().stop_core().await?; #[cfg(target_os = "macos")] { use dns::restore_public_dns; - - logging!(info, Type::System, "Restoring system DNS settings"); restore_public_dns().await; } @@ -111,95 +87,74 @@ pub async fn resolve_reset_async() -> Result<(), anyhow::Error> { } pub fn init_handle() { - logging!(info, Type::Setup, "Initializing app handle..."); handle::Handle::global().init(); } pub(super) fn init_scheme() { - logging!(info, Type::Setup, "Initializing custom URL scheme"); logging_error!(Type::Setup, init::init_scheme()); } #[cfg(not(feature = "tauri-dev"))] pub(super) async fn resolve_setup_logger() { - logging!(info, Type::Setup, "Initializing global logger..."); logging_error!(Type::Setup, init::init_logger().await); } -pub async fn resolve_scheme(param: String) -> Result<()> { - logging!(info, Type::Setup, "Resolving scheme for param: {}", param); +pub async fn resolve_scheme(param: &str) -> Result<()> { logging_error!(Type::Setup, scheme::resolve_scheme(param).await); Ok(()) } pub(super) fn init_embed_server() { - logging!(info, Type::Setup, "Initializing embedded server..."); server::embed_server(); } + pub(super) async fn init_resources() { - logging!(info, Type::Setup, "Initializing resources..."); logging_error!(Type::Setup, init::init_resources().await); } pub(super) async fn init_startup_script() { - logging!(info, Type::Setup, "Initializing startup script"); logging_error!(Type::Setup, init::startup_script().await); } pub(super) async fn init_timer() { - logging!(info, Type::Setup, "Initializing timer..."); logging_error!(Type::Setup, Timer::global().init().await); } pub(super) async fn init_hotkey() { - logging!(info, Type::Setup, "Initializing hotkey..."); - logging_error!(Type::Setup, Hotkey::global().init().await); + logging_error!(Type::Setup, Hotkey::global().init(false).await); } -pub(super) async fn init_once_auto_lightweight() { - logging!( - info, - Type::Lightweight, - "Running auto lightweight mode check..." - ); - run_once_auto_lightweight().await; +pub(super) async fn init_auto_lightweight_boot() { + logging_error!(Type::Setup, auto_lightweight_boot().await); } -pub(super) async fn init_auto_lightweight_mode() { - logging!(info, Type::Setup, "Initializing auto lightweight mode..."); - logging_error!(Type::Setup, auto_lightweight_mode_init().await); +pub(super) async fn init_auto_backup() { + logging_error!(Type::Setup, AutoBackupManager::global().init().await); +} + +pub(super) fn init_signal() { + logging!(info, Type::Setup, "Initializing signal handlers..."); + signal::register(); } pub async fn init_work_config() { - logging!(info, Type::Setup, "Initializing work configuration..."); logging_error!(Type::Setup, init::init_config().await); } pub(super) async fn init_tray() { - // Check if tray should be disabled via environment variable if std::env::var("CLASH_VERGE_DISABLE_TRAY").unwrap_or_default() == "1" { - logging!(info, Type::Setup, "System tray disabled via --no-tray flag"); return; } - - logging!(info, Type::Setup, "Initializing system tray..."); logging_error!(Type::Setup, Tray::global().init().await); } pub(super) async fn init_verge_config() { - logging!(info, Type::Setup, "Initializing verge configuration..."); logging_error!(Type::Setup, Config::init_config().await); } pub(super) async fn init_service_manager() { - logging!(info, Type::Setup, "Initializing service manager..."); clash_verge_service_ipc::set_config(ServiceManager::config()).await; if !is_service_ipc_path_exists() { - logging!( - warn, - Type::Setup, - "Service IPC path does not exist, service may be unavailable" - ); return; } if SERVICE_MANAGER.lock().await.init().await.is_ok() { @@ -208,12 +163,10 @@ pub(super) async fn init_service_manager() { } pub(super) async fn init_core_manager() { - logging!(info, Type::Setup, "Initializing core manager..."); logging_error!(Type::Setup, CoreManager::global().init().await); } pub(super) async fn init_system_proxy() { - logging!(info, Type::Setup, "Initializing system proxy..."); logging_error!( Type::Setup, sysopt::Sysopt::global().update_sysproxy().await @@ -221,26 +174,23 @@ pub(super) async fn init_system_proxy() { } pub(super) fn init_system_proxy_guard() { - logging!(info, Type::Setup, "Initializing system proxy guard..."); logging_error!(Type::Setup, sysopt::Sysopt::global().init_guard_sysproxy()); } pub(super) async fn refresh_tray_menu() { - logging!(info, Type::Setup, "Refreshing tray menu..."); logging_error!(Type::Setup, Tray::global().update_part().await); } pub(super) async fn init_window() { - logging!(info, Type::Setup, "Initializing main window..."); - let is_silent_start = - { Config::verge().await.latest_ref().enable_silent_start }.unwrap_or(false); + let is_silent_start = Config::verge() + .await + .data_arc() + .enable_silent_start + .unwrap_or(false); #[cfg(target_os = "macos")] - { - if is_silent_start { - use crate::core::handle::Handle; - - Handle::global().set_activation_policy_accessory(); - } + if is_silent_start { + use crate::core::handle::Handle; + Handle::global().set_activation_policy_accessory(); } WindowManager::create_window(!is_silent_start).await; } diff --git a/clash-verge-rev/src-tauri/src/utils/resolve/scheme.rs b/clash-verge-rev/src-tauri/src/utils/resolve/scheme.rs index c34c9503fe..e36125c225 100644 --- a/clash-verge-rev/src-tauri/src/utils/resolve/scheme.rs +++ b/clash-verge-rev/src-tauri/src/utils/resolve/scheme.rs @@ -1,18 +1,26 @@ +use std::time::Duration; + use anyhow::{Result, bail}; use percent_encoding::percent_decode_str; +use smartstring::alias::String; use tauri::Url; -use crate::{config::PrfItem, core::handle, logging, utils::logging::Type, wrap_err}; +use crate::{ + config::{Config, PrfItem, profiles}, + core::handle, + logging, + utils::logging::Type, +}; -pub(super) async fn resolve_scheme(param: String) -> Result<()> { - log::info!(target:"app", "received deep link: {param}"); +pub(super) async fn resolve_scheme(param: &str) -> Result<()> { + logging!(info, Type::Config, "received deep link: {param}"); let param_str = if param.starts_with("[") && param.len() > 4 { param .get(2..param.len() - 2) .ok_or_else(|| anyhow::anyhow!("Invalid string slice boundaries"))? } else { - param.as_str() + param }; // 解析 URL @@ -23,50 +31,79 @@ pub(super) async fn resolve_scheme(param: String) -> Result<()> { } }; - if link_parsed.scheme() == "clash" || link_parsed.scheme() == "clash-verge" { - let name = link_parsed - .query_pairs() - .find(|(key, _)| key == "name") - .map(|(_, value)| value.into_owned()); + let (url_param, name) = + if link_parsed.scheme() == "clash" || link_parsed.scheme() == "clash-verge" { + let name_owned: Option = link_parsed + .query_pairs() + .find(|(key, _)| key == "name") + .map(|(_, value)| value.into_owned().into()); - let url_param = if let Some(query) = link_parsed.query() { - let prefix = "url="; - if let Some(pos) = query.find(prefix) { - let raw_url = &query[pos + prefix.len()..]; - Some(percent_decode_str(raw_url).decode_utf8_lossy().to_string()) + let url_param = if let Some(query) = link_parsed.query() { + let prefix = "url="; + if let Some(pos) = query.find(prefix) { + let raw_url = &query[pos + prefix.len()..]; + Some(percent_decode_str(raw_url).decode_utf8_lossy().to_string()) + } else { + None + } } else { None - } + }; + (url_param, name_owned) } else { - None + (None, None) }; - match url_param { - Some(url) => { - log::info!(target:"app", "decoded subscription url: {url}"); - match PrfItem::from_url(url.as_ref(), name, None, None).await { - Ok(item) => { - let uid = match item.uid.clone() { - Some(uid) => uid, - None => { - logging!(error, Type::Config, "Profile item missing UID"); - handle::Handle::notice_message( - "import_sub_url::error", - "Profile item missing UID".to_string(), - ); - return Ok(()); - } - }; - let result = crate::config::profiles::profiles_append_item_safe(item).await; - let _ = wrap_err!(result); - handle::Handle::notice_message("import_sub_url::ok", uid); - } - Err(e) => { - handle::Handle::notice_message("import_sub_url::error", e.to_string()); - } - } - } - None => bail!("failed to get profile url"), + let url = if let Some(ref url) = url_param { + url + } else { + logging!( + error, + Type::Config, + "missing url parameter in deep link: {}", + param_str + ); + return Ok(()); + }; + + let mut item = match PrfItem::from_url(url, name.as_ref(), None, None).await { + Ok(item) => item, + Err(e) => { + logging!( + error, + Type::Config, + "failed to parse profile from url: {:?}", + e + ); + handle::Handle::notice_message("import_sub_url::error", e.to_string()); + return Ok(()); + } + }; + + let uid = item.uid.clone().unwrap_or_default(); + match profiles::profiles_append_item_safe(&mut item).await { + Ok(_) => { + Config::profiles().await.apply(); + let _ = Config::profiles().await.data_arc().save_file().await; + handle::Handle::notice_message( + "import_sub_url::ok", + "", // 空 msg 传入,我们不希望导致 后端-前端-后端 死循环,这里只做提醒。 + ); + handle::Handle::refresh_verge(); + handle::Handle::notify_profile_changed(uid.clone()); + tokio::time::sleep(Duration::from_millis(100)).await; + handle::Handle::notify_profile_changed(uid); + } + Err(e) => { + logging!( + error, + Type::Config, + "failed to import subscription url: {:?}", + e + ); + Config::profiles().await.discard(); + handle::Handle::notice_message("import_sub_url::error", e.to_string()); + return Ok(()); } } diff --git a/clash-verge-rev/src-tauri/src/utils/resolve/ui.rs b/clash-verge-rev/src-tauri/src/utils/resolve/ui.rs index 4ebe30ad10..d93fe9e6f9 100644 --- a/clash-verge-rev/src-tauri/src/utils/resolve/ui.rs +++ b/clash-verge-rev/src-tauri/src/utils/resolve/ui.rs @@ -1,50 +1,37 @@ use once_cell::sync::OnceCell; -use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; use std::sync::{ Arc, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU8, Ordering}, }; use tokio::sync::Notify; use crate::{logging, utils::logging::Type}; -// 使用 AtomicBool 替代 RwLock,性能更好且无锁 -static UI_READY: OnceCell = OnceCell::new(); +// 获取 UI 是否准备就绪的全局状态 +static UI_READY: AtomicBool = AtomicBool::new(false); // 获取UI就绪状态细节 -static UI_READY_STATE: OnceCell = OnceCell::new(); +static UI_READY_STATE: AtomicU8 = AtomicU8::new(0); // 添加通知机制,用于事件驱动的 UI 就绪检测 static UI_READY_NOTIFY: OnceCell> = OnceCell::new(); // UI就绪阶段状态枚举 -#[derive(Debug, Clone, Copy, PartialEq)] +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub enum UiReadyStage { - NotStarted, + NotStarted = 0, Loading, DomReady, ResourcesLoaded, Ready, } -// UI就绪详细状态 -#[derive(Debug)] -struct UiReadyState { - stage: RwLock, +pub fn get_ui_ready() -> &'static AtomicBool { + &UI_READY } -impl Default for UiReadyState { - fn default() -> Self { - Self { - stage: RwLock::new(UiReadyStage::NotStarted), - } - } -} - -pub(super) fn get_ui_ready() -> &'static AtomicBool { - UI_READY.get_or_init(|| AtomicBool::new(false)) -} - -fn get_ui_ready_state() -> &'static UiReadyState { - UI_READY_STATE.get_or_init(UiReadyState::default) +fn get_ui_ready_state() -> &'static AtomicU8 { + &UI_READY_STATE } fn get_ui_ready_notify() -> &'static Arc { @@ -53,10 +40,7 @@ fn get_ui_ready_notify() -> &'static Arc { // 更新UI准备阶段 pub fn update_ui_ready_stage(stage: UiReadyStage) { - let state = get_ui_ready_state(); - let mut stage_lock = state.stage.write(); - - *stage_lock = stage; + get_ui_ready_state().store(stage as u8, Ordering::Release); // 如果是最终阶段,标记UI完全就绪 if stage == UiReadyStage::Ready { mark_ui_ready(); diff --git a/clash-verge-rev/src-tauri/src/utils/resolve/window.rs b/clash-verge-rev/src-tauri/src/utils/resolve/window.rs index 753342275f..cf8cecd53d 100644 --- a/clash-verge-rev/src-tauri/src/utils/resolve/window.rs +++ b/clash-verge-rev/src-tauri/src/utils/resolve/window.rs @@ -1,6 +1,7 @@ use tauri::WebviewWindow; use crate::{ + config::Config, core::handle, logging_error, utils::{ @@ -17,13 +18,17 @@ const MINIMAL_WIDTH: f64 = 520.0; const MINIMAL_HEIGHT: f64 = 520.0; /// 构建新的 WebView 窗口 -pub fn build_new_window() -> Result { +pub async fn build_new_window() -> Result { let app_handle = handle::Handle::app_handle(); + let config = Config::verge().await; + let latest = config.latest_arc(); + let start_page = latest.start_page.as_deref().unwrap_or("/"); + match tauri::WebviewWindowBuilder::new( app_handle, "main", /* the unique window label */ - tauri::WebviewUrl::App("index.html".into()), + tauri::WebviewUrl::App(start_page.into()), ) .title("Clash Verge") .center() diff --git a/clash-verge-rev/src-tauri/src/utils/server.rs b/clash-verge-rev/src-tauri/src/utils/server.rs index 97be9e9be2..4defbd7598 100644 --- a/clash-verge-rev/src-tauri/src/utils/server.rs +++ b/clash-verge-rev/src-tauri/src/utils/server.rs @@ -1,16 +1,20 @@ use super::resolve; use crate::{ config::{Config, DEFAULT_PAC, IVerge}, - logging_error, + logging, logging_error, + module::lightweight, process::AsyncHandler, - utils::logging::Type, + utils::{logging::Type, window_manager::WindowManager}, }; use anyhow::{Result, bail}; use once_cell::sync::OnceCell; use parking_lot::Mutex; use port_scanner::local_port_available; +use reqwest::ClientBuilder; +use smartstring::alias::String; +use std::time::Duration; use tokio::sync::oneshot; -use warp::Filter; +use warp::Filter as _; #[derive(serde::Deserialize, Debug)] struct QueryParam { @@ -24,22 +28,36 @@ static SHUTDOWN_SENDER: OnceCell>>> = OnceCell: pub async fn check_singleton() -> Result<()> { let port = IVerge::get_singleton_port(); if !local_port_available(port) { - let argvs: Vec = std::env::args().collect(); + let client = ClientBuilder::new() + .timeout(Duration::from_millis(500)) + .build()?; + // 需要确保 Send + #[allow(clippy::needless_collect)] + let argvs: Vec = std::env::args().collect(); if argvs.len() > 1 { #[cfg(not(target_os = "macos"))] { let param = argvs[1].as_str(); if param.starts_with("clash:") { - let _ = reqwest::get(format!( - "http://127.0.0.1:{port}/commands/scheme?param={param}" - )) - .await; + client + .get(format!( + "http://127.0.0.1:{port}/commands/scheme?param={param}" + )) + .send() + .await?; } } } else { - let _ = reqwest::get(format!("http://127.0.0.1:{port}/commands/visible")).await; + client + .get(format!("http://127.0.0.1:{port}/commands/visible")) + .send() + .await?; } - log::error!("failed to setup singleton listen server"); + logging!( + error, + Type::Window, + "failed to setup singleton listen server" + ); bail!("app exists"); } Ok(()) @@ -57,7 +75,13 @@ pub fn embed_server() { AsyncHandler::spawn(move || async move { let visible = warp::path!("commands" / "visible").and_then(|| async { - Ok::<_, warp::Rejection>(warp::reply::with_status( + logging!(info, Type::Window, "检测到从单例模式恢复应用窗口"); + if !lightweight::exit_lightweight_mode().await { + WindowManager::show_main_window().await; + } else { + logging!(error, Type::Window, "轻量模式退出失败,无法恢复应用窗口"); + }; + Ok::<_, warp::Rejection>(warp::reply::with_status::( "ok".to_string(), warp::http::StatusCode::OK, )) @@ -66,20 +90,17 @@ pub fn embed_server() { let verge_config = Config::verge().await; let clash_config = Config::clash().await; - let content = verge_config - .latest_ref() + let pac_content = verge_config + .data_arc() .pac_file_content .clone() - .unwrap_or(DEFAULT_PAC.to_string()); + .unwrap_or_else(|| DEFAULT_PAC.into()); - let mixed_port = verge_config - .latest_ref() + let pac_port = verge_config + .data_arc() .verge_mixed_port - .unwrap_or(clash_config.latest_ref().get_mixed_port()); + .unwrap_or_else(|| clash_config.data_arc().get_mixed_port()); - // Clone the content and port for the closure to avoid borrowing issues - let pac_content = content.clone(); - let pac_port = mixed_port; let pac = warp::path!("commands" / "pac").map(move || { let processed_content = pac_content.replace("%mixed-port%", &format!("{pac_port}")); warp::http::Response::builder() @@ -91,13 +112,14 @@ pub fn embed_server() { // Use map instead of and_then to avoid Send issues let scheme = warp::path!("commands" / "scheme") .and(warp::query::()) - .map(|query: QueryParam| { - // Spawn async work in a fire-and-forget manner - let param = query.param.clone(); - tokio::task::spawn_local(async move { - logging_error!(Type::Setup, resolve::resolve_scheme(param).await); + .and_then(|query: QueryParam| async move { + AsyncHandler::spawn(|| async move { + logging_error!(Type::Setup, resolve::resolve_scheme(&query.param).await); }); - warp::reply::with_status("ok".to_string(), warp::http::StatusCode::OK) + Ok::<_, warp::Rejection>(warp::reply::with_status::( + "ok".to_string(), + warp::http::StatusCode::OK, + )) }); let commands = visible.or(scheme).or(pac); @@ -113,7 +135,7 @@ pub fn embed_server() { } pub fn shutdown_embedded_server() { - log::info!("shutting down embedded server"); + logging!(info, Type::Window, "shutting down embedded server"); if let Some(sender) = SHUTDOWN_SENDER.get() && let Some(sender) = sender.lock().take() { diff --git a/clash-verge-rev/src-tauri/src/utils/window_manager.rs b/clash-verge-rev/src-tauri/src/utils/window_manager.rs index fa86f7e0b3..c6a5356965 100644 --- a/clash-verge-rev/src-tauri/src/utils/window_manager.rs +++ b/clash-verge-rev/src-tauri/src/utils/window_manager.rs @@ -5,7 +5,7 @@ use crate::{ }; use std::future::Future; use std::pin::Pin; -use tauri::{Manager, WebviewWindow, Wry}; +use tauri::{Manager as _, WebviewWindow, Wry}; use once_cell::sync::OnceCell; use parking_lot::Mutex; @@ -58,7 +58,11 @@ fn get_window_operation_debounce() -> &'static Mutex { fn should_handle_window_operation() -> bool { if WINDOW_OPERATION_IN_PROGRESS.load(Ordering::Acquire) { - log::warn!(target: "app", "[防抖] 窗口操作已在进行中,跳过重复调用"); + logging!( + warn, + Type::Window, + "Warning: [防抖] 窗口操作已在进行中,跳过重复调用" + ); return false; } @@ -67,17 +71,28 @@ fn should_handle_window_operation() -> bool { let now = Instant::now(); let elapsed = now.duration_since(*last_operation); - log::debug!(target: "app", "[防抖] 检查窗口操作间隔: {}ms (需要>={}ms)", - elapsed.as_millis(), WINDOW_OPERATION_DEBOUNCE_MS); + logging!( + debug, + Type::Window, + "[防抖] 检查窗口操作间隔: {}ms (需要>={}ms)", + elapsed.as_millis(), + WINDOW_OPERATION_DEBOUNCE_MS + ); if elapsed >= Duration::from_millis(WINDOW_OPERATION_DEBOUNCE_MS) { *last_operation = now; + drop(last_operation); WINDOW_OPERATION_IN_PROGRESS.store(true, Ordering::Release); - log::info!(target: "app", "[防抖] 窗口操作被允许执行"); + logging!(info, Type::Window, "[防抖] 窗口操作被允许执行"); true } else { - log::warn!(target: "app", "[防抖] 窗口操作被防抖机制忽略,距离上次操作 {}ms < {}ms", - elapsed.as_millis(), WINDOW_OPERATION_DEBOUNCE_MS); + logging!( + warn, + Type::Window, + "Warning: [防抖] 窗口操作被防抖机制忽略,距离上次操作 {}ms < {}ms", + elapsed.as_millis(), + WINDOW_OPERATION_DEBOUNCE_MS + ); false } } @@ -189,52 +204,51 @@ impl WindowManager { ); match current_state { - WindowState::NotExist => { - // 窗口不存在,创建新窗口 - logging!(info, Type::Window, "窗口不存在,将创建新窗口"); - // 由于已经有防抖保护,直接调用内部方法 - if Self::create_window(true).await { - WindowOperationResult::Created - } else { - WindowOperationResult::Failed - } - } - WindowState::VisibleFocused | WindowState::VisibleUnfocused => { - logging!( - info, - Type::Window, - "窗口可见(焦点状态: {}),将隐藏窗口", - if current_state == WindowState::VisibleFocused { - "有焦点" - } else { - "无焦点" - } - ); - if let Some(window) = Self::get_main_window() { - match window.hide() { - Ok(_) => { - logging!(info, Type::Window, "窗口已成功隐藏"); - WindowOperationResult::Hidden - } - Err(e) => { - logging!(warn, Type::Window, "隐藏窗口失败: {}", e); - WindowOperationResult::Failed - } - } - } else { - logging!(warn, Type::Window, "无法获取窗口实例"); - WindowOperationResult::Failed - } - } - WindowState::Minimized | WindowState::Hidden => { - logging!(info, Type::Window, "窗口存在但被隐藏或最小化,将激活窗口"); - if let Some(window) = Self::get_main_window() { - Self::activate_window(&window) - } else { - logging!(warn, Type::Window, "无法获取窗口实例"); + WindowState::NotExist => Self::handle_not_exist_toggle().await, + WindowState::VisibleFocused | WindowState::VisibleUnfocused => Self::hide_main_window(), + WindowState::Minimized | WindowState::Hidden => Self::activate_existing_main_window(), + } + } + + // 窗口不存在时创建新窗口 + async fn handle_not_exist_toggle() -> WindowOperationResult { + logging!(info, Type::Window, "窗口不存在,将创建新窗口"); + // 由于已经有防抖保护,直接调用内部方法 + if Self::create_window(true).await { + WindowOperationResult::Created + } else { + WindowOperationResult::Failed + } + } + + // 隐藏主窗口 + fn hide_main_window() -> WindowOperationResult { + logging!(info, Type::Window, "窗口可见,将隐藏窗口"); + if let Some(window) = Self::get_main_window() { + match window.hide() { + Ok(_) => { + logging!(info, Type::Window, "窗口已成功隐藏"); + WindowOperationResult::Hidden + } + Err(e) => { + logging!(warn, Type::Window, "隐藏窗口失败: {}", e); WindowOperationResult::Failed } } + } else { + logging!(warn, Type::Window, "无法获取窗口实例"); + WindowOperationResult::Failed + } + } + + // 激活已存在的主窗口 + fn activate_existing_main_window() -> WindowOperationResult { + logging!(info, Type::Window, "窗口存在但被隐藏或最小化,将激活窗口"); + if let Some(window) = Self::get_main_window() { + Self::activate_window(&window) + } else { + logging!(warn, Type::Window, "无法获取窗口实例"); + WindowOperationResult::Failed } } @@ -328,7 +342,7 @@ impl WindowManager { return false; } - match build_new_window() { + match build_new_window().await { Ok(_) => { logging!(info, Type::Window, "新窗口创建成功"); } @@ -360,7 +374,6 @@ impl WindowManager { } return WindowOperationResult::Destroyed; } - logging!(warn, Type::Window, "窗口摧毁失败"); WindowOperationResult::Failed } diff --git a/clash-verge-rev/src-tauri/tauri.conf.json b/clash-verge-rev/src-tauri/tauri.conf.json index 8e2c72cef8..daf6d3801d 100755 --- a/clash-verge-rev/src-tauri/tauri.conf.json +++ b/clash-verge-rev/src-tauri/tauri.conf.json @@ -1,5 +1,5 @@ { - "version": "2.4.3", + "version": "2.4.4", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "bundle": { "active": true, @@ -11,7 +11,7 @@ "icons/icon.icns", "icons/icon.ico" ], - "resources": ["resources", "resources/locales/*"], + "resources": ["resources"], "publisher": "Clash Verge Rev", "externalBin": ["sidecar/verge-mihomo", "sidecar/verge-mihomo-alpha"], "copyright": "GNU General Public License v3.0", diff --git a/clash-verge-rev/src-tauri/tauri.macos.conf.json b/clash-verge-rev/src-tauri/tauri.macos.conf.json index ef471b8ee0..2aa49bbf18 100644 --- a/clash-verge-rev/src-tauri/tauri.macos.conf.json +++ b/clash-verge-rev/src-tauri/tauri.macos.conf.json @@ -28,7 +28,8 @@ "x": 200, "y": 180 } - } + }, + "infoPlist": "packages/macos/info_merge.plist" } } } diff --git a/clash-verge-rev/src/components/base/NoticeManager.tsx b/clash-verge-rev/src/components/base/NoticeManager.tsx index f2ae5ef21c..dc00eac881 100644 --- a/clash-verge-rev/src/components/base/NoticeManager.tsx +++ b/clash-verge-rev/src/components/base/NoticeManager.tsx @@ -1,14 +1,17 @@ import { CloseRounded } from "@mui/icons-material"; import { Snackbar, Alert, IconButton, Box } from "@mui/material"; import React, { useSyncExternalStore } from "react"; +import { useTranslation } from "react-i18next"; import { subscribeNotices, hideNotice, getSnapshotNotices, } from "@/services/noticeService"; +import type { TranslationKey } from "@/types/generated/i18n-keys"; export const NoticeManager: React.FC = () => { + const { t } = useTranslation(); const currentNotices = useSyncExternalStore( subscribeNotices, getSnapshotNotices, @@ -60,7 +63,61 @@ export const NoticeManager: React.FC = () => { } > - {notice.message} + {notice.i18n + ? (() => { + const params = (notice.i18n.params ?? {}) as Record< + string, + unknown + >; + const { + prefixKey, + prefixParams, + prefix, + message, + ...restParams + } = params; + + const prefixKeyParams = + prefixParams && + typeof prefixParams === "object" && + prefixParams !== null + ? (prefixParams as Record) + : undefined; + + const resolvedPrefix = + typeof prefixKey === "string" + ? t(prefixKey as TranslationKey, { + defaultValue: prefixKey, + ...(prefixKeyParams ?? {}), + ...restParams, + }) + : typeof prefix === "string" + ? prefix + : undefined; + + const finalParams: Record = { + ...restParams, + }; + if (resolvedPrefix !== undefined) { + finalParams.prefix = resolvedPrefix; + } + if (typeof message === "string") { + finalParams.message = message; + } + + const defaultValue = + resolvedPrefix && typeof message === "string" + ? `${resolvedPrefix} ${message}` + : typeof message === "string" + ? message + : undefined; + + return t(notice.i18n.key as TranslationKey, { + defaultValue, + ...finalParams, + }); + })() + : notice.message} ))} diff --git a/clash-verge-rev/src/components/base/base-dialog.tsx b/clash-verge-rev/src/components/base/base-dialog.tsx index 219b5108a4..6b6648fcee 100644 --- a/clash-verge-rev/src/components/base/base-dialog.tsx +++ b/clash-verge-rev/src/components/base/base-dialog.tsx @@ -31,22 +31,23 @@ export interface DialogRef { close: () => void; } -export const BaseDialog: React.FC = (props) => { - const { - open, - title, - children, - okBtn, - cancelBtn, - contentSx, - disableCancel, - disableOk, - disableFooter, - loading, - } = props; - +export const BaseDialog: React.FC = ({ + open, + title, + children, + okBtn, + cancelBtn, + contentSx, + disableCancel, + disableOk, + disableFooter, + loading, + onOk, + onCancel, + onClose, +}) => { return ( - + {title} {children} @@ -54,16 +55,12 @@ export const BaseDialog: React.FC = (props) => { {!disableFooter && ( {!disableCancel && ( - )} {!disableOk && ( - + {okBtn} )} diff --git a/clash-verge-rev/src/components/base/base-empty.tsx b/clash-verge-rev/src/components/base/base-empty.tsx index dcf04bbb92..acbe36c46c 100644 --- a/clash-verge-rev/src/components/base/base-empty.tsx +++ b/clash-verge-rev/src/components/base/base-empty.tsx @@ -1,16 +1,25 @@ import { InboxRounded } from "@mui/icons-material"; import { alpha, Box, Typography } from "@mui/material"; +import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; +import type { TranslationKey } from "@/types/generated/i18n-keys"; + interface Props { - text?: React.ReactNode; - extra?: React.ReactNode; + text?: ReactNode; + textKey?: TranslationKey; + extra?: ReactNode; } -export const BaseEmpty = (props: Props) => { - const { text = "Empty", extra } = props; +export const BaseEmpty = ({ + text, + textKey = "shared.statuses.empty", + extra, +}: Props) => { const { t } = useTranslation(); + const resolvedText: ReactNode = text !== undefined ? text : t(textKey); + return ( ({ @@ -24,7 +33,7 @@ export const BaseEmpty = (props: Props) => { })} > - {t(`${text}`)} + {resolvedText} {extra} ); diff --git a/clash-verge-rev/src/components/base/base-error-boundary.tsx b/clash-verge-rev/src/components/base/base-error-boundary.tsx index 2475a2ffdf..8b94429bec 100644 --- a/clash-verge-rev/src/components/base/base-error-boundary.tsx +++ b/clash-verge-rev/src/components/base/base-error-boundary.tsx @@ -20,10 +20,8 @@ interface Props { children?: ReactNode; } -export const BaseErrorBoundary = (props: Props) => { +export const BaseErrorBoundary = ({ children }: Props) => { return ( - - {props.children} - + {children} ); }; diff --git a/clash-verge-rev/src/components/base/base-fieldset.tsx b/clash-verge-rev/src/components/base/base-fieldset.tsx index 8374897d38..57ffa82a3a 100644 --- a/clash-verge-rev/src/components/base/base-fieldset.tsx +++ b/clash-verge-rev/src/components/base/base-fieldset.tsx @@ -9,30 +9,36 @@ type Props = { children?: React.ReactNode; }; -export const BaseFieldset: React.FC = (props: Props) => { +export const BaseFieldset: React.FC = ({ + label, + fontSize, + width, + padding, + children, +}: Props) => { const Fieldset = styled(Box)<{ component?: string }>(() => ({ position: "relative", border: "1px solid #bbb", borderRadius: "5px", - width: props.width ?? "auto", - padding: props.padding ?? "15px", + width: width ?? "auto", + padding: padding ?? "15px", })); const Label = styled("legend")(({ theme }) => ({ position: "absolute", top: "-10px", - left: props.padding ?? "15px", + left: padding ?? "15px", backgroundColor: theme.palette.background.paper, backgroundImage: "linear-gradient(rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.16))", color: theme.palette.text.primary, - fontSize: props.fontSize ?? "1em", + fontSize: fontSize ?? "1em", })); return (
- - {props.children} + + {children}
); }; diff --git a/clash-verge-rev/src/components/base/base-search-box.tsx b/clash-verge-rev/src/components/base/base-search-box.tsx index 84f0a83bb3..beb961e0c1 100644 --- a/clash-verge-rev/src/components/base/base-search-box.tsx +++ b/clash-verge-rev/src/components/base/base-search-box.tsx @@ -1,6 +1,13 @@ import { Box, SvgIcon, TextField, styled } from "@mui/material"; import Tooltip from "@mui/material/Tooltip"; -import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react"; +import { + ChangeEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import matchCaseIcon from "@/assets/image/component/match_case.svg?react"; @@ -35,15 +42,20 @@ const StyledTextField = styled(TextField)(({ theme }) => ({ }, })); -export const BaseSearchBox = (props: SearchProps) => { +export const BaseSearchBox = ({ + placeholder, + matchCase: defaultMatchCase = false, + matchWholeWord: defaultMatchWholeWord = false, + useRegularExpression: defaultUseRegularExpression = false, + onSearch, +}: SearchProps) => { const { t } = useTranslation(); const inputRef = useRef(null); - const [matchCase, setMatchCase] = useState(props.matchCase ?? false); - const [matchWholeWord, setMatchWholeWord] = useState( - props.matchWholeWord ?? false, - ); + const onSearchRef = useRef(onSearch); + const [matchCase, setMatchCase] = useState(defaultMatchCase); + const [matchWholeWord, setMatchWholeWord] = useState(defaultMatchWholeWord); const [useRegularExpression, setUseRegularExpression] = useState( - props.useRegularExpression ?? false, + defaultUseRegularExpression, ); const [errorMessage, setErrorMessage] = useState(""); @@ -56,8 +68,8 @@ export const BaseSearchBox = (props: SearchProps) => { inheritViewBox: true, }; - // 验证正则表达式的辅助函数 - const validateRegex = (pattern: string) => { + // Helper that verifies whether a pattern is a valid regular expression + const validateRegex = useCallback((pattern: string) => { if (!pattern) return true; try { new RegExp(pattern); @@ -66,46 +78,48 @@ export const BaseSearchBox = (props: SearchProps) => { console.warn("[BaseSearchBox] validateRegex error:", e); return false; } - }; + }, []); const createMatcher = useMemo(() => { return (searchText: string) => { - try { - // 当启用正则表达式验证是否合规 - if (useRegularExpression && searchText) { - const isValid = validateRegex(searchText); - if (!isValid) { - throw new Error(t("Invalid regular expression")); - } + if (useRegularExpression && searchText) { + const isValid = validateRegex(searchText); + if (!isValid) { + // Invalid regex should result in no match + return () => false; + } + } + + return (content: string) => { + if (!searchText) { + return true; } - return (content: string) => { - if (!searchText) return true; + const item = !matchCase ? content.toLowerCase() : content; + const searchItem = !matchCase ? searchText.toLowerCase() : searchText; - const item = !matchCase ? content.toLowerCase() : content; - const searchItem = !matchCase ? searchText.toLowerCase() : searchText; + if (useRegularExpression) { + return new RegExp(searchItem).test(item); + } - if (useRegularExpression) { - return new RegExp(searchItem).test(item); - } + if (matchWholeWord) { + return new RegExp(`\\b${searchItem}\\b`).test(item); + } - if (matchWholeWord) { - return new RegExp(`\\b${searchItem}\\b`).test(item); - } - - return item.includes(searchItem); - }; - } catch (err) { - setErrorMessage(err instanceof Error ? err.message : `${err}`); - return () => false; // 无效正则规则 不匹配值 - } + return item.includes(searchItem); + }; }; - }, [matchCase, matchWholeWord, useRegularExpression, t]); + }, [matchCase, matchWholeWord, useRegularExpression, validateRegex]); + + useEffect(() => { + onSearchRef.current = onSearch; + }, [onSearch]); useEffect(() => { if (!inputRef.current) return; const value = inputRef.current.value; - props.onSearch(createMatcher(value), { + const matcher = createMatcher(value); + onSearchRef.current(matcher, { text: value, matchCase, matchWholeWord, @@ -117,15 +131,16 @@ export const BaseSearchBox = (props: SearchProps) => { const value = e.target?.value ?? ""; setErrorMessage(""); - // 验证正则表达式 + // Validate regex input eagerly if (useRegularExpression && value) { const isValid = validateRegex(value); if (!isValid) { - setErrorMessage(t("Invalid regular expression")); + setErrorMessage(t("shared.validation.invalidRegex")); } } - props.onSearch(createMatcher(value), { + const matcher = createMatcher(value); + onSearchRef.current(matcher, { text: value, matchCase, matchWholeWord, @@ -133,6 +148,21 @@ export const BaseSearchBox = (props: SearchProps) => { }); }; + const handleToggleUseRegularExpression = () => { + setUseRegularExpression((prev) => { + const next = !prev; + if (!next) { + setErrorMessage(""); + } else { + const value = inputRef.current?.value ?? ""; + if (value && !validateRegex(value)) { + setErrorMessage(t("shared.validation.invalidRegex")); + } + } + return next; + }); + }; + return ( { size="small" variant="outlined" spellCheck="false" - placeholder={props.placeholder ?? t("Filter conditions")} + placeholder={placeholder ?? t("shared.placeholders.filter")} sx={{ input: { py: 0.65, px: 1.25 } }} onChange={onChange} error={!!errorMessage} @@ -152,36 +182,34 @@ export const BaseSearchBox = (props: SearchProps) => { sx: { pr: 1 }, endAdornment: ( - +
setMatchCase(!matchCase)} + onClick={() => setMatchCase((prev) => !prev)} />
- +
setMatchWholeWord(!matchWholeWord)} + onClick={() => setMatchWholeWord((prev) => !prev)} />
- +
- setUseRegularExpression(!useRegularExpression) - } - />{" "} + onClick={handleToggleUseRegularExpression} + />
diff --git a/clash-verge-rev/src/components/base/base-styled-text-field.tsx b/clash-verge-rev/src/components/base/base-styled-text-field.tsx index 0899da8e2e..3d863a9365 100644 --- a/clash-verge-rev/src/components/base/base-styled-text-field.tsx +++ b/clash-verge-rev/src/components/base/base-styled-text-field.tsx @@ -12,7 +12,7 @@ export const BaseStyledTextField = styled((props: TextFieldProps) => { size="small" variant="outlined" spellCheck="false" - placeholder={t("Filter conditions")} + placeholder={t("shared.placeholders.filter")} sx={{ input: { py: 0.65, px: 1.25 } }} {...props} /> diff --git a/clash-verge-rev/src/components/common/traffic-error-boundary.tsx b/clash-verge-rev/src/components/common/traffic-error-boundary.tsx index ce7a3f61c2..763dcbbea0 100644 --- a/clash-verge-rev/src/components/common/traffic-error-boundary.tsx +++ b/clash-verge-rev/src/components/common/traffic-error-boundary.tsx @@ -177,7 +177,7 @@ const TrafficErrorFallback: React.FC = ({ - {t("Traffic Statistics Error")} + {t("shared.feedback.errors.trafficStats")} = ({ textAlign="center" sx={{ mb: 2 }} > - {t( - "The traffic statistics component encountered an error and has been disabled to prevent crashes.", - )} + {t("shared.feedback.errors.trafficStatsDescription")} - Error: {error?.message || "Unknown error"} + Error:{" "} + {error instanceof Error ? error.message : "Unknown error"} {retryCount > 0 && ( - {t("Retry attempts")}: {retryCount}/{maxRetries} + {t("shared.labels.retryAttempts")}: {retryCount}/{maxRetries} )} @@ -211,12 +210,12 @@ const TrafficErrorFallback: React.FC = ({ onClick={onRetry} size="small" > - {t("Retry")} + {t("shared.actions.retry")} )} @@ -313,30 +314,3 @@ export const LightweightTrafficErrorBoundary: React.FC<{ ); }; - -/** - * HOC:为任何组件添加流量错误边界 - */ -export function withTrafficErrorBoundary

( - WrappedComponent: React.ComponentType

, - options?: { - lightweight?: boolean; - onError?: (error: Error, errorInfo: ErrorInfo) => void; - }, -) { - const WithErrorBoundaryComponent = (props: P) => { - const ErrorBoundaryComponent = options?.lightweight - ? LightweightTrafficErrorBoundary - : TrafficErrorBoundary; - - return ( - - - - ); - }; - - WithErrorBoundaryComponent.displayName = `withTrafficErrorBoundary(${WrappedComponent.displayName || WrappedComponent.name})`; - - return WithErrorBoundaryComponent; -} diff --git a/clash-verge-rev/src/components/common/with-traffic-error-boundary.tsx b/clash-verge-rev/src/components/common/with-traffic-error-boundary.tsx new file mode 100644 index 0000000000..65b6b927af --- /dev/null +++ b/clash-verge-rev/src/components/common/with-traffic-error-boundary.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import type { ErrorInfo } from "react"; + +import { + TrafficErrorBoundary, + LightweightTrafficErrorBoundary, +} from "./traffic-error-boundary"; + +interface WithTrafficErrorBoundaryOptions { + lightweight?: boolean; + onError?: (error: Error, errorInfo: ErrorInfo) => void; +} + +/** + * HOC:为任何组件添加流量错误边界 + */ +export function withTrafficErrorBoundary

( + WrappedComponent: React.ComponentType

, + options?: WithTrafficErrorBoundaryOptions, +) { + const WithErrorBoundaryComponent = (props: P) => { + const ErrorBoundaryComponent = options?.lightweight + ? LightweightTrafficErrorBoundary + : TrafficErrorBoundary; + + return ( + + + + ); + }; + + WithErrorBoundaryComponent.displayName = `withTrafficErrorBoundary(${WrappedComponent.displayName || WrappedComponent.name})`; + + return WithErrorBoundaryComponent; +} diff --git a/clash-verge-rev/src/components/connection/connection-column-manager.tsx b/clash-verge-rev/src/components/connection/connection-column-manager.tsx new file mode 100644 index 0000000000..f2a5a6e42e --- /dev/null +++ b/clash-verge-rev/src/components/connection/connection-column-manager.tsx @@ -0,0 +1,191 @@ +import { + closestCenter, + DndContext, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { arrayMove, SortableContext, useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { DragIndicatorRounded } from "@mui/icons-material"; +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + List, + ListItem, + ListItemText, +} from "@mui/material"; +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +interface ColumnOption { + field: string; + label: string; + visible: boolean; +} + +interface Props { + open: boolean; + columns: ColumnOption[]; + onClose: () => void; + onToggle: (field: string, visible: boolean) => void; + onOrderChange: (order: string[]) => void; + onReset: () => void; +} + +export const ConnectionColumnManager = ({ + open, + columns, + onClose, + onToggle, + onOrderChange, + onReset, +}: Props) => { + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 6 }, + }), + ); + const { t } = useTranslation(); + + const items = useMemo(() => columns.map((column) => column.field), [columns]); + const visibleCount = useMemo( + () => columns.filter((column) => column.visible).length, + [columns], + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const order = columns.map((column) => column.field); + const oldIndex = order.indexOf(active.id as string); + const newIndex = order.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1) return; + + onOrderChange(arrayMove(order, oldIndex, newIndex)); + }, + [columns, onOrderChange], + ); + + return ( +

+ + {t("connections.components.columnManager.title")} + + + + + + {columns.map((column) => ( + + ))} + + + + + + + + + + ); +}; + +interface SortableColumnItemProps { + column: ColumnOption; + onToggle: (field: string, visible: boolean) => void; + dragHandleLabel: string; + disableToggle?: boolean; +} + +const SortableColumnItem = ({ + column, + onToggle, + dragHandleLabel, + disableToggle = false, +}: SortableColumnItemProps) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: column.field }); + + const style = useMemo( + () => ({ + transform: CSS.Transform.toString(transform), + transition, + }), + [transform, transition], + ); + + return ( + `1px solid ${theme.palette.divider}`, + backgroundColor: isDragging ? "action.hover" : "transparent", + display: "flex", + alignItems: "center", + gap: 1, + }} + style={style} + > + onToggle(column.field, event.target.checked)} + /> + + + + + + ); +}; diff --git a/clash-verge-rev/src/components/connection/connection-detail.tsx b/clash-verge-rev/src/components/connection/connection-detail.tsx index f580ae0060..33fd23a8a4 100644 --- a/clash-verge-rev/src/components/connection/connection-detail.tsx +++ b/clash-verge-rev/src/components/connection/connection-detail.tsx @@ -1,26 +1,28 @@ import { Box, Button, Snackbar, useTheme } from "@mui/material"; import { useLockFn } from "ahooks"; import dayjs from "dayjs"; -import { t } from "i18next"; import { useImperativeHandle, useState, type Ref } from "react"; -import { closeConnections } from "tauri-plugin-mihomo-api"; +import { useTranslation } from "react-i18next"; +import { closeConnection } from "tauri-plugin-mihomo-api"; import parseTraffic from "@/utils/parse-traffic"; export interface ConnectionDetailRef { - open: (detail: IConnectionsItem) => void; + open: (detail: IConnectionsItem, closed: boolean) => void; } export function ConnectionDetail({ ref }: { ref?: Ref }) { const [open, setOpen] = useState(false); const [detail, setDetail] = useState(null!); + const [closed, setClosed] = useState(false); const theme = useTheme(); useImperativeHandle(ref, () => ({ - open: (detail: IConnectionsItem) => { + open: (detail: IConnectionsItem, closed: boolean) => { if (open) return; setOpen(true); setDetail(detail); + setClosed(closed); }, })); @@ -42,7 +44,11 @@ export function ConnectionDetail({ ref }: { ref?: Ref }) { }} message={ detail ? ( - + ) : null } /> @@ -51,10 +57,12 @@ export function ConnectionDetail({ ref }: { ref?: Ref }) { interface InnerProps { data: IConnectionsItem; + closed: boolean; onClose?: () => void; } -const InnerConnectionDetail = ({ data, onClose }: InnerProps) => { +const InnerConnectionDetail = ({ data, closed, onClose }: InnerProps) => { + const { t } = useTranslation(); const { metadata, rulePayload } = data; const theme = useTheme(); const chains = [...data.chains].reverse().join(" / "); @@ -67,37 +75,55 @@ const InnerConnectionDetail = ({ data, onClose }: InnerProps) => { : metadata.remoteDestination; const information = [ - { label: t("Host"), value: host }, - { label: t("Downloaded"), value: parseTraffic(data.download).join(" ") }, - { label: t("Uploaded"), value: parseTraffic(data.upload).join(" ") }, + { label: t("connections.components.fields.host"), value: host }, { - label: t("DL Speed"), + label: t("shared.labels.downloaded"), + value: parseTraffic(data.download).join(" "), + }, + { + label: t("shared.labels.uploaded"), + value: parseTraffic(data.upload).join(" "), + }, + { + label: t("connections.components.fields.dlSpeed"), value: parseTraffic(data.curDownload ?? -1).join(" ") + "/s", }, { - label: t("UL Speed"), + label: t("connections.components.fields.ulSpeed"), value: parseTraffic(data.curUpload ?? -1).join(" ") + "/s", }, { - label: t("Chains"), + label: t("connections.components.fields.chains"), value: chains, }, - { label: t("Rule"), value: rule }, + { label: t("connections.components.fields.rule"), value: rule }, { - label: t("Process"), + label: t("connections.components.fields.process"), value: `${metadata.process}${metadata.processPath ? `(${metadata.processPath})` : ""}`, }, - { label: t("Time"), value: dayjs(data.start).fromNow() }, { - label: t("Source"), + label: t("connections.components.fields.time"), + value: dayjs(data.start).fromNow(), + }, + { + label: t("connections.components.fields.source"), value: `${metadata.sourceIP}:${metadata.sourcePort}`, }, - { label: t("Destination"), value: Destination }, - { label: t("DestinationPort"), value: `${metadata.destinationPort}` }, - { label: t("Type"), value: `${metadata.type}(${metadata.network})` }, + { + label: t("connections.components.fields.destination"), + value: Destination, + }, + { + label: t("connections.components.fields.destinationPort"), + value: `${metadata.destinationPort}`, + }, + { + label: t("connections.components.fields.type"), + value: `${metadata.type}(${metadata.network})`, + }, ]; - const onDelete = useLockFn(async () => closeConnections(data.id)); + const onDelete = useLockFn(async () => closeConnection(data.id)); return ( @@ -115,18 +141,20 @@ const InnerConnectionDetail = ({ data, onClose }: InnerProps) => { ))} - - - + {!closed && ( + + + + )} ); }; diff --git a/clash-verge-rev/src/components/connection/connection-item.tsx b/clash-verge-rev/src/components/connection/connection-item.tsx index a596ec72bb..6512b5a5a0 100644 --- a/clash-verge-rev/src/components/connection/connection-item.tsx +++ b/clash-verge-rev/src/components/connection/connection-item.tsx @@ -9,7 +9,8 @@ import { } from "@mui/material"; import { useLockFn } from "ahooks"; import dayjs from "dayjs"; -import { closeConnections } from "tauri-plugin-mihomo-api"; +import { useTranslation } from "react-i18next"; +import { closeConnection } from "tauri-plugin-mihomo-api"; import parseTraffic from "@/utils/parse-traffic"; @@ -26,15 +27,17 @@ const Tag = styled("span")(({ theme }) => ({ interface Props { value: IConnectionsItem; + closed: boolean; onShowDetail?: () => void; } export const ConnectionItem = (props: Props) => { - const { value, onShowDetail } = props; + const { value, closed, onShowDetail } = props; const { id, metadata, chains, start, curUpload, curDownload } = value; + const { t } = useTranslation(); - const onDelete = useLockFn(async () => closeConnections(id)); + const onDelete = useLockFn(async () => closeConnection(id)); const showTraffic = curUpload! >= 100 || curDownload! >= 100; return ( @@ -42,9 +45,17 @@ export const ConnectionItem = (props: Props) => { dense sx={{ borderBottom: "1px solid var(--divider-color)" }} secondaryAction={ - - - + !closed && ( + + + + ) } > void>(() => {}); + +/** + * Reconcile stored column order with base columns to handle added/removed fields + */ +const reconcileColumnOrder = ( + storedOrder: string[], + baseFields: string[], +): string[] => { + const filtered = storedOrder.filter((field) => baseFields.includes(field)); + const missing = baseFields.filter((field) => !filtered.includes(field)); + return [...filtered, ...missing]; +}; + interface Props { connections: IConnectionsItem[]; onShowDetail: (data: IConnectionsItem) => void; + columnManagerOpen: boolean; + onOpenColumnManager: () => void; + onCloseColumnManager: () => void; } export const ConnectionTable = (props: Props) => { - const { connections, onShowDetail } = props; + const { + connections, + onShowDetail, + columnManagerOpen, + onOpenColumnManager, + onCloseColumnManager, + } = props; + const { t } = useTranslation(); + const apiRef = useGridApiRef(); + useLayoutEffect(() => { + const PATCH_FLAG_KEY = "__clashPatchedPublishEvent" as const; + const ORIGINAL_KEY = "__clashOriginalPublishEvent" as const; + let isUnmounted = false; + let retryHandle: ReturnType | null = null; + let cleanupOriginal: (() => void) | null = null; - const [columnVisible, setColumnVisible] = useState< - Partial> - >({}); + const scheduleRetry = () => { + if (isUnmounted || retryHandle !== null) return; + retryHandle = setTimeout(() => { + retryHandle = null; + ensurePatched(); + }, 16); + }; + + // Safari occasionally emits grid events without an event object, + // and MUI expects `defaultMuiPrevented` to exist. Normalize here to avoid crashes. + const createFallbackEvent = () => { + const fallback = { + defaultMuiPrevented: false, + preventDefault() { + fallback.defaultMuiPrevented = true; + }, + }; + return fallback; + }; + + const ensureMuiEvent = ( + value: unknown, + ): { + defaultMuiPrevented: boolean; + preventDefault: () => void; + [key: string]: unknown; + } => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return createFallbackEvent(); + } + + const eventObject = value as { + defaultMuiPrevented?: unknown; + preventDefault?: () => void; + [key: string]: unknown; + }; + + if (typeof eventObject.defaultMuiPrevented !== "boolean") { + eventObject.defaultMuiPrevented = false; + } + + if (typeof eventObject.preventDefault !== "function") { + eventObject.preventDefault = () => { + eventObject.defaultMuiPrevented = true; + }; + } + + return eventObject as { + defaultMuiPrevented: boolean; + preventDefault: () => void; + [key: string]: unknown; + }; + }; + + const ensurePatched = () => { + if (isUnmounted) return; + const api = apiRef.current; + + if (!api?.publishEvent) { + scheduleRetry(); + return; + } + + const metadataApi = api as unknown as typeof api & + Record; + if (metadataApi[PATCH_FLAG_KEY] === true) return; + + const originalPublishEvent = api.publishEvent; + + // Use Proxy to create a more resilient wrapper that always normalizes events + const patchedPublishEvent = new Proxy(originalPublishEvent, { + apply(target, thisArg, rawArgs: unknown[]) { + rawArgs[2] = ensureMuiEvent(rawArgs[2]); + + return Reflect.apply( + target as (...args: unknown[]) => unknown, + thisArg, + rawArgs, + ); + }, + }) as typeof originalPublishEvent; + + api.publishEvent = patchedPublishEvent; + metadataApi[PATCH_FLAG_KEY] = true; + metadataApi[ORIGINAL_KEY] = originalPublishEvent; + + cleanupOriginal = () => { + const storedOriginal = metadataApi[ORIGINAL_KEY] as + | typeof originalPublishEvent + | undefined; + + api.publishEvent = ( + typeof storedOriginal === "function" + ? storedOriginal + : originalPublishEvent + ) as typeof originalPublishEvent; + + delete metadataApi[PATCH_FLAG_KEY]; + delete metadataApi[ORIGINAL_KEY]; + }; + }; + + ensurePatched(); + + return () => { + isUnmounted = true; + if (retryHandle !== null) { + clearTimeout(retryHandle); + retryHandle = null; + } + if (cleanupOriginal) { + cleanupOriginal(); + cleanupOriginal = null; + } + }; + }, [apiRef]); const [columnWidths, setColumnWidths] = useLocalStorage< Record @@ -28,103 +193,288 @@ export const ConnectionTable = (props: Props) => { {}, ); - const [columns] = useState([ + const [columnVisibilityModel, setColumnVisibilityModel] = useLocalStorage< + Partial> + >( + "connection-table-visibility", + {}, { - field: "host", - headerName: t("Host"), - width: columnWidths["host"] || 220, - minWidth: 180, + serializer: JSON.stringify, + deserializer: (value) => { + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === "object") return parsed; + } catch (err) { + console.warn("Failed to parse connection-table-visibility", err); + } + return {}; + }, }, + ); + + const [columnOrder, setColumnOrder] = useLocalStorage( + "connection-table-order", + [], { - field: "download", - headerName: t("Downloaded"), - width: columnWidths["download"] || 88, - align: "right", - headerAlign: "right", - valueFormatter: (value: number) => parseTraffic(value).join(" "), + serializer: JSON.stringify, + deserializer: (value) => { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) return parsed; + } catch (err) { + console.warn("Failed to parse connection-table-order", err); + } + return []; + }, }, - { - field: "upload", - headerName: t("Uploaded"), - width: columnWidths["upload"] || 88, - align: "right", - headerAlign: "right", - valueFormatter: (value: number) => parseTraffic(value).join(" "), - }, - { - field: "dlSpeed", - headerName: t("DL Speed"), - width: columnWidths["dlSpeed"] || 88, - align: "right", - headerAlign: "right", - valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s", - }, - { - field: "ulSpeed", - headerName: t("UL Speed"), - width: columnWidths["ulSpeed"] || 88, - align: "right", - headerAlign: "right", - valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s", - }, - { - field: "chains", - headerName: t("Chains"), - width: columnWidths["chains"] || 340, - minWidth: 180, - }, - { - field: "rule", - headerName: t("Rule"), - width: columnWidths["rule"] || 280, - minWidth: 180, - }, - { - field: "process", - headerName: t("Process"), - width: columnWidths["process"] || 220, - minWidth: 180, - }, - { - field: "time", - headerName: t("Time"), - width: columnWidths["time"] || 120, - minWidth: 100, - align: "right", - headerAlign: "right", - sortComparator: (v1: string, v2: string) => - new Date(v2).getTime() - new Date(v1).getTime(), - valueFormatter: (value: number) => dayjs(value).fromNow(), - }, - { - field: "source", - headerName: t("Source"), - width: columnWidths["source"] || 200, - minWidth: 130, - }, - { - field: "remoteDestination", - headerName: t("Destination"), - width: columnWidths["remoteDestination"] || 200, - minWidth: 130, - }, - { - field: "type", - headerName: t("Type"), - width: columnWidths["type"] || 160, - minWidth: 100, - }, - ]); + ); + + const baseColumns = useMemo(() => { + return [ + { + field: "host", + headerName: t("connections.components.fields.host"), + width: columnWidths["host"] || 220, + minWidth: 180, + }, + { + field: "download", + headerName: t("shared.labels.downloaded"), + width: columnWidths["download"] || 88, + align: "right", + headerAlign: "right", + valueFormatter: (value: number) => parseTraffic(value).join(" "), + }, + { + field: "upload", + headerName: t("shared.labels.uploaded"), + width: columnWidths["upload"] || 88, + align: "right", + headerAlign: "right", + valueFormatter: (value: number) => parseTraffic(value).join(" "), + }, + { + field: "dlSpeed", + headerName: t("connections.components.fields.dlSpeed"), + width: columnWidths["dlSpeed"] || 88, + align: "right", + headerAlign: "right", + valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s", + }, + { + field: "ulSpeed", + headerName: t("connections.components.fields.ulSpeed"), + width: columnWidths["ulSpeed"] || 88, + align: "right", + headerAlign: "right", + valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s", + }, + { + field: "chains", + headerName: t("connections.components.fields.chains"), + width: columnWidths["chains"] || 340, + minWidth: 180, + }, + { + field: "rule", + headerName: t("connections.components.fields.rule"), + width: columnWidths["rule"] || 280, + minWidth: 180, + }, + { + field: "process", + headerName: t("connections.components.fields.process"), + width: columnWidths["process"] || 220, + minWidth: 180, + }, + { + field: "time", + headerName: t("connections.components.fields.time"), + width: columnWidths["time"] || 120, + minWidth: 100, + align: "right", + headerAlign: "right", + sortComparator: (v1: string, v2: string) => + new Date(v2).getTime() - new Date(v1).getTime(), + valueFormatter: (value: number) => dayjs(value).fromNow(), + }, + { + field: "source", + headerName: t("connections.components.fields.source"), + width: columnWidths["source"] || 200, + minWidth: 130, + }, + { + field: "remoteDestination", + headerName: t("connections.components.fields.destination"), + width: columnWidths["remoteDestination"] || 200, + minWidth: 130, + }, + { + field: "type", + headerName: t("connections.components.fields.type"), + width: columnWidths["type"] || 160, + minWidth: 100, + }, + ]; + }, [columnWidths, t]); + + useEffect(() => { + setColumnOrder((prevValue) => { + const baseFields = baseColumns.map((col) => col.field); + const prev = Array.isArray(prevValue) ? prevValue : []; + const reconciled = reconcileColumnOrder(prev, baseFields); + if ( + reconciled.length === prev.length && + reconciled.every((field, i) => field === prev[i]) + ) { + return prevValue; + } + return reconciled; + }); + }, [baseColumns, setColumnOrder]); + + const columns = useMemo(() => { + const order = Array.isArray(columnOrder) ? columnOrder : []; + const orderMap = new Map(order.map((field, index) => [field, index])); + + return [...baseColumns].sort((a, b) => { + const aIndex = orderMap.has(a.field) + ? (orderMap.get(a.field) as number) + : Number.MAX_SAFE_INTEGER; + const bIndex = orderMap.has(b.field) + ? (orderMap.get(b.field) as number) + : Number.MAX_SAFE_INTEGER; + + if (aIndex === bIndex) { + return order.indexOf(a.field) - order.indexOf(b.field); + } + + return aIndex - bIndex; + }); + }, [baseColumns, columnOrder]); + + const visibleColumnsCount = useMemo(() => { + return columns.reduce((count, column) => { + return (columnVisibilityModel?.[column.field] ?? true) !== false + ? count + 1 + : count; + }, 0); + }, [columns, columnVisibilityModel]); const handleColumnResize = (params: GridColumnResizeParams) => { const { colDef, width } = params; - console.log("Column resize:", colDef.field, width); setColumnWidths((prev) => ({ ...prev, [colDef.field]: width, })); }; + const handleColumnVisibilityChange = useCallback( + (model: GridColumnVisibilityModel) => { + const hiddenFields = new Set(); + Object.entries(model).forEach(([field, value]) => { + if (value === false) { + hiddenFields.add(field); + } + }); + + const nextVisibleCount = columns.reduce((count, column) => { + return hiddenFields.has(column.field) ? count : count + 1; + }, 0); + + if (nextVisibleCount === 0) { + return; + } + + setColumnVisibilityModel(() => { + const sanitized: Partial> = {}; + hiddenFields.forEach((field) => { + sanitized[field] = false; + }); + return sanitized; + }); + }, + [columns, setColumnVisibilityModel], + ); + + const handleToggleColumn = useCallback( + (field: string, visible: boolean) => { + if (!visible && visibleColumnsCount <= 1) { + return; + } + + setColumnVisibilityModel((prev) => { + const next = { ...(prev ?? {}) }; + if (visible) { + delete next[field]; + } else { + next[field] = false; + } + return next; + }); + }, + [setColumnVisibilityModel, visibleColumnsCount], + ); + + const handleColumnOrderChange = useCallback( + (params: GridColumnOrderChangeParams) => { + setColumnOrder((prevValue) => { + const baseFields = baseColumns.map((col) => col.field); + const currentOrder = Array.isArray(prevValue) + ? [...prevValue] + : [...baseFields]; + const field = params.column.field; + const currentIndex = currentOrder.indexOf(field); + if (currentIndex === -1) return currentOrder; + + currentOrder.splice(currentIndex, 1); + const targetIndex = Math.min( + Math.max(params.targetIndex, 0), + currentOrder.length, + ); + currentOrder.splice(targetIndex, 0, field); + + return currentOrder; + }); + }, + [baseColumns, setColumnOrder], + ); + + const handleManagerOrderChange = useCallback( + (order: string[]) => { + setColumnOrder(() => { + const baseFields = baseColumns.map((col) => col.field); + return reconcileColumnOrder(order, baseFields); + }); + }, + [baseColumns, setColumnOrder], + ); + + const handleResetColumns = useCallback(() => { + setColumnVisibilityModel({}); + setColumnOrder(baseColumns.map((col) => col.field)); + }, [baseColumns, setColumnOrder, setColumnVisibilityModel]); + + const gridVisibilityModel = useMemo(() => { + const result: GridColumnVisibilityModel = {}; + if (!columnVisibilityModel) return result; + Object.entries(columnVisibilityModel).forEach(([field, value]) => { + if (typeof value === "boolean") { + result[field] = value; + } + }); + return result; + }, [columnVisibilityModel]); + + const columnOptions = useMemo(() => { + return columns.map((column) => ({ + field: column.field, + label: column.headerName ?? column.field, + visible: (columnVisibilityModel?.[column.field] ?? true) !== false, + })); + }, [columns, columnVisibilityModel]); + const connRows = useMemo(() => { return connections.map((each) => { const { metadata, rulePayload } = each; @@ -155,23 +505,97 @@ export const ConnectionTable = (props: Props) => { }, [connections]); return ( - onShowDetail(e.row.connectionData)} - density="compact" - sx={{ - border: "none", - "div:focus": { outline: "none !important" }, - "& .MuiDataGrid-columnHeader": { - userSelect: "none", - }, - }} - columnVisibilityModel={columnVisible} - onColumnVisibilityModelChange={(e) => setColumnVisible(e)} - onColumnResize={handleColumnResize} - disableColumnMenu={false} - /> + + + onShowDetail(e.row.connectionData)} + density="compact" + sx={{ + flex: 1, + border: "none", + minHeight: 0, + "div:focus": { outline: "none !important" }, + "& .MuiDataGrid-columnHeader": { + userSelect: "none", + }, + }} + columnVisibilityModel={gridVisibilityModel} + onColumnVisibilityModelChange={handleColumnVisibilityChange} + onColumnResize={handleColumnResize} + onColumnOrderChange={handleColumnOrderChange} + slotProps={{ + columnMenu: { + slots: { + columnMenuColumnsItem: ConnectionColumnMenuColumnsItem, + }, + }, + }} + /> + + + + ); +}; + +type ConnectionColumnMenuManageItemProps = GridColumnMenuItemProps & { + onOpenColumnManager: () => void; +}; + +const ConnectionColumnMenuManageItem = ( + props: ConnectionColumnMenuManageItemProps, +) => { + const { onClick, onOpenColumnManager } = props; + const rootProps = useGridRootProps(); + const { t } = useTranslation(); + const handleClick = useCallback( + (event: MouseEvent) => { + onClick(event); + onOpenColumnManager(); + }, + [onClick, onOpenColumnManager], + ); + + if (rootProps.disableColumnSelector) { + return null; + } + + const MenuItem = rootProps.slots.baseMenuItem; + const Icon = rootProps.slots.columnMenuManageColumnsIcon; + + return ( + }> + {t("connections.components.columnManager.title")} + + ); +}; + +const ConnectionColumnMenuColumnsItem = (props: GridColumnMenuItemProps) => { + const onOpenColumnManager = use(ColumnManagerContext); + + return ( + <> + + + ); }; diff --git a/clash-verge-rev/src/components/controller/window-controller.tsx b/clash-verge-rev/src/components/controller/window-controller.tsx index 5e0fb638a6..f8ad6dcc1b 100644 --- a/clash-verge-rev/src/components/controller/window-controller.tsx +++ b/clash-verge-rev/src/components/controller/window-controller.tsx @@ -1,5 +1,5 @@ import { Close, CropSquare, FilterNone, Minimize } from "@mui/icons-material"; -import { IconButton } from "@mui/material"; +import { Box, IconButton } from "@mui/material"; import { forwardRef, useImperativeHandle } from "react"; import { useWindowControls } from "@/hooks/use-window"; @@ -37,10 +37,19 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) { ); // 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。 - // 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准 + // 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。 return ( -
+ button": { + cursor: "default", + }, + }} + > {OS === "macos" && ( <> {/* macOS 风格:关闭 → 最小化 → 全屏 */} @@ -67,22 +76,26 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) { {OS === "windows" && ( <> {/* Windows 风格:最小化 → 最大化 → 关闭 */} - - + + {maximized ? ( - + ) : ( - + )} - - + + )} @@ -90,25 +103,29 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) { {OS === "linux" && ( <> {/* Linux 桌面常见布局(GNOME/KDE 多为:最小化 → 最大化 → 关闭) */} - - + + {maximized ? ( - + ) : ( - + )} - - + + )} -
+ ); }); diff --git a/clash-verge-rev/src/components/home/clash-info-card.tsx b/clash-verge-rev/src/components/home/clash-info-card.tsx index 9b6ff4b71b..ed62732f52 100644 --- a/clash-verge-rev/src/components/home/clash-info-card.tsx +++ b/clash-verge-rev/src/components/home/clash-info-card.tsx @@ -32,7 +32,7 @@ export const ClashInfoCard = () => { - {t("Core Version")} + {t("home.components.clashInfo.fields.coreVersion")} {clashVersion || "-"} @@ -41,7 +41,7 @@ export const ClashInfoCard = () => { - {t("System Proxy Address")} + {t("home.components.clashInfo.fields.systemProxyAddress")} {systemProxyAddress} @@ -50,7 +50,7 @@ export const ClashInfoCard = () => { - {t("Mixed Port")} + {t("home.components.clashInfo.fields.mixedPort")} {clashConfig.mixedPort || "-"} @@ -59,7 +59,7 @@ export const ClashInfoCard = () => { - {t("Uptime")} + {t("home.components.clashInfo.fields.uptime")} {formattedUptime} @@ -68,7 +68,7 @@ export const ClashInfoCard = () => { - {t("Rules Count")} + {t("home.components.clashInfo.fields.rulesCount")} {rules.length} @@ -87,7 +87,7 @@ export const ClashInfoCard = () => { return ( } iconColor="warning" action={null} diff --git a/clash-verge-rev/src/components/home/clash-mode-card.tsx b/clash-verge-rev/src/components/home/clash-mode-card.tsx index 9458da0503..ea48128ca9 100644 --- a/clash-verge-rev/src/components/home/clash-mode-card.tsx +++ b/clash-verge-rev/src/components/home/clash-mode-card.tsx @@ -12,6 +12,31 @@ import { closeAllConnections } from "tauri-plugin-mihomo-api"; import { useVerge } from "@/hooks/use-verge"; import { useAppData } from "@/providers/app-data-context"; import { patchClashMode } from "@/services/cmds"; +import type { TranslationKey } from "@/types/generated/i18n-keys"; + +const CLASH_MODES = ["rule", "global", "direct"] as const; +type ClashMode = (typeof CLASH_MODES)[number]; + +const isClashMode = (mode: string): mode is ClashMode => + (CLASH_MODES as readonly string[]).includes(mode); + +const MODE_META: Record< + ClashMode, + { label: TranslationKey; description: TranslationKey } +> = { + rule: { + label: "home.components.clashMode.labels.rule", + description: "home.components.clashMode.descriptions.rule", + }, + global: { + label: "home.components.clashMode.labels.global", + description: "home.components.clashMode.descriptions.global", + }, + direct: { + label: "home.components.clashMode.labels.direct", + description: "home.components.clashMode.descriptions.direct", + }, +}; export const ClashModeCard = () => { const { t } = useTranslation(); @@ -19,19 +44,21 @@ export const ClashModeCard = () => { const { clashConfig, refreshClashConfig } = useAppData(); // 支持的模式列表 - const modeList = useMemo(() => ["rule", "global", "direct"] as const, []); + const modeList = CLASH_MODES; // 直接使用API返回的模式,不维护本地状态 const currentMode = clashConfig?.mode?.toLowerCase(); + const currentModeKey = + typeof currentMode === "string" && isClashMode(currentMode) + ? currentMode + : undefined; const modeDescription = useMemo(() => { - if (typeof currentMode === "string" && currentMode.length > 0) { - return t( - `${currentMode[0].toLocaleUpperCase()}${currentMode.slice(1)} Mode Description`, - ); + if (currentModeKey) { + return t(MODE_META[currentModeKey].description); } - return t("Core communication error"); - }, [currentMode]); + return t("home.components.clashMode.errors.communication"); + }, [currentModeKey, t]); // 模式图标映射 const modeIcons = useMemo( @@ -44,8 +71,8 @@ export const ClashModeCard = () => { ); // 切换模式的处理函数 - const onChangeMode = useLockFn(async (mode: string) => { - if (mode === currentMode) return; + const onChangeMode = useLockFn(async (mode: ClashMode) => { + if (mode === currentModeKey) return; if (verge?.auto_close_connection) { closeAllConnections(); } @@ -60,7 +87,7 @@ export const ClashModeCard = () => { }); // 按钮样式 - const buttonStyles = (mode: string) => ({ + const buttonStyles = (mode: ClashMode) => ({ cursor: "pointer", px: 2, py: 1.2, @@ -68,8 +95,8 @@ export const ClashModeCard = () => { alignItems: "center", justifyContent: "center", gap: 1, - bgcolor: mode === currentMode ? "primary.main" : "background.paper", - color: mode === currentMode ? "primary.contrastText" : "text.primary", + bgcolor: mode === currentModeKey ? "primary.main" : "background.paper", + color: mode === currentModeKey ? "primary.contrastText" : "text.primary", borderRadius: 1.5, transition: "all 0.2s ease-in-out", position: "relative", @@ -82,7 +109,7 @@ export const ClashModeCard = () => { transform: "translateY(1px)", }, "&::after": - mode === currentMode + mode === currentModeKey ? { content: '""', position: "absolute", @@ -128,7 +155,7 @@ export const ClashModeCard = () => { {modeList.map((mode) => ( onChangeMode(mode)} sx={buttonStyles(mode)} > @@ -137,10 +164,10 @@ export const ClashModeCard = () => { variant="body2" sx={{ textTransform: "capitalize", - fontWeight: mode === currentMode ? 600 : 400, + fontWeight: mode === currentModeKey ? 600 : 400, }} > - {t(mode)} + {t(MODE_META[mode].label)} ))} diff --git a/clash-verge-rev/src/components/home/current-proxy-card.tsx b/clash-verge-rev/src/components/home/current-proxy-card.tsx index b3f372eaf5..c29db668b5 100644 --- a/clash-verge-rev/src/components/home/current-proxy-card.tsx +++ b/clash-verge-rev/src/components/home/current-proxy-card.tsx @@ -28,12 +28,13 @@ import { } from "@mui/material"; import { useLockFn } from "ahooks"; import React from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { delayGroup, healthcheckProxyProvider } from "tauri-plugin-mihomo-api"; import { EnhancedCard } from "@/components/home/enhanced-card"; +import { useProfiles } from "@/hooks/use-profiles"; import { useProxySelection } from "@/hooks/use-proxy-selection"; import { useVerge } from "@/hooks/use-verge"; import { useAppData } from "@/providers/app-data-context"; @@ -44,6 +45,9 @@ const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group"; const STORAGE_KEY_PROXY = "clash-verge-selected-proxy"; const STORAGE_KEY_SORT_TYPE = "clash-verge-proxy-sort-type"; +const AUTO_CHECK_INITIAL_DELAY_MS = 1500; +const AUTO_CHECK_INTERVAL_MS = 5 * 60 * 1000; + // 代理节点信息接口 interface ProxyOption { name: string; @@ -96,8 +100,52 @@ export const CurrentProxyCard = () => { const { t } = useTranslation(); const navigate = useNavigate(); const theme = useTheme(); - const { proxies, clashConfig, refreshProxy } = useAppData(); + const { proxies, clashConfig, refreshProxy, rules } = useAppData(); const { verge } = useVerge(); + const { current: currentProfile } = useProfiles(); + const autoDelayEnabled = verge?.enable_auto_delay_detection ?? false; + const currentProfileId = currentProfile?.uid || null; + + const getProfileStorageKey = useCallback( + (baseKey: string) => + currentProfileId ? `${baseKey}:${currentProfileId}` : baseKey, + [currentProfileId], + ); + + const readProfileScopedItem = useCallback( + (baseKey: string) => { + if (typeof window === "undefined") return null; + const profileKey = getProfileStorageKey(baseKey); + const profileValue = localStorage.getItem(profileKey); + if (profileValue != null) { + return profileValue; + } + + if (profileKey !== baseKey) { + const legacyValue = localStorage.getItem(baseKey); + if (legacyValue != null) { + localStorage.removeItem(baseKey); + localStorage.setItem(profileKey, legacyValue); + return legacyValue; + } + } + + return null; + }, + [getProfileStorageKey], + ); + + const writeProfileScopedItem = useCallback( + (baseKey: string, value: string) => { + if (typeof window === "undefined") return; + const profileKey = getProfileStorageKey(baseKey); + localStorage.setItem(profileKey, value); + if (profileKey !== baseKey) { + localStorage.removeItem(baseKey); + } + }, + [getProfileStorageKey], + ); // 统一代理选择器 const { handleSelectChange } = useProxySelection({ @@ -115,16 +163,47 @@ export const CurrentProxyCard = () => { 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; }); + const [delaySortRefresh, setDelaySortRefresh] = useState(0); + + const normalizePolicyName = useCallback( + (value?: string | null) => (typeof value === "string" ? value.trim() : ""), + [], + ); + + const matchPolicyName = useMemo(() => { + if (!Array.isArray(rules)) return ""; + for (let index = rules.length - 1; index >= 0; index -= 1) { + const rule = rules[index]; + if (!rule) continue; + + if ( + typeof rule?.type === "string" && + rule.type.toUpperCase() === "MATCH" + ) { + const policy = normalizePolicyName(rule.proxy); + if (policy) { + return policy; + } + } + } + return ""; + }, [rules, normalizePolicyName]); + + type ProxyGroupOption = { + name: string; + now: string; + all: string[]; + type?: string; + }; - // 定义状态类型 type ProxyState = { proxyData: { - groups: { name: string; now: string; all: string[] }[]; + groups: ProxyGroupOption[]; records: Record; }; selection: { @@ -146,6 +225,25 @@ export const CurrentProxyCard = () => { displayProxy: null, }); + const autoCheckInProgressRef = useRef(false); + const latestTimeoutRef = useRef( + verge?.default_latency_timeout || 10000, + ); + const latestProxyRecordRef = useRef(null); + + useEffect(() => { + latestTimeoutRef.current = verge?.default_latency_timeout || 10000; + }, [verge?.default_latency_timeout]); + + useEffect(() => { + if (!state.selection.proxy) { + latestProxyRecordRef.current = null; + return; + } + latestProxyRecordRef.current = + state.proxyData.records?.[state.selection.proxy] || null; + }, [state.selection.proxy, state.proxyData.records]); + // 初始化选择的组 useEffect(() => { if (!proxies) return; @@ -193,7 +291,7 @@ export const CurrentProxyCard = () => { }, })); } else { - const savedGroup = localStorage.getItem(STORAGE_KEY_GROUP); + const savedGroup = readProfileScopedItem(STORAGE_KEY_GROUP); // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect setState((prev) => ({ ...prev, @@ -203,7 +301,7 @@ export const CurrentProxyCard = () => { }, })); } - }, [isGlobalMode, isDirectMode, proxies]); + }, [isGlobalMode, isDirectMode, proxies, readProfileScopedItem]); // 监听代理数据变化,更新状态 useEffect(() => { @@ -211,26 +309,66 @@ export const CurrentProxyCard = () => { // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect setState((prev) => { - // 只保留 Selector 类型的组用于选择 - const filteredGroups = proxies.groups - .filter((g: { name: string; type?: string }) => g.type === "Selector") - .map( - (g: { name: string; now: string; all: Array<{ name: string }> }) => ({ - name: g.name, - now: g.now || "", - all: g.all.map((p: { name: string }) => p.name), - }), - ); + const groupsMap = new Map(); + + const registerGroup = (group: any, fallbackName?: string) => { + if (!group && !fallbackName) return; + + const rawName = + typeof group?.name === "string" && group.name.length > 0 + ? group.name + : fallbackName; + const name = normalizePolicyName(rawName); + if (!name || groupsMap.has(name)) return; + + const rawAll = ( + Array.isArray(group?.all) + ? (group.all as Array) + : [] + ) as Array; + const allNames = rawAll + .map((item) => + typeof item === "string" + ? normalizePolicyName(item) + : normalizePolicyName(item?.name), + ) + .filter((value): value is string => value.length > 0); + + const uniqueAll = Array.from(new Set(allNames)); + if (uniqueAll.length === 0) return; + + groupsMap.set(name, { + name, + now: normalizePolicyName(group?.now), + all: uniqueAll, + type: group?.type, + }); + }; + + if (matchPolicyName) { + const matchGroup = + proxies.groups?.find( + (g: { name: string }) => g.name === matchPolicyName, + ) || + (proxies.global?.name === matchPolicyName ? proxies.global : null) || + proxies.records?.[matchPolicyName]; + registerGroup(matchGroup, matchPolicyName); + } + + (proxies.groups || []) + .filter((g: { type?: string }) => g?.type === "Selector") + .forEach((selectorGroup: any) => registerGroup(selectorGroup)); + + const filteredGroups = Array.from(groupsMap.values()); let newProxy = ""; let newDisplayProxy = null; let newGroup = prev.selection.group; - // 根据模式确定新代理 if (isDirectMode) { newGroup = "DIRECT"; newProxy = "DIRECT"; - newDisplayProxy = proxies.records?.DIRECT || { name: "DIRECT" }; // 确保非空 + newDisplayProxy = proxies.records?.DIRECT || { name: "DIRECT" }; } else if (isGlobalMode && proxies.global) { newGroup = "GLOBAL"; newProxy = proxies.global.now || ""; @@ -240,18 +378,17 @@ export const CurrentProxyCard = () => { (g: { name: string }) => g.name === prev.selection.group, ); - // 如果当前组不存在或为空,自动选择第一个 selector 类型的组 if (!currentGroup && filteredGroups.length > 0) { - const selectorGroup = filteredGroups[0]; - if (selectorGroup) { - newGroup = selectorGroup.name; - newProxy = selectorGroup.now || selectorGroup.all[0] || ""; + const firstGroup = filteredGroups[0]; + if (firstGroup) { + newGroup = firstGroup.name; + newProxy = firstGroup.now || firstGroup.all[0] || ""; newDisplayProxy = proxies.records?.[newProxy] || null; if (!isGlobalMode && !isDirectMode) { - localStorage.setItem(STORAGE_KEY_GROUP, newGroup); + writeProfileScopedItem(STORAGE_KEY_GROUP, newGroup); if (newProxy) { - localStorage.setItem(STORAGE_KEY_PROXY, newProxy); + writeProfileScopedItem(STORAGE_KEY_PROXY, newProxy); } } } @@ -261,7 +398,6 @@ export const CurrentProxyCard = () => { } } - // 返回新状态 return { proxyData: { groups: filteredGroups, @@ -274,7 +410,14 @@ export const CurrentProxyCard = () => { displayProxy: newDisplayProxy, }; }); - }, [proxies, isGlobalMode, isDirectMode]); + }, [ + proxies, + isGlobalMode, + isDirectMode, + writeProfileScopedItem, + normalizePolicyName, + matchPolicyName, + ]); // 使用防抖包装状态更新 const timeoutRef = React.useRef | null>(null); @@ -298,7 +441,7 @@ export const CurrentProxyCard = () => { const newGroup = event.target.value; - localStorage.setItem(STORAGE_KEY_GROUP, newGroup); + writeProfileScopedItem(STORAGE_KEY_GROUP, newGroup); setState((prev) => { const group = prev.proxyData.groups.find( @@ -323,7 +466,7 @@ export const CurrentProxyCard = () => { }; }); }, - [isGlobalMode, isDirectMode], + [isGlobalMode, isDirectMode, writeProfileScopedItem], ); // 处理代理节点变更 @@ -345,7 +488,7 @@ export const CurrentProxyCard = () => { })); if (!isGlobalMode && !isDirectMode) { - localStorage.setItem(STORAGE_KEY_PROXY, newProxy); + writeProfileScopedItem(STORAGE_KEY_PROXY, newProxy); } const skipConfigSave = isGlobalMode || isDirectMode; @@ -357,12 +500,13 @@ export const CurrentProxyCard = () => { state.selection, debouncedSetState, handleSelectChange, + writeProfileScopedItem, ], ); // 导航到代理页面 const goToProxies = useCallback(() => { - navigate("/"); + navigate("/proxies"); }, [navigate]); // 获取要显示的代理节点 @@ -382,29 +526,112 @@ export const CurrentProxyCard = () => { ? getSignalIcon(currentDelay) : { icon: , text: "未初始化", color: "text.secondary" }; + const checkCurrentProxyDelay = useCallback(async () => { + if (autoCheckInProgressRef.current) return; + if (isDirectMode) return; + + const groupName = state.selection.group; + const proxyName = state.selection.proxy; + + if (!groupName || !proxyName) return; + + const proxyRecord = latestProxyRecordRef.current; + if (!proxyRecord) { + console.log( + `[CurrentProxyCard] 自动延迟检测跳过,组: ${groupName}, 节点: ${proxyName} 未找到`, + ); + return; + } + + autoCheckInProgressRef.current = true; + + const timeout = latestTimeoutRef.current || 10000; + + try { + console.log( + `[CurrentProxyCard] 自动检测当前节点延迟,组: ${groupName}, 节点: ${proxyName}`, + ); + if (proxyRecord.provider) { + await healthcheckProxyProvider(proxyRecord.provider); + } else { + await delayManager.checkDelay(proxyName, groupName, timeout); + } + } catch (error) { + console.error( + `[CurrentProxyCard] 自动检测当前节点延迟失败,组: ${groupName}, 节点: ${proxyName}`, + error, + ); + } finally { + autoCheckInProgressRef.current = false; + refreshProxy(); + if (sortType === 1) { + setDelaySortRefresh((prev) => prev + 1); + } + } + }, [ + isDirectMode, + refreshProxy, + state.selection.group, + state.selection.proxy, + sortType, + setDelaySortRefresh, + ]); + + useEffect(() => { + if (isDirectMode) return; + if (!autoDelayEnabled) return; + if (!state.selection.group || !state.selection.proxy) return; + + let disposed = false; + let intervalTimer: ReturnType | null = null; + let initialTimer: ReturnType | null = null; + + const runAndSchedule = async () => { + if (disposed) return; + await checkCurrentProxyDelay(); + if (disposed) return; + intervalTimer = setTimeout(runAndSchedule, AUTO_CHECK_INTERVAL_MS); + }; + + initialTimer = setTimeout(async () => { + await checkCurrentProxyDelay(); + if (disposed) return; + intervalTimer = setTimeout(runAndSchedule, AUTO_CHECK_INTERVAL_MS); + }, AUTO_CHECK_INITIAL_DELAY_MS); + + return () => { + disposed = true; + if (initialTimer) clearTimeout(initialTimer); + if (intervalTimer) clearTimeout(intervalTimer); + }; + }, [ + checkCurrentProxyDelay, + isDirectMode, + state.selection.group, + state.selection.proxy, + autoDelayEnabled, + ]); + // 自定义渲染选择框中的值 - const renderProxyValue = useCallback( - (selected: string) => { - if (!selected || !state.proxyData.records[selected]) return selected; + const renderProxyValue = (selected: string) => { + if (!selected || !state.proxyData.records[selected]) return selected; - const delayValue = delayManager.getDelayFix( - state.proxyData.records[selected], - state.selection.group, - ); + const delayValue = delayManager.getDelayFix( + state.proxyData.records[selected], + state.selection.group, + ); - return ( - - {selected} - - - ); - }, - [state.proxyData.records, state.selection.group], - ); + return ( + + {selected} + + + ); + }; // 排序类型变更 const handleSortTypeChange = useCallback(() => { @@ -490,24 +717,28 @@ export const CurrentProxyCard = () => { } refreshProxy(); + if (sortType === 1) { + setDelaySortRefresh((prev) => prev + 1); + } }); - // 排序代理函数(增加非空校验) - const sortProxies = useCallback( - (proxies: ProxyOption[]) => { - if (!proxies || sortType === 0) return proxies; + // 计算要显示的代理选项(增加非空校验) + const proxyOptions = useMemo(() => { + const sortWithLatency = (proxiesToSort: ProxyOption[]) => { + if (!proxiesToSort || sortType === 0) return proxiesToSort; - // 确保数据存在 - if (!state.proxyData.records || !state.selection.group) return proxies; + if (!state.proxyData.records || !state.selection.group) { + return proxiesToSort; + } - const list = [...proxies]; + const list = [...proxiesToSort]; if (sortType === 1) { + const refreshTick = delaySortRefresh; list.sort((a, b) => { const recordA = state.proxyData.records[a.name]; const recordB = state.proxyData.records[b.name]; - // 处理 record 不存在的情况 if (!recordA) return 1; if (!recordB) return -1; @@ -517,19 +748,16 @@ export const CurrentProxyCard = () => { if (ad === -1 || ad === -2) return 1; if (bd === -1 || bd === -2) return -1; - return ad - bd; + if (ad !== bd) return ad - bd; + return refreshTick >= 0 ? a.name.localeCompare(b.name) : 0; }); } else { list.sort((a, b) => a.name.localeCompare(b.name)); } return list; - }, - [sortType, state.proxyData.records, state.selection.group], - ); + }; - // 计算要显示的代理选项(增加非空校验) - const proxyOptions = useMemo(() => { if (isDirectMode) { return [{ name: "DIRECT" }]; } @@ -543,7 +771,7 @@ export const CurrentProxyCard = () => { name: typeof p === "string" ? p : p.name, })); - return sortProxies(options); + return sortWithLatency(options); } // 规则模式 @@ -553,7 +781,7 @@ export const CurrentProxyCard = () => { if (group) { const options = group.all.map((name) => ({ name })); - return sortProxies(options); + return sortWithLatency(options); } return []; @@ -563,7 +791,8 @@ export const CurrentProxyCard = () => { proxies, state.proxyData, state.selection.group, - sortProxies, + sortType, + delaySortRefresh, ]); // 获取排序图标 @@ -582,11 +811,11 @@ export const CurrentProxyCard = () => { const getSortTooltip = (): string => { switch (sortType) { case 0: - return t("Sort by default"); + return t("proxies.page.tooltips.sortDefault"); case 1: - return t("Sort by delay"); + return t("proxies.page.tooltips.sortDelay"); case 2: - return t("Sort by name"); + return t("proxies.page.tooltips.sortName"); default: return ""; } @@ -594,7 +823,7 @@ export const CurrentProxyCard = () => { return ( { iconColor={currentProxy ? "primary" : undefined} action={ - + { sx={{ borderRadius: 1.5 }} endIcon={} > - {t("Label-Proxies")} + {t("layout.components.navigation.tabs.proxies")} } @@ -677,7 +908,7 @@ export const CurrentProxyCard = () => { {isGlobalMode && ( @@ -685,7 +916,7 @@ export const CurrentProxyCard = () => { {isDirectMode && ( @@ -725,12 +956,14 @@ export const CurrentProxyCard = () => { size="small" sx={{ mb: 1.5 }} > - {t("Group")} + + {t("home.components.currentProxy.labels.group")} + { ) : ( - {t("No active proxy node")} + {t("home.components.currentProxy.labels.noActiveNode")} )} diff --git a/clash-verge-rev/src/components/home/enhanced-canvas-traffic-graph.tsx b/clash-verge-rev/src/components/home/enhanced-canvas-traffic-graph.tsx index 7f78cf66f1..0cb5d43cf0 100644 --- a/clash-verge-rev/src/components/home/enhanced-canvas-traffic-graph.tsx +++ b/clash-verge-rev/src/components/home/enhanced-canvas-traffic-graph.tsx @@ -6,6 +6,7 @@ import { useEffect, useImperativeHandle, useMemo, + useReducer, useRef, useState, } from "react"; @@ -76,10 +77,26 @@ const GRAPH_CONFIG = { }, }; +const MIN_FPS = 8; +const MAX_FPS = 20; +const FPS_ADJUST_INTERVAL = 3000; // ms +const FPS_SAMPLE_WINDOW = 12; +const STALE_DATA_THRESHOLD = 2500; // ms without fresh data => drop FPS +const RESUME_FPS_TARGET = 12; +const RESUME_COOLDOWN_MS = 2000; + +const getNow = () => + typeof performance !== "undefined" ? performance.now() : Date.now(); + interface EnhancedCanvasTrafficGraphProps { ref?: Ref; } +const displayDataReducer = ( + _: ITrafficDataPoint[], + payload: ITrafficDataPoint[], +): ITrafficDataPoint[] => payload; + /** * 稳定版Canvas流量图表组件 * 修复闪烁问题,添加时间轴显示 @@ -99,6 +116,10 @@ export const EnhancedCanvasTrafficGraph = memo( const [timeRange, setTimeRange] = useState(10); const [chartStyle, setChartStyle] = useState<"bezier" | "line">("bezier"); + const initialFocusState = + typeof document !== "undefined" ? !document.hidden : true; + const [isWindowFocused, setIsWindowFocused] = useState(initialFocusState); + // 悬浮提示状态 const [tooltipData, setTooltipData] = useState({ x: 0, @@ -116,9 +137,26 @@ export const EnhancedCanvasTrafficGraph = memo( const animationFrameRef = useRef(undefined); const lastRenderTimeRef = useRef(0); const isInitializedRef = useRef(false); + const isWindowFocusedRef = useRef(initialFocusState); + const fpsControllerRef = useRef<{ + target: number; + samples: number[]; + lastAdjustTime: number; + }>({ + target: GRAPH_CONFIG.targetFPS, + samples: [], + lastAdjustTime: 0, + }); + const lastDataTimestampRef = useRef(0); + const resumeCooldownRef = useRef(0); // 当前显示的数据缓存 - const [displayData, setDisplayData] = useState([]); + const [displayData, dispatchDisplayData] = useReducer( + displayDataReducer, + [], + ); + const debounceTimeoutRef = useRef(null); + const [currentFPS, setCurrentFPS] = useState(GRAPH_CONFIG.targetFPS); // 主题颜色配置 const colors = useMemo( @@ -133,26 +171,95 @@ export const EnhancedCanvasTrafficGraph = memo( ); // 更新显示数据(防抖处理) - const updateDisplayDataDebounced = useMemo(() => { - let timeoutId: number; - return (newData: ITrafficDataPoint[]) => { - clearTimeout(timeoutId); - timeoutId = window.setTimeout(() => { - setDisplayData(newData); - }, 50); // 50ms防抖 - }; + const updateDisplayData = useCallback((newData: ITrafficDataPoint[]) => { + if (debounceTimeoutRef.current !== null) { + window.clearTimeout(debounceTimeoutRef.current); + } + debounceTimeoutRef.current = window.setTimeout(() => { + dispatchDisplayData(newData); + }, 50); // 50ms防抖 }, []); // 监听数据变化 useEffect(() => { const timeRangeData = getDataForTimeRange(timeRange); - updateDisplayDataDebounced(timeRangeData); - }, [ - dataPoints, - timeRange, - getDataForTimeRange, - updateDisplayDataDebounced, - ]); + updateDisplayData(timeRangeData); + + return () => { + if (debounceTimeoutRef.current !== null) { + window.clearTimeout(debounceTimeoutRef.current); + debounceTimeoutRef.current = null; + } + }; + }, [dataPoints, timeRange, getDataForTimeRange, updateDisplayData]); + + useEffect(() => { + if (displayData.length === 0) { + lastDataTimestampRef.current = 0; + fpsControllerRef.current.target = GRAPH_CONFIG.targetFPS; + fpsControllerRef.current.samples = []; + fpsControllerRef.current.lastAdjustTime = 0; + // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect + setCurrentFPS(GRAPH_CONFIG.targetFPS); + return; + } + + const latestTimestamp = + displayData[displayData.length - 1]?.timestamp ?? null; + if (latestTimestamp) { + lastDataTimestampRef.current = latestTimestamp; + } + }, [displayData]); + + const handleFocusStateChange = useCallback( + (focused: boolean) => { + isWindowFocusedRef.current = focused; + setIsWindowFocused(focused); + + const highResNow = getNow(); + lastRenderTimeRef.current = highResNow; + + if (focused) { + resumeCooldownRef.current = Date.now(); + const controller = fpsControllerRef.current; + const resumeTarget = Math.max( + MIN_FPS, + Math.min(controller.target, RESUME_FPS_TARGET), + ); + controller.target = resumeTarget; + controller.samples = []; + controller.lastAdjustTime = 0; + setCurrentFPS(resumeTarget); + } else { + resumeCooldownRef.current = 0; + } + }, + [setIsWindowFocused, setCurrentFPS], + ); + + useEffect(() => { + if (typeof window === "undefined" || typeof document === "undefined") { + return; + } + + const handleFocus = () => handleFocusStateChange(true); + const handleBlur = () => handleFocusStateChange(false); + const handleVisibilityChange = () => + handleFocusStateChange(!document.hidden); + + window.addEventListener("focus", handleFocus); + window.addEventListener("blur", handleBlur); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + window.removeEventListener("focus", handleFocus); + window.removeEventListener("blur", handleBlur); + document.removeEventListener( + "visibilitychange", + handleVisibilityChange, + ); + }; + }, [handleFocusStateChange]); // Y轴坐标计算 - 基于刻度范围的线性映射 const calculateY = useCallback( @@ -781,31 +888,135 @@ export const EnhancedCanvasTrafficGraph = memo( tooltipData, ]); + const collectFrameSample = useCallback( + (renderDuration: number, frameBudget: number) => { + const controller = fpsControllerRef.current; + controller.samples.push(renderDuration); + if (controller.samples.length > FPS_SAMPLE_WINDOW) { + controller.samples.shift(); + } + + const perfNow = getNow(); + const lastDataAge = + lastDataTimestampRef.current > 0 + ? Date.now() - lastDataTimestampRef.current + : null; + const isDataStale = + typeof lastDataAge === "number" && lastDataAge > STALE_DATA_THRESHOLD; + + let inResumeCooldown = false; + if (resumeCooldownRef.current) { + const elapsedSinceResume = Date.now() - resumeCooldownRef.current; + if (elapsedSinceResume < RESUME_COOLDOWN_MS) { + inResumeCooldown = true; + } else { + resumeCooldownRef.current = 0; + } + } + + if (isDataStale && controller.target !== MIN_FPS) { + controller.target = MIN_FPS; + controller.samples = []; + controller.lastAdjustTime = perfNow; + setCurrentFPS(controller.target); + return; + } + + if ( + !isDataStale && + !inResumeCooldown && + controller.target < GRAPH_CONFIG.targetFPS + ) { + controller.target = Math.min( + GRAPH_CONFIG.targetFPS, + controller.target + 2, + ); + controller.samples = []; + controller.lastAdjustTime = perfNow; + setCurrentFPS(controller.target); + } + + if ( + controller.lastAdjustTime !== 0 && + perfNow - controller.lastAdjustTime < FPS_ADJUST_INTERVAL + ) { + return; + } + + if (controller.samples.length === 0) return; + + const avgRender = + controller.samples.reduce((sum, value) => sum + value, 0) / + controller.samples.length; + + let nextTarget = controller.target; + + if (avgRender > frameBudget * 0.75 && controller.target > MIN_FPS) { + nextTarget = Math.max(MIN_FPS, controller.target - 2); + } else if ( + avgRender < Math.max(4, frameBudget * 0.4) && + controller.target < MAX_FPS && + !inResumeCooldown + ) { + nextTarget = Math.min(MAX_FPS, controller.target + 2); + } + + controller.samples = []; + controller.lastAdjustTime = perfNow; + + if (nextTarget !== controller.target) { + controller.target = nextTarget; + setCurrentFPS(nextTarget); + } + }, + [setCurrentFPS], + ); + // 受控的动画循环 useEffect(() => { - const animate = (currentTime: number) => { - // 控制帧率,减少不必要的重绘 - if ( - currentTime - lastRenderTimeRef.current >= - 1000 / GRAPH_CONFIG.targetFPS - ) { - drawGraph(); - lastRenderTimeRef.current = currentTime; + if (!isWindowFocused || displayData.length === 0) { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = undefined; } + lastRenderTimeRef.current = getNow(); + return; + } + + const animate = (currentTime: number) => { + if (!isWindowFocusedRef.current) { + lastRenderTimeRef.current = getNow(); + animationFrameRef.current = undefined; + return; + } + + const targetFPS = fpsControllerRef.current.target; + const frameBudget = 1000 / targetFPS; + + if ( + currentTime - lastRenderTimeRef.current >= frameBudget || + !isInitializedRef.current + ) { + const drawStart = getNow(); + drawGraph(); + const drawEnd = getNow(); + + lastRenderTimeRef.current = currentTime; + collectFrameSample(drawEnd - drawStart, frameBudget); + } + animationFrameRef.current = requestAnimationFrame(animate); }; - // 只有在有数据时才开始动画 - if (displayData.length > 0) { - animationFrameRef.current = requestAnimationFrame(animate); - } + animationFrameRef.current = requestAnimationFrame(animate); return () => { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = undefined; } }; - }, [drawGraph, displayData.length]); + }, [drawGraph, displayData.length, isWindowFocused, collectFrameSample]); // 切换时间范围 const handleTimeRangeClick = useCallback((event: React.MouseEvent) => { @@ -840,7 +1051,9 @@ export const EnhancedCanvasTrafficGraph = memo( // 获取时间范围文本 const getTimeRangeText = useCallback(() => { - return t("{{time}} Minutes", { time: timeRange }); + return t("home.components.traffic.patterns.minutes", { + time: timeRange, + }); }, [timeRange, t]); return ( @@ -923,7 +1136,7 @@ export const EnhancedCanvasTrafficGraph = memo( textAlign: "right", }} > - {t("Upload")} + {t("home.components.traffic.legends.upload")} - {t("Download")} + {t("home.components.traffic.legends.download")} @@ -964,7 +1177,7 @@ export const EnhancedCanvasTrafficGraph = memo( }} > Points: {displayData.length} | Compressed:{" "} - {samplerStats.compressedBufferSize} + {samplerStats.compressedBufferSize} | FPS: {currentFPS} {/* 悬浮提示框 */} diff --git a/clash-verge-rev/src/components/home/enhanced-traffic-stats.tsx b/clash-verge-rev/src/components/home/enhanced-traffic-stats.tsx index e731ac72c2..a725b542ad 100644 --- a/clash-verge-rev/src/components/home/enhanced-traffic-stats.tsx +++ b/clash-verge-rev/src/components/home/enhanced-traffic-stats.tsx @@ -187,7 +187,7 @@ export const EnhancedTrafficStats = () => { uploadTotalUnit, downloadTotal, downloadTotalUnit, - connectionsCount: connections?.connections.length, + connectionsCount: connections?.activeConnections.length, }; }, [traffic, memory, connections]); @@ -219,42 +219,42 @@ export const EnhancedTrafficStats = () => { () => [ { icon: , - title: t("Upload Speed"), + title: t("home.components.traffic.metrics.uploadSpeed"), value: parsedData.up, unit: `${parsedData.upUnit}/s`, color: "secondary" as const, }, { icon: , - title: t("Download Speed"), + title: t("home.components.traffic.metrics.downloadSpeed"), value: parsedData.down, unit: `${parsedData.downUnit}/s`, color: "primary" as const, }, { icon: , - title: t("Active Connections"), + title: t("home.components.traffic.metrics.activeConnections"), value: parsedData.connectionsCount, unit: "", color: "success" as const, }, { icon: , - title: t("Uploaded"), + title: t("shared.labels.uploaded"), value: parsedData.uploadTotal, unit: parsedData.uploadTotalUnit, color: "secondary" as const, }, { icon: , - title: t("Downloaded"), + title: t("shared.labels.downloaded"), value: parsedData.downloadTotal, unit: parsedData.downloadTotalUnit, color: "primary" as const, }, { icon: , - title: t("Memory Usage"), + title: t("home.components.traffic.metrics.memoryUsage"), value: parsedData.inuse, unit: parsedData.inuseUnit, color: "error" as const, @@ -278,7 +278,7 @@ export const EnhancedTrafficStats = () => { )} {/* 统计卡片区域 */} - {statCards.map((card, _index) => ( + {statCards.map((card) => ( diff --git a/clash-verge-rev/src/components/home/home-profile-card.tsx b/clash-verge-rev/src/components/home/home-profile-card.tsx index 14f7719280..86d0e3ef02 100644 --- a/clash-verge-rev/src/components/home/home-profile-card.tsx +++ b/clash-verge-rev/src/components/home/home-profile-card.tsx @@ -22,7 +22,7 @@ import { useLockFn } from "ahooks"; import dayjs from "dayjs"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { useAppData } from "@/providers/app-data-context"; import { openWebUrl, updateProfile } from "@/services/cmds"; @@ -111,7 +111,7 @@ const ProfileDetails = ({ noWrap sx={{ display: "flex", alignItems: "center" }} > - {t("From")}: + {t("shared.labels.from")}: {current.home ? ( - {t("Update Time")}:{" "} + {t("shared.labels.updateTime")}:{" "} {dayjs(current.updated * 1000).format("YYYY-MM-DD HH:mm")} @@ -199,7 +199,7 @@ const ProfileDetails = ({ - {t("Used / Total")}:{" "} + {t("shared.labels.usedTotal")}:{" "} {parseTraffic(usedTraffic)} /{" "} {parseTraffic(current.extra.total)} @@ -211,7 +211,7 @@ const ProfileDetails = ({ - {t("Expire Time")}:{" "} + {t("shared.labels.expireTime")}:{" "} {parseExpire(current.extra.expire)} @@ -266,10 +266,10 @@ const EmptyProfile = ({ onClick }: { onClick: () => void }) => { sx={{ fontSize: 60, color: "primary.main", mb: 2 }} /> - {t("Import")} {t("Profiles")} + {t("profiles.page.actions.import")} {t("profiles.page.title")} - {t("Click to import subscription")} + {t("profiles.components.card.labels.clickToImport")} ); @@ -292,13 +292,12 @@ export const HomeProfileCard = ({ setUpdating(true); try { await updateProfile(current.uid, current.option); - showNotice("success", t("Update subscription successfully"), 1000); onProfileUpdated?.(); // 刷新首页数据 refreshAll(); - } catch (err: any) { - showNotice("error", err.message || err.toString(), 3000); + } catch (err) { + showNotice.error(err, 3000); } finally { setUpdating(false); } @@ -311,7 +310,7 @@ export const HomeProfileCard = ({ // 卡片标题 const cardTitle = useMemo(() => { - if (!current) return t("Profiles"); + if (!current) return t("profiles.page.title"); if (!current.home) return current.name; @@ -364,7 +363,7 @@ export const HomeProfileCard = ({ endIcon={} sx={{ borderRadius: 1.5 }} > - {t("Label-Profiles")} + {t("layout.components.navigation.tabs.proxies")} ); }, [current, goToProfiles, t]); diff --git a/clash-verge-rev/src/components/home/ip-info-card.tsx b/clash-verge-rev/src/components/home/ip-info-card.tsx index 585460dc6a..18360e5502 100644 --- a/clash-verge-rev/src/components/home/ip-info-card.tsx +++ b/clash-verge-rev/src/components/home/ip-info-card.tsx @@ -1,11 +1,11 @@ import { LocationOnOutlined, RefreshOutlined, - VisibilityOutlined, VisibilityOffOutlined, + VisibilityOutlined, } from "@mui/icons-material"; -import { Box, Typography, Button, Skeleton, IconButton } from "@mui/material"; -import { useState, useEffect, useCallback, memo } from "react"; +import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material"; +import { memo, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { getIpInfo } from "@/services/api"; @@ -68,8 +68,12 @@ export const IpInfoCard = () => { const data = await getIpInfo(); setIpInfo(data); setCountdown(IP_REFRESH_SECONDS); - } catch (err: any) { - setError(err.message || t("Failed to get IP info")); + } catch (err) { + setError( + err instanceof Error + ? err.message + : t("home.components.ipInfo.errors.load"), + ); } finally { setLoading(false); } @@ -114,7 +118,7 @@ export const IpInfoCard = () => { if (loading) { return ( } iconColor="info" action={ @@ -137,7 +141,7 @@ export const IpInfoCard = () => { if (error) { return ( } iconColor="info" action={ @@ -160,7 +164,7 @@ export const IpInfoCard = () => { {error} @@ -170,7 +174,7 @@ export const IpInfoCard = () => { // 渲染正常数据 return ( } iconColor="info" action={ @@ -222,7 +226,7 @@ export const IpInfoCard = () => { maxWidth: "100%", }} > - {ipInfo?.country || t("Unknown")} + {ipInfo?.country || t("home.components.ipInfo.labels.unknown")} @@ -232,7 +236,7 @@ export const IpInfoCard = () => { color="text.secondary" sx={{ flexShrink: 0 }} > - {t("IP")}: + {t("home.components.ipInfo.labels.ip")}: { {/* 右侧:组织、ISP和位置信息 */} - - + + - + @@ -297,7 +310,7 @@ export const IpInfoCard = () => { }} > - {t("Auto refresh")}: {countdown}s + {t("home.components.ipInfo.labels.autoRefresh")}: {countdown}s { const { enable_tun_mode } = verge ?? {}; - const handleError = (err: Error) => { - showNotice("error", err.message || err.toString()); + const handleError = (err: unknown) => { + showNotice.error(err); }; const handleTabChange = (tab: string) => { @@ -160,18 +160,18 @@ export const ProxyTunCard: FC = () => { if (activeTab === "system") { return { text: systemProxyActualState - ? t("System Proxy Enabled") - : t("System Proxy Disabled"), - tooltip: t("System Proxy Info"), + ? t("home.components.proxyTun.status.systemProxyEnabled") + : t("home.components.proxyTun.status.systemProxyDisabled"), + tooltip: t("home.components.proxyTun.tooltips.systemProxy"), }; } else { return { text: !isTunModeAvailable - ? t("TUN Mode Service Required") + ? t("home.components.proxyTun.status.tunModeServiceRequired") : enable_tun_mode - ? t("TUN Mode Enabled") - : t("TUN Mode Disabled"), - tooltip: t("TUN Mode Intercept Info"), + ? t("home.components.proxyTun.status.tunModeEnabled") + : t("home.components.proxyTun.status.tunModeDisabled"), + tooltip: t("home.components.proxyTun.tooltips.tunMode"), }; } }, [ @@ -198,14 +198,14 @@ export const ProxyTunCard: FC = () => { isActive={activeTab === "system"} onClick={() => handleTabChange("system")} icon={ComputerRounded} - label={t("System Proxy")} + label={t("settings.sections.system.toggles.systemProxy")} hasIndicator={systemProxyActualState} /> handleTabChange("tun")} icon={TroubleshootRounded} - label={t("Tun Mode")} + label={t("settings.sections.system.toggles.tunMode")} hasIndicator={enable_tun_mode && isTunModeAvailable} /> @@ -236,7 +236,11 @@ export const ProxyTunCard: FC = () => { > diff --git a/clash-verge-rev/src/components/home/system-info-card.tsx b/clash-verge-rev/src/components/home/system-info-card.tsx index e9280efa6e..8697291f56 100644 --- a/clash-verge-rev/src/components/home/system-info-card.tsx +++ b/clash-verge-rev/src/components/home/system-info-card.tsx @@ -14,11 +14,10 @@ import { IconButton, Tooltip, } from "@mui/material"; -import { check as checkUpdate } from "@tauri-apps/plugin-updater"; import { useLockFn } from "ahooks"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useReducer } from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import useSWR from "swr"; import { useSystemState } from "@/hooks/use-system-state"; @@ -26,10 +25,34 @@ import { useVerge } from "@/hooks/use-verge"; import { useServiceInstaller } from "@/hooks/useServiceInstaller"; import { getSystemInfo } from "@/services/cmds"; import { showNotice } from "@/services/noticeService"; +import { checkUpdateSafe as checkUpdate } from "@/services/update"; import { version as appVersion } from "@root/package.json"; import { EnhancedCard } from "./enhanced-card"; +interface SystemState { + osInfo: string; + lastCheckUpdate: string; +} + +type SystemStateAction = + | { type: "set-os-info"; payload: string } + | { type: "set-last-check-update"; payload: string }; + +const systemStateReducer = ( + state: SystemState, + action: SystemStateAction, +): SystemState => { + switch (action.type) { + case "set-os-info": + return { ...state, osInfo: action.payload }; + case "set-last-check-update": + return { ...state, lastCheckUpdate: action.payload }; + default: + return state; + } +}; + export const SystemInfoCard = () => { const { t } = useTranslation(); const { verge, patchVerge } = useVerge(); @@ -38,13 +61,15 @@ export const SystemInfoCard = () => { const { installServiceAndRestartCore } = useServiceInstaller(); // 系统信息状态 - const [systemState, setSystemState] = useState({ + const [systemState, dispatchSystemState] = useReducer(systemStateReducer, { osInfo: "", lastCheckUpdate: "-", }); // 初始化系统信息 useEffect(() => { + let timeoutId: number | undefined; + getSystemInfo() .then((info) => { const lines = info.split("\n"); @@ -59,10 +84,10 @@ export const SystemInfoCard = () => { sysVersion = sysVersion.substring(sysName.length).trim(); } - setSystemState((prev) => ({ - ...prev, - osInfo: `${sysName} ${sysVersion}`, - })); + dispatchSystemState({ + type: "set-os-info", + payload: `${sysName} ${sysVersion}`, + }); } }) .catch(console.error); @@ -73,10 +98,10 @@ export const SystemInfoCard = () => { try { const timestamp = parseInt(lastCheck, 10); if (!isNaN(timestamp)) { - setSystemState((prev) => ({ - ...prev, - lastCheckUpdate: new Date(timestamp).toLocaleString(), - })); + dispatchSystemState({ + type: "set-last-check-update", + payload: new Date(timestamp).toLocaleString(), + }); } } catch (e) { console.error("Error parsing last check update time", e); @@ -85,18 +110,23 @@ export const SystemInfoCard = () => { // 如果启用了自动检查更新但没有记录,设置当前时间并延迟检查 const now = Date.now(); localStorage.setItem("last_check_update", now.toString()); - setSystemState((prev) => ({ - ...prev, - lastCheckUpdate: new Date(now).toLocaleString(), - })); + dispatchSystemState({ + type: "set-last-check-update", + payload: new Date(now).toLocaleString(), + }); - setTimeout(() => { + timeoutId = window.setTimeout(() => { if (verge?.auto_check_update) { checkUpdate().catch(console.error); } }, 5000); } - }, [verge?.auto_check_update]); + return () => { + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + }; + }, [verge?.auto_check_update, dispatchSystemState]); // 自动检查更新逻辑 useSWR( @@ -104,10 +134,10 @@ export const SystemInfoCard = () => { async () => { const now = Date.now(); localStorage.setItem("last_check_update", now.toString()); - setSystemState((prev) => ({ - ...prev, - lastCheckUpdate: new Date(now).toLocaleString(), - })); + dispatchSystemState({ + type: "set-last-check-update", + payload: new Date(now).toLocaleString(), + }); return await checkUpdate(); }, { @@ -144,13 +174,15 @@ export const SystemInfoCard = () => { try { const info = await checkUpdate(); if (!info?.available) { - showNotice("success", t("Currently on the Latest Version")); + showNotice.success( + "settings.components.verge.advanced.notifications.latestVersion", + ); } else { - showNotice("info", t("Update Available"), 2000); + showNotice.info("shared.feedback.notifications.updateAvailable", 2000); goToSettings(); } - } catch (err: any) { - showNotice("error", err.message || err.toString()); + } catch (err) { + showNotice.error(err); } }); @@ -187,11 +219,11 @@ export const SystemInfoCard = () => { <> ); @@ -199,21 +231,21 @@ export const SystemInfoCard = () => { return ( ); } else if (isSidecarMode) { return ( ); } else { return ( ); } @@ -224,13 +256,13 @@ export const SystemInfoCard = () => { if (isAdminMode) { // 判断是否同时处于服务模式 if (!isSidecarMode) { - return t("Administrator + Service Mode"); + return t("home.components.systemInfo.badges.adminServiceMode"); } - return t("Administrator Mode"); + return t("home.components.systemInfo.badges.adminMode"); } else if (isSidecarMode) { - return t("Sidecar Mode"); + return t("home.components.systemInfo.badges.sidecarMode"); } else { - return t("Service Mode"); + return t("home.components.systemInfo.badges.serviceMode"); } }; @@ -239,11 +271,15 @@ export const SystemInfoCard = () => { return ( } iconColor="error" action={ - + } @@ -251,7 +287,7 @@ export const SystemInfoCard = () => { - {t("OS Info")} + {t("home.components.systemInfo.fields.osInfo")} {systemState.osInfo} @@ -264,19 +300,23 @@ export const SystemInfoCard = () => { alignItems="center" > - {t("Auto Launch")} + {t("home.components.systemInfo.fields.autoLaunch")} {isAdminMode && ( )} { alignItems="center" > - {t("Running Mode")} + {t("home.components.systemInfo.fields.runningMode")} { - {t("Last Check Update")} + {t("home.components.systemInfo.fields.lastCheckUpdate")} { - {t("Verge Version")} + {t("home.components.systemInfo.fields.vergeVersion")} v{appVersion} diff --git a/clash-verge-rev/src/components/home/test-card.tsx b/clash-verge-rev/src/components/home/test-card.tsx index 518500907f..903f3c7ce0 100644 --- a/clash-verge-rev/src/components/home/test-card.tsx +++ b/clash-verge-rev/src/components/home/test-card.tsx @@ -173,16 +173,16 @@ export const TestCard = () => { return ( } action={ - + - + diff --git a/clash-verge-rev/src/components/layout/layout-item.tsx b/clash-verge-rev/src/components/layout/layout-item.tsx index 15d4fb93f7..e1c233ba04 100644 --- a/clash-verge-rev/src/components/layout/layout-item.tsx +++ b/clash-verge-rev/src/components/layout/layout-item.tsx @@ -1,3 +1,7 @@ +import type { + DraggableAttributes, + DraggableSyntheticListeners, +} from "@dnd-kit/core"; import { alpha, ListItem, @@ -5,24 +9,46 @@ import { ListItemText, ListItemIcon, } from "@mui/material"; -import { useMatch, useResolvedPath, useNavigate } from "react-router-dom"; +import type { CSSProperties, ReactNode } from "react"; +import { useMatch, useResolvedPath, useNavigate } from "react-router"; import { useVerge } from "@/hooks/use-verge"; + +interface SortableProps { + setNodeRef?: (element: HTMLElement | null) => void; + attributes?: DraggableAttributes; + listeners?: DraggableSyntheticListeners; + style?: CSSProperties; + isDragging?: boolean; + disabled?: boolean; +} + interface Props { to: string; children: string; - icon: React.ReactNode[]; + icon: ReactNode[]; + sortable?: SortableProps; } export const LayoutItem = (props: Props) => { - const { to, children, icon } = props; + const { to, children, icon, sortable } = props; const { verge } = useVerge(); const { menu_icon } = verge ?? {}; const resolved = useResolvedPath(to); const match = useMatch({ path: resolved.pathname, end: true }); const navigate = useNavigate(); + const { setNodeRef, attributes, listeners, style, isDragging } = + sortable ?? {}; + return ( - + { paddingLeft: 1, paddingRight: 1, marginRight: 1.25, + cursor: sortable && !sortable.disabled ? "grab" : "pointer", "& .MuiListItemText-primary": { color: "text.primary", fontWeight: "700", @@ -52,6 +79,8 @@ export const LayoutItem = (props: Props) => { }, ]} onClick={() => navigate(to)} + {...(attributes ?? {})} + {...(listeners ?? {})} > {(menu_icon === "monochrome" || !menu_icon) && ( diff --git a/clash-verge-rev/src/components/layout/layout-traffic.tsx b/clash-verge-rev/src/components/layout/layout-traffic.tsx index 9b37c5252f..36c35ac2e3 100644 --- a/clash-verge-rev/src/components/layout/layout-traffic.tsx +++ b/clash-verge-rev/src/components/layout/layout-traffic.tsx @@ -87,7 +87,7 @@ export const LayoutTraffic = () => { { { {displayMemory && ( void; + appendData: (data: Traffic) => void; toggleStyle: () => void; } @@ -27,15 +26,15 @@ export interface TrafficRef { export function TrafficGraph({ ref }: { ref?: Ref }) { const countRef = useRef(0); const styleRef = useRef(true); - const listRef = useRef(defaultList); + const listRef = useRef(defaultList); const canvasRef = useRef(null!); - const cacheRef = useRef(null); + const cacheRef = useRef(null); const { palette } = useTheme(); useImperativeHandle(ref, () => ({ - appendData: (data: TrafficData) => { + appendData: (data: Traffic) => { cacheRef.current = data; }, toggleStyle: () => { @@ -173,7 +172,11 @@ export function TrafficGraph({ ref }: { ref?: Ref }) { context.globalAlpha = upLineAlpha; context.lineWidth = upLineWidth; context.strokeStyle = upLineColor; - lineStyle ? drawBezier(listUp, offset) : drawLine(listUp, offset); + if (lineStyle) { + drawBezier(listUp, offset); + } else { + drawLine(listUp, offset); + } context.stroke(); context.closePath(); @@ -181,7 +184,11 @@ export function TrafficGraph({ ref }: { ref?: Ref }) { context.globalAlpha = downLineAlpha; context.lineWidth = downLineWidth; context.strokeStyle = downLineColor; - lineStyle ? drawBezier(listDown, offset) : drawLine(listDown, offset); + if (lineStyle) { + drawBezier(listDown, offset); + } else { + drawLine(listDown, offset); + } context.stroke(); context.closePath(); diff --git a/clash-verge-rev/src/components/layout/update-button.tsx b/clash-verge-rev/src/components/layout/update-button.tsx index afa0f83b7e..d5c8b4ffe1 100644 --- a/clash-verge-rev/src/components/layout/update-button.tsx +++ b/clash-verge-rev/src/components/layout/update-button.tsx @@ -1,9 +1,9 @@ import { Button } from "@mui/material"; -import { check } from "@tauri-apps/plugin-updater"; import { useRef } from "react"; import useSWR from "swr"; import { useVerge } from "@/hooks/use-verge"; +import { checkUpdateSafe } from "@/services/update"; import { DialogRef } from "../base"; import { UpdateViewer } from "../setting/mods/update-viewer"; @@ -21,7 +21,7 @@ export const UpdateButton = (props: Props) => { const { data: updateInfo } = useSWR( auto_check_update || auto_check_update === null ? "checkUpdate" : null, - check, + checkUpdateSafe, { errorRetryCount: 2, revalidateIfStale: false, diff --git a/clash-verge-rev/src/components/layout/use-custom-theme.ts b/clash-verge-rev/src/components/layout/use-custom-theme.ts index 145bc5bd71..7682b0a965 100644 --- a/clash-verge-rev/src/components/layout/use-custom-theme.ts +++ b/clash-verge-rev/src/components/layout/use-custom-theme.ts @@ -53,10 +53,13 @@ export const useCustomTheme = () => { return; } - if ( + const preferBrowserMatchMedia = typeof window !== "undefined" && - typeof window.matchMedia === "function" - ) { + typeof window.matchMedia === "function" && + // Skip Tauri flow when running purely in browser. + !("__TAURI__" in window); + + if (preferBrowserMatchMedia) { return; } diff --git a/clash-verge-rev/src/components/log/log-item.tsx b/clash-verge-rev/src/components/log/log-item.tsx index 3b7a8dee50..4b1b25699d 100644 --- a/clash-verge-rev/src/components/log/log-item.tsx +++ b/clash-verge-rev/src/components/log/log-item.tsx @@ -1,4 +1,5 @@ import { styled, Box } from "@mui/material"; +import type { ReactNode } from "react"; import { SearchState } from "@/components/base/base-search-box"; @@ -67,17 +68,38 @@ const LogItem = ({ value, searchState }: Props) => { } const flags = searchState.matchCase ? "g" : "gi"; - const parts = text.split(new RegExp(`(${pattern})`, flags)); + const regex = new RegExp(pattern, flags); + const elements: ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; - return parts.map((part, index) => { - return index % 2 === 1 ? ( - - {part} - - ) : ( - part + while ((match = regex.exec(text)) !== null) { + const start = match.index; + const matchText = match[0]; + + if (matchText === "") { + regex.lastIndex += 1; + continue; + } + + if (start > lastIndex) { + elements.push(text.slice(lastIndex, start)); + } + + elements.push( + + {matchText} + , ); - }); + + lastIndex = start + matchText.length; + } + + if (lastIndex < text.length) { + elements.push(text.slice(lastIndex)); + } + + return elements.length ? elements : text; } catch { return text; } diff --git a/clash-verge-rev/src/components/profile/confirm-viewer.tsx b/clash-verge-rev/src/components/profile/confirm-viewer.tsx index d99eb4262f..001fc013ee 100644 --- a/clash-verge-rev/src/components/profile/confirm-viewer.tsx +++ b/clash-verge-rev/src/components/profile/confirm-viewer.tsx @@ -35,10 +35,10 @@ export const ConfirmViewer = (props: Props) => {
diff --git a/clash-verge-rev/src/components/profile/editor-viewer.tsx b/clash-verge-rev/src/components/profile/editor-viewer.tsx index a095919fd3..64f2e16756 100644 --- a/clash-verge-rev/src/components/profile/editor-viewer.tsx +++ b/clash-verge-rev/src/components/profile/editor-viewer.tsx @@ -1,7 +1,8 @@ +import MonacoEditor from "@monaco-editor/react"; import { + CloseFullscreenRounded, FormatPaintRounded, OpenInFullRounded, - CloseFullscreenRounded, } from "@mui/icons-material"; import { Button, @@ -20,9 +21,8 @@ import metaSchema from "meta-json-schema/schemas/meta-json-schema.json"; import * as monaco from "monaco-editor"; import { configureMonacoYaml } from "monaco-yaml"; import { nanoid } from "nanoid"; -import { ReactNode, useEffect, useRef, useState } from "react"; +import { ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import MonacoEditor from "react-monaco-editor"; import pac from "types-pac/pac.d.ts?raw"; import { showNotice } from "@/services/noticeService"; @@ -63,13 +63,13 @@ const monacoInitialization = () => { { uri: "http://example.com/meta-json-schema.json", fileMatch: ["**/*.clash.yaml"], - // @ts-ignore + // @ts-expect-error -- meta schema JSON import does not satisfy JSONSchema7 at compile time schema: metaSchema as JSONSchema7, }, { uri: "http://example.com/clash-verge-merge-json-schema.json", fileMatch: ["**/*.merge.yaml"], - // @ts-ignore + // @ts-expect-error -- merge schema JSON import does not satisfy JSONSchema7 at compile time schema: mergeSchema as JSONSchema7, }, ], @@ -87,8 +87,8 @@ export const EditorViewer = (props: Props) => { const { open = false, - title = t("Edit File"), - initialData = Promise.resolve(""), + title, + initialData, readOnly = false, language = "yaml", schema, @@ -97,21 +97,25 @@ export const EditorViewer = (props: Props) => { onClose, } = props; + const resolvedTitle = title ?? t("profiles.components.menu.editFile"); + const resolvedInitialData = useMemo( + () => initialData ?? Promise.resolve(""), + [initialData], + ); + const editorRef = useRef(undefined); const prevData = useRef(""); const currData = useRef(""); - const editorWillMount = () => { + const beforeMount = () => { monacoInitialization(); // initialize monaco }; - const editorDidMount = async ( - editor: monaco.editor.IStandaloneCodeEditor, - ) => { + const onMount = async (editor: monaco.editor.IStandaloneCodeEditor) => { editorRef.current = editor; // retrieve initial data - await initialData.then((data) => { + await resolvedInitialData.then((data) => { prevData.current = data; currData.current = data; @@ -122,36 +126,44 @@ export const EditorViewer = (props: Props) => { }); }; - const handleChange = useLockFn(async (value: string | undefined) => { + const handleChange = useLockFn(async (_value?: string) => { try { + const value = editorRef.current?.getValue(); currData.current = value; onChange?.(prevData.current, currData.current); - } catch (err: any) { - showNotice("error", err.message || err.toString()); + } catch (err) { + showNotice.error(err); } }); const handleSave = useLockFn(async () => { try { - !readOnly && onSave?.(prevData.current, currData.current); + if (!readOnly) { + currData.current = editorRef.current?.getValue(); + onSave?.(prevData.current, currData.current); + } onClose(); - } catch (err: any) { - showNotice("error", err.message || err.toString()); + } catch (err) { + showNotice.error(err); } }); const handleClose = useLockFn(async () => { try { onClose(); - } catch (err: any) { - showNotice("error", err.message || err.toString()); + } catch (err) { + showNotice.error(err); } }); - const editorResize = debounce(() => { - editorRef.current?.layout(); - setTimeout(() => editorRef.current?.layout(), 500); - }, 100); + const editorResize = useMemo( + () => + debounce(() => { + editorRef.current?.layout(); + setTimeout(() => editorRef.current?.layout(), 500); + }, 100), + [], + ); useEffect(() => { const onResized = debounce(() => { @@ -167,11 +179,11 @@ export const EditorViewer = (props: Props) => { editorRef.current?.dispose(); editorRef.current = undefined; }; - }, []); + }, [editorResize]); return ( - {title} + {resolvedTitle} (props: Props) => { > (props: Props) => { }, mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例 readOnly: readOnly, // 只读模式 - readOnlyMessage: { value: t("ReadOnlyMessage") }, // 只读模式尝试编辑时的提示信息 + readOnlyMessage: { + value: t("profiles.modals.editor.messages.readOnly"), + }, // 只读模式尝试编辑时的提示信息 renderValidationDecorations: "on", // 只读模式下显示校验信息 quickSuggestions: { strings: true, // 字符串类型的建议 @@ -206,8 +220,8 @@ export const EditorViewer = (props: Props) => { fontLigatures: false, // 连字符 smoothScrolling: true, // 平滑滚动 }} - editorWillMount={editorWillMount} - editorDidMount={editorDidMount} + beforeMount={beforeMount} + onMount={onMount} onChange={handleChange} /> @@ -219,7 +233,7 @@ export const EditorViewer = (props: Props) => { size="medium" color="inherit" sx={{ display: readOnly ? "none" : "" }} - title={t("Format document")} + title={t("profiles.modals.editor.actions.format")} onClick={() => editorRef.current ?.getAction("editor.action.formatDocument") @@ -231,7 +245,9 @@ export const EditorViewer = (props: Props) => { appWindow.toggleMaximize().then(editorResize)} > {isMaximized ? : } @@ -241,11 +257,11 @@ export const EditorViewer = (props: Props) => { {!readOnly && ( )} diff --git a/clash-verge-rev/src/components/profile/file-input.tsx b/clash-verge-rev/src/components/profile/file-input.tsx index 5d8511b763..20b4ad20c5 100644 --- a/clash-verge-rev/src/components/profile/file-input.tsx +++ b/clash-verge-rev/src/components/profile/file-input.tsx @@ -42,7 +42,7 @@ export const FileInput = (props: Props) => { sx={{ flex: "none" }} onClick={() => inputRef.current?.click()} > - {t("Choose File")} + {t("profiles.components.fileInput.chooseFile")} { const sortable = type === "prepend" || type === "append"; const { - attributes, - listeners, - setNodeRef, + attributes: sortableAttributes, + listeners: sortableListeners, + setNodeRef: sortableSetNodeRef, transform, transition, isDragging, - } = sortable - ? useSortable({ id: group.name }) - : { - attributes: {}, - listeners: {}, - setNodeRef: null, - transform: null, - transition: null, - isDragging: false, - }; + } = useSortable({ + id: group.name, + disabled: !sortable, + }); + const dragAttributes = sortable ? sortableAttributes : undefined; + const dragListeners = sortable ? sortableListeners : undefined; + const dragNodeRef = sortable ? sortableSetNodeRef : undefined; const [iconCachePath, setIconCachePath] = useState(""); useEffect(() => { - initIconCachePath(); - }, [group]); + let cancelled = false; + const initIconCachePath = async () => { + const icon = group.icon?.trim() ?? ""; + if (icon.startsWith("http")) { + try { + const fileName = + group.name.replaceAll(" ", "") + "-" + getFileName(icon); + const iconPath = await downloadIconCache(icon, fileName); + if (!cancelled) { + setIconCachePath(convertFileSrc(iconPath)); + } + } catch { + if (!cancelled) { + setIconCachePath(""); + } + } + } else if (!cancelled) { + setIconCachePath(""); + } + }; - async function initIconCachePath() { - if (group.icon && group.icon.trim().startsWith("http")) { - const fileName = - group.name.replaceAll(" ", "") + "-" + getFileName(group.icon); - const iconPath = await downloadIconCache(group.icon, fileName); - setIconCachePath(convertFileSrc(iconPath)); - } - } + void initIconCachePath(); + + return () => { + cancelled = true; + }; + }, [group.icon, group.name]); function getFileName(url: string) { return url.substring(url.lastIndexOf("/") + 1); @@ -108,9 +121,9 @@ export const GroupItem = (props: Props) => { /> )} { } - secondaryTypographyProps={{ - sx: { - display: "flex", - alignItems: "center", - color: "#ccc", + slotProps={{ + secondary: { + sx: { + display: "flex", + alignItems: "center", + color: "#ccc", + }, }, }} /> diff --git a/clash-verge-rev/src/components/profile/groups-editor-viewer.tsx b/clash-verge-rev/src/components/profile/groups-editor-viewer.tsx index 6ec594b0a2..0ccb5531fe 100644 --- a/clash-verge-rev/src/components/profile/groups-editor-viewer.tsx +++ b/clash-verge-rev/src/components/profile/groups-editor-viewer.tsx @@ -1,19 +1,20 @@ import { DndContext, - closestCenter, + DragEndEvent, KeyboardSensor, PointerSensor, + closestCenter, useSensor, useSensors, - DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, } from "@dnd-kit/sortable"; +import MonacoEditor from "@monaco-editor/react"; import { - VerticalAlignTopRounded, VerticalAlignBottomRounded, + VerticalAlignTopRounded, } from "@mui/icons-material"; import { Autocomplete, @@ -32,14 +33,19 @@ import { } from "@mui/material"; import { useLockFn } from "ahooks"; import { - requestIdleCallback, cancelIdleCallback, + requestIdleCallback, } from "foxact/request-idle-callback"; import yaml from "js-yaml"; -import { useEffect, useMemo, useState } from "react"; +import { + startTransition, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import MonacoEditor from "react-monaco-editor"; import { Virtuoso } from "react-virtuoso"; import { Switch } from "@/components/base"; @@ -51,6 +57,7 @@ import { } from "@/services/cmds"; import { showNotice } from "@/services/noticeService"; import { useThemeMode } from "@/services/states"; +import type { TranslationKey } from "@/types/generated/i18n-keys"; import getSystem from "@/utils/get-system"; import { BaseSearchBox } from "../base/base-search-box"; @@ -67,10 +74,86 @@ interface Props { const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"]; +const PROXY_STRATEGY_LABEL_KEYS: Record = { + select: "proxies.components.enums.strategies.select", + "url-test": "proxies.components.enums.strategies.url-test", + fallback: "proxies.components.enums.strategies.fallback", + "load-balance": "proxies.components.enums.strategies.load-balance", + relay: "proxies.components.enums.strategies.relay", +}; + +const PROXY_POLICY_LABEL_KEYS: Record = + builtinProxyPolicies.reduce( + (acc, policy) => { + acc[policy] = + `proxies.components.enums.policies.${policy}` as TranslationKey; + return acc; + }, + {} as Record, + ); + +const normalizeDeleteSeq = (input?: unknown): string[] => { + if (!Array.isArray(input)) { + return []; + } + + const names = input + .map((item) => { + if (typeof item === "string") { + return item; + } + + if ( + item && + typeof item === "object" && + "name" in item && + typeof (item as { name: unknown }).name === "string" + ) { + return (item as { name: string }).name; + } + + return undefined; + }) + .filter( + (name): name is string => typeof name === "string" && name.length > 0, + ); + + return Array.from(new Set(names)); +}; + +const buildGroupsYaml = ( + prepend: IProxyGroupConfig[], + append: IProxyGroupConfig[], + deleteList: string[], +) => { + return yaml.dump( + { + prepend, + append, + delete: deleteList, + }, + { forceQuotes: true }, + ); +}; + export const GroupsEditorViewer = (props: Props) => { const { mergeUid, proxiesUid, profileUid, property, open, onClose, onSave } = props; const { t } = useTranslation(); + const translateStrategy = useCallback( + (value: string) => + PROXY_STRATEGY_LABEL_KEYS[value] + ? t(PROXY_STRATEGY_LABEL_KEYS[value]) + : value, + [t], + ); + const translatePolicy = useCallback( + (value: string) => + PROXY_POLICY_LABEL_KEYS[value] + ? t(PROXY_POLICY_LABEL_KEYS[value]) + : value, + [t], + ); const themeMode = useThemeMode(); const [prevData, setPrevData] = useState(""); const [currData, setCurrData] = useState(""); @@ -160,43 +243,55 @@ export const GroupsEditorViewer = (props: Props) => { } } }; - const fetchContent = async () => { + const fetchContent = useCallback(async () => { const data = await readProfileFile(property); const obj = yaml.load(data) as ISeqProfileConfig | null; setPrependSeq(obj?.prepend || []); setAppendSeq(obj?.append || []); - setDeleteSeq(obj?.delete || []); + setDeleteSeq((prev) => { + const normalized = normalizeDeleteSeq(obj?.delete); + if ( + normalized.length === prev.length && + normalized.every((item, index) => item === prev[index]) + ) { + return prev; + } + return normalized; + }); setPrevData(data); setCurrData(data); - }; + }, [property]); useEffect(() => { - if (currData === "") return; - if (visualization !== true) return; + if (currData === "" || visualization !== true) { + return; + } - const obj = yaml.load(currData) as { - prepend: []; - append: []; - delete: []; - } | null; - setPrependSeq(obj?.prepend || []); - setAppendSeq(obj?.append || []); - setDeleteSeq(obj?.delete || []); - }, [visualization]); + const obj = yaml.load(currData) as ISeqProfileConfig | null; + startTransition(() => { + setPrependSeq(obj?.prepend ?? []); + setAppendSeq(obj?.append ?? []); + setDeleteSeq((prev) => { + const normalized = normalizeDeleteSeq(obj?.delete); + if ( + normalized.length === prev.length && + normalized.every((item, index) => item === prev[index]) + ) { + return prev; + } + return normalized; + }); + }); + }, [currData, visualization]); // 优化:异步处理大数据yaml.dump,避免UI卡死 useEffect(() => { if (prependSeq && appendSeq && deleteSeq) { const serialize = () => { try { - setCurrData( - yaml.dump( - { prepend: prependSeq, append: appendSeq, delete: deleteSeq }, - { forceQuotes: true }, - ), - ); + setCurrData(buildGroupsYaml(prependSeq, appendSeq, deleteSeq)); } catch (e) { console.warn("[GroupsEditorViewer] yaml.dump failed:", e); // 防止异常导致UI卡死 @@ -210,7 +305,7 @@ export const GroupsEditorViewer = (props: Props) => { } }, [prependSeq, appendSeq, deleteSeq]); - const fetchProxyPolicy = async () => { + const fetchProxyPolicy = useCallback(async () => { const data = await readProfileFile(profileUid); const proxiesData = await readProfileFile(proxiesUid); const originGroupsObj = yaml.load(data) as { @@ -222,32 +317,39 @@ export const GroupsEditorViewer = (props: Props) => { const moreProxiesObj = yaml.load(proxiesData) as ISeqProfileConfig | null; const morePrependProxies = moreProxiesObj?.prepend || []; const moreAppendProxies = moreProxiesObj?.append || []; - const moreDeleteProxies = - moreProxiesObj?.delete || ([] as string[] | { name: string }[]); + const moreDeleteProxies = normalizeDeleteSeq(moreProxiesObj?.delete); const proxies = morePrependProxies.concat( originProxies.filter((proxy: any) => { - if (proxy.name) { - return !moreDeleteProxies.includes(proxy.name); - } else { - return !moreDeleteProxies.includes(proxy); - } + const proxyName = + typeof proxy === "string" + ? proxy + : (proxy?.name as string | undefined); + return proxyName ? !moreDeleteProxies.includes(proxyName) : true; }), moreAppendProxies, ); - setProxyPolicyList( - builtinProxyPolicies.concat( - prependSeq.map((group: IProxyGroupConfig) => group.name), - originGroupsObj?.["proxy-groups"] - .map((group: IProxyGroupConfig) => group.name) - .filter((name) => !deleteSeq.includes(name)) || [], - appendSeq.map((group: IProxyGroupConfig) => group.name), - proxies.map((proxy: any) => proxy.name), - ), + const proxyNames = proxies + .map((proxy: any) => + typeof proxy === "string" ? proxy : (proxy?.name as string | undefined), + ) + .filter( + (name): name is string => typeof name === "string" && name.length > 0, + ); + + const computedPolicyList = builtinProxyPolicies.concat( + prependSeq.map((group: IProxyGroupConfig) => group.name), + (originGroupsObj?.["proxy-groups"] || []) + .map((group: IProxyGroupConfig) => group.name) + .filter((name) => !deleteSeq.includes(name)), + appendSeq.map((group: IProxyGroupConfig) => group.name), + proxyNames, ); - }; - const fetchProfile = async () => { + + setProxyPolicyList(Array.from(new Set(computedPolicyList))); + }, [appendSeq, deleteSeq, prependSeq, profileUid, proxiesUid]); + const fetchProfile = useCallback(async () => { const data = await readProfileFile(profileUid); const mergeData = await readProfileFile(mergeUid); const globalMergeData = await readProfileFile("Merge"); @@ -257,17 +359,17 @@ export const GroupsEditorViewer = (props: Props) => { } | null; const originProviderObj = yaml.load(data) as { - "proxy-providers": {}; + "proxy-providers": Record; } | null; const originProvider = originProviderObj?.["proxy-providers"] || {}; const moreProviderObj = yaml.load(mergeData) as { - "proxy-providers": {}; + "proxy-providers": Record; } | null; const moreProvider = moreProviderObj?.["proxy-providers"] || {}; const globalProviderObj = yaml.load(globalMergeData) as { - "proxy-providers": {}; + "proxy-providers": Record; } | null; const globalProvider = globalProviderObj?.["proxy-providers"] || {}; @@ -280,37 +382,47 @@ export const GroupsEditorViewer = (props: Props) => { setProxyProviderList(Object.keys(provider)); setGroupList(originGroupsObj?.["proxy-groups"] || []); - }; - const getInterfaceNameList = async () => { + }, [mergeUid, profileUid]); + const getInterfaceNameList = useCallback(async () => { const list = await getNetworkInterfaces(); setInterfaceNameList(list); - }; + }, []); useEffect(() => { + if (!open) return; fetchProxyPolicy(); - }, [prependSeq, appendSeq, deleteSeq]); + }, [fetchProxyPolicy, open]); + useEffect(() => { if (!open) return; fetchContent(); - fetchProxyPolicy(); fetchProfile(); getInterfaceNameList(); - }, [open]); + }, [fetchContent, fetchProfile, getInterfaceNameList, open]); const validateGroup = () => { const group = formIns.getValues(); if (group.name === "") { - throw new Error(t("Group Name Required")); + throw new Error(t("profiles.modals.groupsEditor.errors.nameRequired")); } }; const handleSave = useLockFn(async () => { try { - await saveProfileFile(property, currData); - showNotice("success", t("Saved Successfully")); - onSave?.(prevData, currData); + const nextData = visualization + ? buildGroupsYaml(prependSeq, appendSeq, deleteSeq) + : currData; + + if (visualization) { + setCurrData(nextData); + } + + await saveProfileFile(property, nextData); + showNotice.success("shared.feedback.notifications.saved"); + setPrevData(nextData); + onSave?.(prevData, nextData); onClose(); - } catch (err: any) { - showNotice("error", err.toString()); + } catch (err) { + showNotice.error(err); } }); @@ -319,7 +431,7 @@ export const GroupsEditorViewer = (props: Props) => { { - {t("Edit Groups")} + {t("profiles.modals.groupsEditor.title")} @@ -357,7 +471,9 @@ export const GroupsEditorViewer = (props: Props) => { control={control} render={({ field }) => ( - + { "relay", ]} value={field.value} + getOptionLabel={translateStrategy} renderOption={(props, option) => ( -
  • - {option} +
  • + {translateStrategy(option)}
  • )} onChange={(_, value) => value && field.onChange(value)} @@ -385,7 +502,9 @@ export const GroupsEditorViewer = (props: Props) => { control={control} render={({ field }) => ( - + { control={control} render={({ field }) => ( - + { control={control} render={({ field }) => ( - + { onChange={(_, value) => value && field.onChange(value)} renderInput={(params) => } renderOption={(props, option) => ( -
  • - {option} +
  • + {translatePolicy(option)}
  • )} + getOptionLabel={translatePolicy} />
    )} @@ -442,7 +568,11 @@ export const GroupsEditorViewer = (props: Props) => { control={control} render={({ field }) => ( - + { control={control} render={({ field }) => ( - + { control={control} render={({ field }) => ( - + { control={control} render={({ field }) => ( - + { input: { endAdornment: ( - {t("seconds")} + {t("shared.units.seconds")} ), }, @@ -522,7 +664,7 @@ export const GroupsEditorViewer = (props: Props) => { control={control} render={({ field }) => ( - + { input: { endAdornment: ( - {t("millis")} + {t("shared.units.milliseconds")} ), }, @@ -550,7 +692,11 @@ export const GroupsEditorViewer = (props: Props) => { control={control} render={({ field }) => ( - + { control={control} render={({ field }) => ( - + { control={control} render={({ field }) => ( - + { control={control} render={({ field }) => ( - + { control={control} render={({ field }) => ( - + { control={control} render={({ field }) => ( - + { control={control} render={({ field }) => ( - + )} @@ -690,7 +860,11 @@ export const GroupsEditorViewer = (props: Props) => { control={control} render={({ field }) => ( - + )} @@ -700,7 +874,11 @@ export const GroupsEditorViewer = (props: Props) => { control={control} render={({ field }) => ( - + )} @@ -710,7 +888,9 @@ export const GroupsEditorViewer = (props: Props) => { control={control} render={({ field }) => ( - + )} @@ -720,7 +900,11 @@ export const GroupsEditorViewer = (props: Props) => { control={control} render={({ field }) => ( - + )} @@ -730,7 +914,11 @@ export const GroupsEditorViewer = (props: Props) => { control={control} render={({ field }) => ( - + )} @@ -746,16 +934,18 @@ export const GroupsEditorViewer = (props: Props) => { validateGroup(); for (const item of [...prependSeq, ...groupList]) { if (item.name === formIns.getValues().name) { - throw new Error(t("Group Name Already Exists")); + throw new Error( + t("profiles.modals.groupsEditor.errors.nameExists"), + ); } } setPrependSeq([formIns.getValues(), ...prependSeq]); - } catch (err: any) { - showNotice("error", err.message || err.toString()); + } catch (err) { + showNotice.error(err); } }} > - {t("Prepend Group")} + {t("profiles.modals.groupsEditor.actions.prepend")} @@ -768,16 +958,18 @@ export const GroupsEditorViewer = (props: Props) => { validateGroup(); for (const item of [...appendSeq, ...groupList]) { if (item.name === formIns.getValues().name) { - throw new Error(t("Group Name Already Exists")); + throw new Error( + t("profiles.modals.groupsEditor.errors.nameExists"), + ); } } setAppendSeq([...appendSeq, formIns.getValues()]); - } catch (err: any) { - showNotice("error", err.message || err.toString()); + } catch (err) { + showNotice.error(err); } }} > - {t("Append Group")} + {t("profiles.modals.groupsEditor.actions.append")} @@ -811,10 +1003,10 @@ export const GroupsEditorViewer = (props: Props) => { return x.name; })} > - {filteredPrependSeq.map((item, index) => { + {filteredPrependSeq.map((item) => { return ( { @@ -834,7 +1026,7 @@ export const GroupsEditorViewer = (props: Props) => { const newIndex = index - shift; return ( { return x.name; })} > - {filteredAppendSeq.map((item, index) => { + {filteredAppendSeq.map((item) => { return ( { @@ -900,7 +1092,7 @@ export const GroupsEditorViewer = (props: Props) => { height="100%" language="yaml" value={currData} - theme={themeMode === "light" ? "vs" : "vs-dark"} + theme={themeMode === "light" ? "light" : "vs-dark"} options={{ tabSize: 2, // 根据语言类型设置缩进大小 minimap: { @@ -921,18 +1113,18 @@ export const GroupsEditorViewer = (props: Props) => { fontLigatures: false, // 连字符 smoothScrolling: true, // 平滑滚动 }} - onChange={(value) => setCurrData(value)} + onChange={(value) => setCurrData(value ?? "")} /> )}
    diff --git a/clash-verge-rev/src/components/profile/log-viewer.tsx b/clash-verge-rev/src/components/profile/log-viewer.tsx index adcbd78eae..90349ccca1 100644 --- a/clash-verge-rev/src/components/profile/log-viewer.tsx +++ b/clash-verge-rev/src/components/profile/log-viewer.tsx @@ -26,7 +26,7 @@ export const LogViewer = (props: Props) => { return ( - {t("Script Console")} + {t("profiles.modals.logViewer.title")} { pb: 1, }} > - {logInfo.map(([level, log], index) => ( - + {logInfo.map(([level, log]) => ( + { diff --git a/clash-verge-rev/src/components/profile/profile-item.tsx b/clash-verge-rev/src/components/profile/profile-item.tsx index 876466fb6a..7f928336d3 100644 --- a/clash-verge-rev/src/components/profile/profile-item.tsx +++ b/clash-verge-rev/src/components/profile/profile-item.tsx @@ -19,7 +19,7 @@ import { import { open } from "@tauri-apps/plugin-shell"; import { useLockFn } from "ahooks"; import dayjs from "dayjs"; -import { useEffect, useState } from "react"; +import { useEffect, useReducer, useState } from "react"; import { useTranslation } from "react-i18next"; import { mutate } from "swr"; @@ -36,6 +36,7 @@ import { } from "@/services/cmds"; import { showNotice } from "@/services/noticeService"; import { useLoadingCache, useSetLoadingCache } from "@/services/states"; +import type { TranslationKey } from "@/types/generated/i18n-keys"; import parseTraffic from "@/utils/parse-traffic"; import { ProfileBox } from "./profile-box"; @@ -61,6 +62,7 @@ interface Props { export const ProfileItem = (props: Props) => { const { + id, selected, activating, itemData, @@ -80,11 +82,11 @@ export const ProfileItem = (props: Props) => { transition, isDragging, } = useSortable({ - id: props.id, + id, }); const { t } = useTranslation(); - const [anchorEl, setAnchorEl] = useState(null); + const [anchorEl, setAnchorEl] = useState(null); const [position, setPosition] = useState({ left: 0, top: 0 }); const loadingCache = useLoadingCache(); const setLoadingCache = useSetLoadingCache(); @@ -119,34 +121,46 @@ export const ProfileItem = (props: Props) => { // 如果已经过期,显示"更新失败" if (nextUpdateDate.isBefore(now)) { - setNextUpdateTime(t("Last Update failed")); + setNextUpdateTime( + t("profiles.components.profileItem.status.lastUpdateFailed"), + ); } else { // 否则显示剩余时间 const diffMinutes = nextUpdateDate.diff(now, "minute"); if (diffMinutes < 60) { if (diffMinutes <= 0) { - setNextUpdateTime(`${t("Next Up")} <1m`); + setNextUpdateTime( + `${t("profiles.components.profileItem.status.nextUp")} <1m`, + ); } else { - setNextUpdateTime(`${t("Next Up")} ${diffMinutes}m`); + setNextUpdateTime( + `${t("profiles.components.profileItem.status.nextUp")} ${diffMinutes}m`, + ); } } else { const hours = Math.floor(diffMinutes / 60); const mins = diffMinutes % 60; - setNextUpdateTime(`${t("Next Up")} ${hours}h ${mins}m`); + setNextUpdateTime( + `${t("profiles.components.profileItem.status.nextUp")} ${hours}h ${mins}m`, + ); } } } else { console.log(`返回的下次更新时间为空`); - setNextUpdateTime(t("No schedule")); + setNextUpdateTime( + t("profiles.components.profileItem.status.noSchedule"), + ); } } catch (err) { console.error(`获取下次更新时间出错:`, err); - setNextUpdateTime(t("Unknown")); + setNextUpdateTime(t("profiles.components.profileItem.status.unknown")); } } else { console.log(`该配置未设置更新间隔或间隔为0`); - setNextUpdateTime(t("Auto update disabled")); + setNextUpdateTime( + t("profiles.components.profileItem.status.autoUpdateDisabled"), + ); } }); @@ -166,37 +180,44 @@ export const ProfileItem = (props: Props) => { if (showNextUpdate) { fetchNextUpdateTime(); } - }, [showNextUpdate, itemData.option?.update_interval, updated]); + }, [ + fetchNextUpdateTime, + showNextUpdate, + itemData.option?.update_interval, + updated, + ]); // 订阅定时器更新事件 useEffect(() => { + let refreshTimeout: number | undefined; // 处理定时器更新事件 - 这个事件专门用于通知定时器变更 - const handleTimerUpdate = (event: any) => { - const updatedUid = event.payload as string; + const handleTimerUpdate = (event: Event) => { + const source = event as CustomEvent & { payload?: string }; + const updatedUid = source.detail ?? source.payload; // 只有当更新的是当前配置时才刷新显示 if (updatedUid === itemData.uid && showNextUpdate) { console.log(`收到定时器更新事件: uid=${updatedUid}`); - setTimeout(() => { + if (refreshTimeout !== undefined) { + clearTimeout(refreshTimeout); + } + refreshTimeout = window.setTimeout(() => { fetchNextUpdateTime(true); }, 1000); } }; // 只注册定时器更新事件监听 - window.addEventListener( - "verge://timer-updated", - handleTimerUpdate as EventListener, - ); + window.addEventListener("verge://timer-updated", handleTimerUpdate); return () => { + if (refreshTimeout !== undefined) { + clearTimeout(refreshTimeout); + } // 清理事件监听 - window.removeEventListener( - "verge://timer-updated", - handleTimerUpdate as EventListener, - ); + window.removeEventListener("verge://timer-updated", handleTimerUpdate); }; - }, [showNextUpdate, itemData.uid]); + }, [fetchNextUpdateTime, itemData.uid, showNextUpdate]); // local file mode // remote file mode @@ -217,11 +238,11 @@ export const ProfileItem = (props: Props) => { const loading = loadingCache[itemData.uid] ?? false; // interval update fromNow field - const [, setRefresh] = useState({}); + const [, forceRefresh] = useReducer((value: number) => value + 1, 0); useEffect(() => { if (!hasUrl) return; - let timer: any = null; + let timer: ReturnType | undefined; const handler = () => { const now = Date.now(); @@ -232,7 +253,7 @@ export const ProfileItem = (props: Props) => { const wait = now - lastUpdate >= 36e5 ? 30e5 : 5e4; timer = setTimeout(() => { - setRefresh({}); + forceRefresh(); handler(); }, wait); }; @@ -240,9 +261,12 @@ export const ProfileItem = (props: Props) => { handler(); return () => { - if (timer) clearTimeout(timer); + if (timer) { + clearTimeout(timer); + timer = undefined; + } }; - }, [hasUrl, updated]); + }, [forceRefresh, hasUrl, updated]); const [fileOpen, setFileOpen] = useState(false); const [rulesOpen, setRulesOpen] = useState(false); @@ -301,8 +325,8 @@ export const ProfileItem = (props: Props) => { setAnchorEl(null); try { await viewProfile(itemData.uid); - } catch (err: any) { - showNotice("error", err?.message || err.toString()); + } catch (err) { + showNotice.error(err); } }); @@ -330,10 +354,10 @@ export const ProfileItem = (props: Props) => { try { // 调用后端更新(后端会自动处理回退逻辑) - await updateProfile(itemData.uid, option); + const payload = Object.keys(option).length > 0 ? option : undefined; + await updateProfile(itemData.uid, payload); // 更新成功,刷新列表 - showNotice("success", t("Update subscription successfully")); mutate("getProfiles"); } catch { // 更新完全失败(包括后端的回退尝试) @@ -343,91 +367,164 @@ export const ProfileItem = (props: Props) => { } }); - const urlModeMenu = ( - hasHome ? [{ label: "Home", handler: onOpenHome, disabled: false }] : [] - ).concat([ - { label: "Select", handler: onForceSelect, disabled: false }, - { label: "Edit Info", handler: onEditInfo, disabled: false }, - { label: "Edit File", handler: onEditFile, disabled: false }, + type ContextMenuItem = { + label: string; + handler: () => void; + disabled: boolean; + }; + + const menuLabels: Record = { + home: "profiles.components.menu.home", + select: "profiles.components.menu.select", + editInfo: "profiles.components.menu.editInfo", + editFile: "profiles.components.menu.editFile", + editRules: "profiles.components.menu.editRules", + editProxies: "profiles.components.menu.editProxies", + editGroups: "profiles.components.menu.editGroups", + extendConfig: "profiles.components.menu.extendConfig", + extendScript: "profiles.components.menu.extendScript", + openFile: "profiles.components.menu.openFile", + update: "profiles.components.menu.update", + updateViaProxy: "profiles.components.menu.updateViaProxy", + delete: "shared.actions.delete", + } as const; + + const urlModeMenu: ContextMenuItem[] = [ + ...(hasHome + ? [ + { + label: menuLabels.home, + handler: onOpenHome, + disabled: false, + } satisfies ContextMenuItem, + ] + : []), { - label: "Edit Rules", + label: menuLabels.select, + handler: onForceSelect, + disabled: false, + }, + { + label: menuLabels.editInfo, + handler: onEditInfo, + disabled: false, + }, + { + label: menuLabels.editFile, + handler: onEditFile, + disabled: false, + }, + { + label: menuLabels.editRules, handler: onEditRules, disabled: !option?.rules, }, { - label: "Edit Proxies", + label: menuLabels.editProxies, handler: onEditProxies, disabled: !option?.proxies, }, { - label: "Edit Groups", + label: menuLabels.editGroups, handler: onEditGroups, disabled: !option?.groups, }, { - label: "Extend Config", + label: menuLabels.extendConfig, handler: onEditMerge, disabled: !option?.merge, }, { - label: "Extend Script", + label: menuLabels.extendScript, handler: onEditScript, disabled: !option?.script, }, - { label: "Open File", handler: onOpenFile, disabled: false }, - { label: "Update", handler: () => onUpdate(0), disabled: false }, - { label: "Update via proxy", handler: () => onUpdate(2), disabled: false }, { - label: "Delete", + label: menuLabels.openFile, + handler: onOpenFile, + disabled: false, + }, + { + label: menuLabels.update, + handler: () => onUpdate(0), + disabled: false, + }, + { + label: menuLabels.updateViaProxy, + handler: () => onUpdate(2), + disabled: false, + }, + { + label: menuLabels.delete, handler: () => { setAnchorEl(null); if (batchMode) { // If in batch mode, just toggle selection instead of showing delete confirmation - onSelectionChange && onSelectionChange(); + if (onSelectionChange) { + onSelectionChange(); + } } else { setConfirmOpen(true); } }, disabled: false, }, - ]); - const fileModeMenu = [ - { label: "Select", handler: onForceSelect, disabled: false }, - { label: "Edit Info", handler: onEditInfo, disabled: false }, - { label: "Edit File", handler: onEditFile, disabled: false }, + ]; + const fileModeMenu: ContextMenuItem[] = [ { - label: "Edit Rules", + label: menuLabels.select, + handler: onForceSelect, + disabled: false, + }, + { + label: menuLabels.editInfo, + handler: onEditInfo, + disabled: false, + }, + { + label: menuLabels.editFile, + handler: onEditFile, + disabled: false, + }, + { + label: menuLabels.editRules, handler: onEditRules, disabled: !option?.rules, }, { - label: "Edit Proxies", + label: menuLabels.editProxies, handler: onEditProxies, disabled: !option?.proxies, }, { - label: "Edit Groups", + label: menuLabels.editGroups, handler: onEditGroups, disabled: !option?.groups, }, { - label: "Extend Config", + label: menuLabels.extendConfig, handler: onEditMerge, disabled: !option?.merge, }, { - label: "Extend Script", + label: menuLabels.extendScript, handler: onEditScript, disabled: !option?.script, }, - { label: "Open File", handler: onOpenFile, disabled: false }, { - label: "Delete", + label: menuLabels.openFile, + handler: onOpenFile, + disabled: false, + }, + { + label: menuLabels.delete, handler: () => { setAnchorEl(null); if (batchMode) { // If in batch mode, just toggle selection instead of showing delete confirmation - onSelectionChange && onSelectionChange(); + if (onSelectionChange) { + onSelectionChange(); + } } else { setConfirmOpen(true); } @@ -445,14 +542,16 @@ export const ProfileItem = (props: Props) => { // 监听自动更新事件 useEffect(() => { - const handleUpdateStarted = (event: CustomEvent) => { - if (event.detail.uid === itemData.uid) { + const handleUpdateStarted = (event: Event) => { + const customEvent = event as CustomEvent<{ uid?: string }>; + if (customEvent.detail?.uid === itemData.uid) { setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true })); } }; - const handleUpdateCompleted = (event: CustomEvent) => { - if (event.detail.uid === itemData.uid) { + const handleUpdateCompleted = (event: Event) => { + const customEvent = event as CustomEvent<{ uid?: string }>; + if (customEvent.detail?.uid === itemData.uid) { setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false })); // 更新完成后刷新显示 if (showNextUpdate) { @@ -462,27 +561,18 @@ export const ProfileItem = (props: Props) => { }; // 注册事件监听 - window.addEventListener( - "profile-update-started", - handleUpdateStarted as EventListener, - ); - window.addEventListener( - "profile-update-completed", - handleUpdateCompleted as EventListener, - ); + window.addEventListener("profile-update-started", handleUpdateStarted); + window.addEventListener("profile-update-completed", handleUpdateCompleted); return () => { // 清理事件监听 - window.removeEventListener( - "profile-update-started", - handleUpdateStarted as EventListener, - ); + window.removeEventListener("profile-update-started", handleUpdateStarted); window.removeEventListener( "profile-update-completed", - handleUpdateCompleted as EventListener, + handleUpdateCompleted, ); }; - }, [itemData.uid, showNextUpdate]); + }, [fetchNextUpdateTime, itemData.uid, setLoadingCache, showNextUpdate]); return ( { onContextMenu={(event) => { const { clientX, clientY } = event; setPosition({ top: clientY, left: clientX }); - setAnchorEl(event.currentTarget); + setAnchorEl(event.currentTarget as HTMLElement); event.preventDefault(); }} > @@ -544,7 +634,9 @@ export const ProfileItem = (props: Props) => { sx={{ padding: "2px", marginRight: "4px", marginLeft: "-8px" }} onClick={(e) => { e.stopPropagation(); - onSelectionChange && onSelectionChange(); + if (onSelectionChange) { + onSelectionChange(); + } }} > {isSelected ? ( @@ -589,7 +681,7 @@ export const ProfileItem = (props: Props) => { {/* only if has url can it be updated */} {hasUrl && ( { ) : ( hasUrl && ( - + {from} ) @@ -647,8 +742,8 @@ export const ProfileItem = (props: Props) => { textAlign="right" title={ showNextUpdate - ? t("Click to show last update time") - : `${t("Update Time")}: ${parseExpire(updated)}\n${t("Click to show next update")}` + ? t("profiles.components.profileItem.tooltips.showLast") + : `${t("shared.labels.updateTime")}: ${parseExpire(updated)}\n${t("profiles.components.profileItem.tooltips.showNext")}` } sx={{ cursor: "pointer", @@ -676,14 +771,16 @@ export const ProfileItem = (props: Props) => { {/* the third line show extra info or last updated time */} {hasExtra ? ( - + {parseTraffic(upload + download)} / {parseTraffic(total)} - {expire} + {expire} ) : ( - {parseExpire(updated)} + + {parseExpire(updated)} + )} { (theme) => { return { color: - item.label === "Delete" + item.label === menuLabels.delete ? theme.palette.error.main : undefined, }; @@ -738,7 +835,7 @@ export const ProfileItem = (props: Props) => { schema="clash" onSave={async (prev, curr) => { await saveProfileFile(uid, curr ?? ""); - onSave && onSave(prev, curr); + onSave?.(prev, curr); }} onClose={() => setFileOpen(false)} /> @@ -784,7 +881,7 @@ export const ProfileItem = (props: Props) => { schema="clash" onSave={async (prev, curr) => { await saveProfileFile(option?.merge ?? "", curr ?? ""); - onSave && onSave(prev, curr); + onSave?.(prev, curr); }} onClose={() => setMergeOpen(false)} /> @@ -796,15 +893,15 @@ export const ProfileItem = (props: Props) => { language="javascript" onSave={async (prev, curr) => { await saveProfileFile(option?.script ?? "", curr ?? ""); - onSave && onSave(prev, curr); + onSave?.(prev, curr); }} onClose={() => setScriptOpen(false)} /> )} setConfirmOpen(false)} onConfirm={() => { diff --git a/clash-verge-rev/src/components/profile/profile-more.tsx b/clash-verge-rev/src/components/profile/profile-more.tsx index 6d11335768..bcc7acd34a 100644 --- a/clash-verge-rev/src/components/profile/profile-more.tsx +++ b/clash-verge-rev/src/components/profile/profile-more.tsx @@ -25,12 +25,15 @@ interface Props { onSave?: (prev?: string, curr?: string) => void; } +const EMPTY_LOG_INFO: [string, string][] = []; + // profile enhanced item export const ProfileMore = (props: Props) => { - const { id, logInfo = [], onSave } = props; + const { id, logInfo, onSave } = props; + const entries = logInfo ?? EMPTY_LOG_INFO; const { t } = useTranslation(); - const [anchorEl, setAnchorEl] = useState(null); + const [anchorEl, setAnchorEl] = useState(null); const [position, setPosition] = useState({ left: 0, top: 0 }); const [fileOpen, setFileOpen] = useState(false); const [logOpen, setLogOpen] = useState(false); @@ -44,16 +47,26 @@ export const ProfileMore = (props: Props) => { setAnchorEl(null); try { await viewProfile(id); - } catch (err: any) { - showNotice("error", err?.message || err.toString()); + } catch (err) { + showNotice.error(err); } }); - const hasError = !!logInfo.find((e) => e[0] === "exception"); + const hasError = entries.some(([level]) => level === "exception"); + + const globalTitles: Record = { + Merge: "profiles.components.more.global.merge", + Script: "profiles.components.more.global.script", + }; + + const chipLabels: Record = { + Merge: "profiles.components.more.chips.merge", + Script: "profiles.components.more.chips.script", + }; const itemMenu = [ - { label: "Edit File", handler: onEditFile }, - { label: "Open File", handler: onOpenFile }, + { label: "profiles.components.menu.editFile", handler: onEditFile }, + { label: "profiles.components.menu.openFile", handler: onOpenFile }, ]; const boxStyle = { @@ -71,7 +84,7 @@ export const ProfileMore = (props: Props) => { onContextMenu={(event) => { const { clientX, clientY } = event; setPosition({ top: clientY, left: clientX }); - setAnchorEl(event.currentTarget); + setAnchorEl(event.currentTarget as HTMLElement); event.preventDefault(); }} > @@ -86,13 +99,13 @@ export const ProfileMore = (props: Props) => { variant="h6" component="h2" noWrap - title={t(`Global ${id}`)} + title={t(globalTitles[id])} > - {t(`Global ${id}`)} + {t(globalTitles[id])} { size="small" edge="start" color="error" - title={t("Script Console")} + title={t("profiles.modals.logViewer.title")} onClick={() => setLogOpen(true)} > @@ -119,7 +132,7 @@ export const ProfileMore = (props: Props) => { size="small" edge="start" color="inherit" - title={t("Script Console")} + title={t("profiles.modals.logViewer.title")} onClick={() => setLogOpen(true)} > @@ -167,13 +180,13 @@ export const ProfileMore = (props: Props) => { {fileOpen && ( { await saveProfileFile(id, curr ?? ""); - onSave && onSave(prev, curr); + onSave?.(prev, curr); }} onClose={() => setFileOpen(false)} /> @@ -181,7 +194,7 @@ export const ProfileMore = (props: Props) => { {logOpen && ( setLogOpen(false)} /> )} diff --git a/clash-verge-rev/src/components/profile/profile-viewer.tsx b/clash-verge-rev/src/components/profile/profile-viewer.tsx index b66a216df4..34b225e9cf 100644 --- a/clash-verge-rev/src/components/profile/profile-viewer.tsx +++ b/clash-verge-rev/src/components/profile/profile-viewer.tsx @@ -45,23 +45,19 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { // file input const fileDataRef = useRef(null); - const { - control, - watch, - register: _register, - ...formIns - } = useForm({ - defaultValues: { - type: "remote", - name: "", - desc: "", - url: "", - option: { - with_proxy: false, - self_proxy: false, + const { control, watch, setValue, reset, handleSubmit, getValues } = + useForm({ + defaultValues: { + type: "remote", + name: "", + desc: "", + url: "", + option: { + with_proxy: false, + self_proxy: false, + }, }, - }, - }); + }); useImperativeHandle(ref, () => ({ create: () => { @@ -71,7 +67,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { edit: (item: IProfileItem) => { if (item) { Object.entries(item).forEach(([key, value]) => { - formIns.setValue(key as any, value); + setValue(key as any, value); }); } setOpenType("edit"); @@ -83,15 +79,15 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { const withProxy = watch("option.with_proxy"); useEffect(() => { - if (selfProxy) formIns.setValue("option.with_proxy", false); - }, [selfProxy]); + if (selfProxy) setValue("option.with_proxy", false); + }, [selfProxy, setValue]); useEffect(() => { - if (withProxy) formIns.setValue("option.self_proxy", false); - }, [withProxy]); + if (withProxy) setValue("option.self_proxy", false); + }, [setValue, withProxy]); const handleOk = useLockFn( - formIns.handleSubmit(async (form) => { + handleSubmit(async (form) => { if (form.option?.timeout_seconds) { form.option.timeout_seconds = +form.option.timeout_seconds; } @@ -148,9 +144,8 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { } } catch { // 首次创建/更新失败,尝试使用自身代理 - showNotice( - "info", - t("Profile creation failed, retrying with Clash proxy..."), + showNotice.info( + "profiles.modals.profileForm.feedback.notifications.creationRetry", ); // 使用自身代理的配置 @@ -174,24 +169,23 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { await patchProfile(form.uid, { option: originalOptions }); } - showNotice( - "success", - t("Profile creation succeeded with Clash proxy"), + showNotice.success( + "profiles.modals.profileForm.feedback.notifications.creationSuccess", ); } } // 成功后的操作 setOpen(false); - setTimeout(() => formIns.reset(), 500); + setTimeout(() => reset(), 500); fileDataRef.current = null; // 优化:UI先关闭,异步通知父组件 setTimeout(() => { onChange(isActivating); }, 0); - } catch (err: any) { - showNotice("error", err.message || err.toString()); + } catch (err) { + showNotice.error(err); } finally { setLoading(false); } @@ -202,7 +196,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { try { setOpen(false); fileDataRef.current = null; - setTimeout(() => formIns.reset(), 500); + setTimeout(() => reset(), 500); } catch (e) { console.warn("[ProfileViewer] handleClose error:", e); } @@ -224,10 +218,14 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { return ( ( - {t("Type")} - Remote Local @@ -251,7 +255,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { name="name" control={control} render={({ field }) => ( - + )} /> @@ -259,7 +263,11 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { name="desc" control={control} render={({ field }) => ( - + )} /> @@ -273,7 +281,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { {...text} {...field} multiline - label={t("Subscription URL")} + label={t("profiles.modals.profileForm.fields.subscriptionUrl")} /> )} /> @@ -300,12 +308,12 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { {...field} type="number" placeholder="60" - label={t("HTTP Request Timeout")} + label={t("profiles.modals.profileForm.fields.httpTimeout")} slotProps={{ input: { endAdornment: ( - {t("seconds")} + {t("shared.units.seconds")} ), }, @@ -325,11 +333,13 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { {...text} {...field} type="number" - label={t("Update Interval")} + label={t("profiles.modals.profileForm.fields.updateInterval")} slotProps={{ input: { endAdornment: ( - {t("mins")} + + {t("shared.units.minutes")} + ), }, }} @@ -341,7 +351,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { {isLocal && openType === "new" && ( { - formIns.setValue("name", formIns.getValues("name") || file.name); + setValue("name", getValues("name") || file.name); fileDataRef.current = val; }} /> @@ -354,7 +364,9 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { control={control} render={({ field }) => ( - {t("Use System Proxy")} + + {t("profiles.modals.profileForm.fields.useSystemProxy")} + )} @@ -365,7 +377,9 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { control={control} render={({ field }) => ( - {t("Use Clash Proxy")} + + {t("profiles.modals.profileForm.fields.useClashProxy")} + )} @@ -376,7 +390,22 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { control={control} render={({ field }) => ( - {t("Accept Invalid Certs (Danger)")} + + {t("profiles.modals.profileForm.fields.acceptInvalidCerts")} + + + + )} + /> + + ( + + + {t("profiles.modals.profileForm.fields.allowAutoUpdate")} + )} diff --git a/clash-verge-rev/src/components/profile/proxies-editor-viewer.tsx b/clash-verge-rev/src/components/profile/proxies-editor-viewer.tsx index 3ec18f3b35..e8d4fe3b4e 100644 --- a/clash-verge-rev/src/components/profile/proxies-editor-viewer.tsx +++ b/clash-verge-rev/src/components/profile/proxies-editor-viewer.tsx @@ -1,19 +1,20 @@ import { DndContext, - closestCenter, + DragEndEvent, KeyboardSensor, PointerSensor, + closestCenter, useSensor, useSensors, - DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, } from "@dnd-kit/sortable"; +import MonacoEditor from "@monaco-editor/react"; import { - VerticalAlignTopRounded, VerticalAlignBottomRounded, + VerticalAlignTopRounded, } from "@mui/icons-material"; import { Box, @@ -29,9 +30,14 @@ import { } from "@mui/material"; import { useLockFn } from "ahooks"; import yaml from "js-yaml"; -import { useEffect, useMemo, useState } from "react"; +import { + startTransition, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import { useTranslation } from "react-i18next"; -import MonacoEditor from "react-monaco-editor"; import { Virtuoso } from "react-virtuoso"; import { ProxyItem } from "@/components/profile/proxy-item"; @@ -145,7 +151,9 @@ export const ProxiesEditorViewer = (props: Props) => { const lines = uris.trim().split("\n"); let idx = 0; const batchSize = 50; - function parseBatch() { + let parseTimer: number | undefined; + + const parseBatch = () => { const end = Math.min(idx + batchSize, lines.length); for (; idx < end; idx++) { const uri = lines[idx]; @@ -155,24 +163,28 @@ export const ProxiesEditorViewer = (props: Props) => { proxies.push(proxy); names.push(proxy.name); } - } catch (err: any) { + } catch (err) { console.warn( "[ProxiesEditorViewer] parseUri failed for line:", uri, - err?.message || err, + err, ); // 不阻塞主流程 } } if (idx < lines.length) { - setTimeout(parseBatch, 0); + parseTimer = window.setTimeout(parseBatch, 0); } else { + if (parseTimer !== undefined) { + clearTimeout(parseTimer); + parseTimer = undefined; + } cb(proxies); } - } + }; parseBatch(); }; - const fetchProfile = async () => { + const fetchProfile = useCallback(async () => { const data = await readProfileFile(profileUid); const originProxiesObj = yaml.load(data) as { @@ -180,9 +192,9 @@ export const ProxiesEditorViewer = (props: Props) => { } | null; setProxyList(originProxiesObj?.proxies || []); - }; + }, [profileUid]); - const fetchContent = async () => { + const fetchContent = useCallback(async () => { const data = await readProfileFile(property); const obj = yaml.load(data) as ISeqProfileConfig | null; @@ -192,59 +204,70 @@ export const ProxiesEditorViewer = (props: Props) => { setPrevData(data); setCurrData(data); - }; + }, [property]); useEffect(() => { - if (currData === "") return; - if (visualization !== true) return; - - const obj = yaml.load(currData) as { - prepend: []; - append: []; - delete: []; - } | null; - setPrependSeq(obj?.prepend || []); - setAppendSeq(obj?.append || []); - setDeleteSeq(obj?.delete || []); - }, [visualization]); - - useEffect(() => { - if (prependSeq && appendSeq && deleteSeq) { - const serialize = () => { - try { - setCurrData( - yaml.dump( - { prepend: prependSeq, append: appendSeq, delete: deleteSeq }, - { forceQuotes: true }, - ), - ); - } catch (e) { - console.warn("[ProxiesEditorViewer] yaml.dump failed:", e); - // 防止异常导致UI卡死 - } - }; - if (window.requestIdleCallback) { - window.requestIdleCallback(serialize); - } else { - setTimeout(serialize, 0); - } + if (currData === "" || visualization !== true) { + return; } + + const obj = yaml.load(currData) as ISeqProfileConfig | null; + startTransition(() => { + setPrependSeq(obj?.prepend ?? []); + setAppendSeq(obj?.append ?? []); + setDeleteSeq(obj?.delete ?? []); + }); + }, [currData, visualization]); + + useEffect(() => { + if (!(prependSeq && appendSeq && deleteSeq)) { + return; + } + + const serialize = () => { + try { + setCurrData( + yaml.dump( + { prepend: prependSeq, append: appendSeq, delete: deleteSeq }, + { forceQuotes: true }, + ), + ); + } catch (e) { + console.warn("[ProxiesEditorViewer] yaml.dump failed:", e); + // 防止异常导致UI卡死 + } + }; + let idleId: number | undefined; + let timeoutId: number | undefined; + if (window.requestIdleCallback) { + idleId = window.requestIdleCallback(serialize); + } else { + timeoutId = window.setTimeout(serialize, 0); + } + return () => { + if (idleId !== undefined && window.cancelIdleCallback) { + window.cancelIdleCallback(idleId); + } + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + }; }, [prependSeq, appendSeq, deleteSeq]); useEffect(() => { if (!open) return; fetchContent(); fetchProfile(); - }, [open]); + }, [fetchContent, fetchProfile, open]); const handleSave = useLockFn(async () => { try { await saveProfileFile(property, currData); - showNotice("success", t("Saved Successfully")); + showNotice.success("shared.feedback.notifications.saved"); onSave?.(prevData, currData); onClose(); - } catch (err: any) { - showNotice("error", err.toString()); + } catch (err) { + showNotice.error(err); } }); @@ -253,7 +276,7 @@ export const ProxiesEditorViewer = (props: Props) => { { - {t("Edit Proxies")} + {t("profiles.modals.proxiesEditor.title")} @@ -289,7 +314,9 @@ export const ProxiesEditorViewer = (props: Props) => { { }); }} > - {t("Prepend Proxy")} + {t("profiles.modals.proxiesEditor.actions.prepend")} @@ -323,7 +350,7 @@ export const ProxiesEditorViewer = (props: Props) => { }); }} > - {t("Append Proxy")} + {t("profiles.modals.proxiesEditor.actions.append")} @@ -357,10 +384,10 @@ export const ProxiesEditorViewer = (props: Props) => { return x.name; })} > - {filteredPrependSeq.map((item, index) => { + {filteredPrependSeq.map((item) => { return ( { @@ -380,7 +407,7 @@ export const ProxiesEditorViewer = (props: Props) => { const newIndex = index - shift; return ( { return x.name; })} > - {filteredAppendSeq.map((item, index) => { + {filteredAppendSeq.map((item) => { return ( { @@ -446,7 +473,7 @@ export const ProxiesEditorViewer = (props: Props) => { height="100%" language="yaml" value={currData} - theme={themeMode === "light" ? "vs" : "vs-dark"} + theme={themeMode === "light" ? "light" : "vs-dark"} options={{ tabSize: 2, // 根据语言类型设置缩进大小 minimap: { @@ -467,18 +494,18 @@ export const ProxiesEditorViewer = (props: Props) => { fontLigatures: false, // 连字符 smoothScrolling: true, // 平滑滚动 }} - onChange={(value) => setCurrData(value)} + onChange={(value) => setCurrData(value ?? "")} /> )}
    diff --git a/clash-verge-rev/src/components/profile/proxy-item.tsx b/clash-verge-rev/src/components/profile/proxy-item.tsx index 7efb878f06..4b8a561f55 100644 --- a/clash-verge-rev/src/components/profile/proxy-item.tsx +++ b/clash-verge-rev/src/components/profile/proxy-item.tsx @@ -21,22 +21,19 @@ export const ProxyItem = (props: Props) => { const sortable = type === "prepend" || type === "append"; const { - attributes, - listeners, - setNodeRef, + attributes: sortableAttributes, + listeners: sortableListeners, + setNodeRef: sortableSetNodeRef, transform, transition, isDragging, - } = sortable - ? useSortable({ id: proxy.name }) - : { - attributes: {}, - listeners: {}, - setNodeRef: null, - transform: null, - transition: null, - isDragging: false, - }; + } = useSortable({ + id: proxy.name, + disabled: !sortable, + }); + const dragAttributes = sortable ? sortableAttributes : undefined; + const dragListeners = sortable ? sortableListeners : undefined; + const dragNodeRef = sortable ? sortableSetNodeRef : undefined; return ( { })} > { } - secondaryTypographyProps={{ - sx: { - display: "flex", - alignItems: "center", - color: "#ccc", + slotProps={{ + secondary: { + sx: { + display: "flex", + alignItems: "center", + color: "#ccc", + }, }, }} /> diff --git a/clash-verge-rev/src/components/profile/rule-item.tsx b/clash-verge-rev/src/components/profile/rule-item.tsx index 03e5fb547a..587913f143 100644 --- a/clash-verge-rev/src/components/profile/rule-item.tsx +++ b/clash-verge-rev/src/components/profile/rule-item.tsx @@ -95,11 +95,13 @@ export const RuleItem = (props: Props) => { } - secondaryTypographyProps={{ - sx: { - display: "flex", - alignItems: "center", - color: "#ccc", + slotProps={{ + secondary: { + sx: { + display: "flex", + alignItems: "center", + color: "#ccc", + }, }, }} /> diff --git a/clash-verge-rev/src/components/profile/rules-editor-viewer.tsx b/clash-verge-rev/src/components/profile/rules-editor-viewer.tsx index fe27bfc2a5..0eeab96291 100644 --- a/clash-verge-rev/src/components/profile/rules-editor-viewer.tsx +++ b/clash-verge-rev/src/components/profile/rules-editor-viewer.tsx @@ -1,19 +1,20 @@ import { DndContext, - closestCenter, + DragEndEvent, KeyboardSensor, PointerSensor, + closestCenter, useSensor, useSensors, - DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, } from "@dnd-kit/sortable"; +import MonacoEditor from "@monaco-editor/react"; import { - VerticalAlignTopRounded, VerticalAlignBottomRounded, + VerticalAlignTopRounded, } from "@mui/icons-material"; import { Autocomplete, @@ -31,9 +32,14 @@ import { } from "@mui/material"; import { useLockFn } from "ahooks"; import yaml from "js-yaml"; -import { useEffect, useMemo, useState } from "react"; +import { + startTransition, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import { useTranslation } from "react-i18next"; -import MonacoEditor from "react-monaco-editor"; import { Virtuoso } from "react-virtuoso"; import { Switch } from "@/components/base"; @@ -41,6 +47,7 @@ import { RuleItem } from "@/components/profile/rule-item"; import { readProfileFile, saveProfileFile } from "@/services/cmds"; import { showNotice } from "@/services/noticeService"; import { useThemeMode } from "@/services/states"; +import type { TranslationKey } from "@/types/generated/i18n-keys"; import getSystem from "@/utils/get-system"; import { BaseSearchBox } from "../base/base-search-box"; @@ -234,8 +241,25 @@ const rules: { }, ]; +const RULE_TYPE_LABEL_KEYS: Record = Object.fromEntries( + rules.map((rule) => [ + rule.name, + `rules.modals.editor.ruleTypes.${rule.name}`, + ]), +); + const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"]; +const PROXY_POLICY_LABEL_KEYS: Record = + builtinProxyPolicies.reduce( + (acc, policy) => { + acc[policy] = + `proxies.components.enums.policies.${policy}` as TranslationKey; + return acc; + }, + {} as Record, + ); + export const RulesEditorViewer = (props: Props) => { const { groupsUid, mergeUid, profileUid, property, open, onClose, onSave } = props; @@ -305,7 +329,7 @@ export const RulesEditorViewer = (props: Props) => { } } }; - const fetchContent = async () => { + const fetchContent = useCallback(async () => { const data = await readProfileFile(property); const obj = yaml.load(data) as ISeqProfileConfig | null; @@ -315,42 +339,57 @@ export const RulesEditorViewer = (props: Props) => { setPrevData(data); setCurrData(data); - }; + }, [property]); useEffect(() => { - if (currData === "") return; - if (visualization !== true) return; + if (currData === "" || visualization !== true) { + return; + } const obj = yaml.load(currData) as ISeqProfileConfig | null; - setPrependSeq(obj?.prepend || []); - setAppendSeq(obj?.append || []); - setDeleteSeq(obj?.delete || []); - }, [visualization]); + startTransition(() => { + setPrependSeq(obj?.prepend ?? []); + setAppendSeq(obj?.append ?? []); + setDeleteSeq(obj?.delete ?? []); + }); + }, [currData, visualization]); // 优化:异步处理大数据yaml.dump,避免UI卡死 useEffect(() => { - if (prependSeq && appendSeq && deleteSeq) { - const serialize = () => { - try { - setCurrData( - yaml.dump( - { prepend: prependSeq, append: appendSeq, delete: deleteSeq }, - { forceQuotes: true }, - ), - ); - } catch (e: any) { - showNotice("error", e?.message || e?.toString() || "YAML dump error"); - } - }; - if (window.requestIdleCallback) { - window.requestIdleCallback(serialize); - } else { - setTimeout(serialize, 0); - } + if (!(prependSeq && appendSeq && deleteSeq)) { + return; } + + const serialize = () => { + try { + setCurrData( + yaml.dump( + { prepend: prependSeq, append: appendSeq, delete: deleteSeq }, + { forceQuotes: true }, + ), + ); + } catch (error) { + showNotice.error(error ?? "YAML dump error"); + } + }; + let idleId: number | undefined; + let timeoutId: number | undefined; + if (window.requestIdleCallback) { + idleId = window.requestIdleCallback(serialize); + } else { + timeoutId = window.setTimeout(serialize, 0); + } + return () => { + if (idleId !== undefined && window.cancelIdleCallback) { + window.cancelIdleCallback(idleId); + } + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + }; }, [prependSeq, appendSeq, deleteSeq]); - const fetchProfile = async () => { + const fetchProfile = useCallback(async () => { const data = await readProfileFile(profileUid); // 原配置文件 const groupsData = await readProfileFile(groupsUid); // groups配置文件 const mergeData = await readProfileFile(mergeUid); // merge配置文件 @@ -358,13 +397,25 @@ export const RulesEditorViewer = (props: Props) => { const rulesObj = yaml.load(data) as { rules: [] } | null; - const originGroupsObj = yaml.load(data) as { "proxy-groups": [] } | null; + const originGroupsObj = yaml.load(data) as { + "proxy-groups": IProxyGroupConfig[]; + } | null; const originGroups = originGroupsObj?.["proxy-groups"] || []; const moreGroupsObj = yaml.load(groupsData) as ISeqProfileConfig | null; - const morePrependGroups = moreGroupsObj?.["prepend"] || []; - const moreAppendGroups = moreGroupsObj?.["append"] || []; - const moreDeleteGroups = - moreGroupsObj?.["delete"] || ([] as string[] | { name: string }[]); + const rawPrependGroups = moreGroupsObj?.["prepend"]; + const morePrependGroups = Array.isArray(rawPrependGroups) + ? (rawPrependGroups as IProxyGroupConfig[]) + : []; + const rawAppendGroups = moreGroupsObj?.["append"]; + const moreAppendGroups = Array.isArray(rawAppendGroups) + ? (rawAppendGroups as IProxyGroupConfig[]) + : []; + const rawDeleteGroups = moreGroupsObj?.["delete"]; + const moreDeleteGroups: Array = Array.isArray( + rawDeleteGroups, + ) + ? (rawDeleteGroups as Array) + : []; const groups = morePrependGroups.concat( originGroups.filter((group: any) => { if (group.name) { @@ -376,14 +427,16 @@ export const RulesEditorViewer = (props: Props) => { moreAppendGroups, ); - const originRuleSetObj = yaml.load(data) as { "rule-providers": {} } | null; + const originRuleSetObj = yaml.load(data) as { + "rule-providers": Record; + } | null; const originRuleSet = originRuleSetObj?.["rule-providers"] || {}; const moreRuleSetObj = yaml.load(mergeData) as { - "rule-providers": {}; + "rule-providers": Record; } | null; const moreRuleSet = moreRuleSetObj?.["rule-providers"] || {}; const globalRuleSetObj = yaml.load(globalMergeData) as { - "rule-providers": {}; + "rule-providers": Record; } | null; const globalRuleSet = globalRuleSetObj?.["rule-providers"] || {}; const ruleSet = Object.assign( @@ -393,12 +446,16 @@ export const RulesEditorViewer = (props: Props) => { globalRuleSet, ); - const originSubRuleObj = yaml.load(data) as { "sub-rules": {} } | null; + const originSubRuleObj = yaml.load(data) as { + "sub-rules": Record; + } | null; const originSubRule = originSubRuleObj?.["sub-rules"] || {}; - const moreSubRuleObj = yaml.load(mergeData) as { "sub-rules": {} } | null; + const moreSubRuleObj = yaml.load(mergeData) as { + "sub-rules": Record; + } | null; const moreSubRule = moreSubRuleObj?.["sub-rules"] || {}; const globalSubRuleObj = yaml.load(globalMergeData) as { - "sub-rules": {}; + "sub-rules": Record; } | null; const globalSubRule = globalSubRuleObj?.["sub-rules"] || {}; const subRule = Object.assign( @@ -413,20 +470,22 @@ export const RulesEditorViewer = (props: Props) => { setRuleSetList(Object.keys(ruleSet)); setSubRuleList(Object.keys(subRule)); setRuleList(rulesObj?.rules || []); - }; + }, [groupsUid, mergeUid, profileUid]); useEffect(() => { if (!open) return; fetchContent(); fetchProfile(); - }, [open]); + }, [fetchContent, fetchProfile, open]); const validateRule = () => { if ((ruleType.required ?? true) && !ruleContent) { - throw new Error(t("Rule Condition Required")); + throw new Error( + t("rules.modals.editor.form.validation.conditionRequired"), + ); } if (ruleType.validator && !ruleType.validator(ruleContent)) { - throw new Error(t("Invalid Rule")); + throw new Error(t("rules.modals.editor.form.validation.invalidRule")); } const condition = (ruleType.required ?? true) ? ruleContent : ""; @@ -438,11 +497,11 @@ export const RulesEditorViewer = (props: Props) => { const handleSave = useLockFn(async () => { try { await saveProfileFile(property, currData); - showNotice("success", t("Saved Successfully")); + showNotice.success("shared.feedback.notifications.saved"); onSave?.(prevData, currData); onClose(); } catch (err: any) { - showNotice("error", err.toString()); + showNotice.error(err); } }); @@ -451,7 +510,7 @@ export const RulesEditorViewer = (props: Props) => { { - {t("Edit Rules")} + {t("rules.modals.editor.title")} @@ -479,26 +540,37 @@ export const RulesEditorViewer = (props: Props) => { }} > - + } options={rules} value={ruleType} - getOptionLabel={(option) => option.name} - renderOption={(props, option) => ( -
  • - {option.name} -
  • - )} + getOptionLabel={(option) => + t(RULE_TYPE_LABEL_KEYS[option.name] ?? option.name) + } + renderOption={(props, option) => { + const label = t( + RULE_TYPE_LABEL_KEYS[option.name] ?? option.name, + ); + return ( +
  • + {label} +
  • + ); + }} onChange={(_, value) => value && setRuleType(value)} />
    - + {ruleType.name === "RULE-SET" && ( { )} - + } options={proxyPolicyList} value={proxyPolicy} - renderOption={(props, option) => ( -
  • - {option} -
  • - )} + getOptionLabel={(option) => + t(PROXY_POLICY_LABEL_KEYS[option] ?? option) + } + renderOption={(props, option) => { + const label = t(PROXY_POLICY_LABEL_KEYS[option] ?? option); + return ( +
  • + {label} +
  • + ); + }} onChange={(_, value) => value && setProxyPolicy(value)} />
    {ruleType.noResolve && ( - + setNoResolve(!noResolve)} @@ -570,11 +652,11 @@ export const RulesEditorViewer = (props: Props) => { if (prependSeq.includes(raw)) return; setPrependSeq([raw, ...prependSeq]); } catch (err: any) { - showNotice("error", err.message || err.toString()); + showNotice.error(err); } }} > - {t("Prepend Rule")} + {t("rules.modals.editor.form.actions.prependRule")} @@ -588,11 +670,11 @@ export const RulesEditorViewer = (props: Props) => { if (appendSeq.includes(raw)) return; setAppendSeq([...appendSeq, raw]); } catch (err: any) { - showNotice("error", err.message || err.toString()); + showNotice.error(err); } }} > - {t("Append Rule")} + {t("rules.modals.editor.form.actions.appendRule")} @@ -626,10 +708,10 @@ export const RulesEditorViewer = (props: Props) => { return x; })} > - {filteredPrependSeq.map((item, index) => { + {filteredPrependSeq.map((item) => { return ( { @@ -647,7 +729,7 @@ export const RulesEditorViewer = (props: Props) => { const newIndex = index - shift; return ( { return x; })} > - {filteredAppendSeq.map((item, index) => { + {filteredAppendSeq.map((item) => { return ( { @@ -709,7 +791,7 @@ export const RulesEditorViewer = (props: Props) => { height="100%" language="yaml" value={currData} - theme={themeMode === "light" ? "vs" : "vs-dark"} + theme={themeMode === "light" ? "light" : "vs-dark"} options={{ tabSize: 2, // 根据语言类型设置缩进大小 minimap: { @@ -730,18 +812,18 @@ export const RulesEditorViewer = (props: Props) => { fontLigatures: false, // 连字符 smoothScrolling: true, // 平滑滚动 }} - onChange={(value) => setCurrData(value)} + onChange={(value) => setCurrData(value ?? "")} /> )} diff --git a/clash-verge-rev/src/components/proxy/provider-button.tsx b/clash-verge-rev/src/components/proxy/provider-button.tsx index e22b856e0c..4355163860 100644 --- a/clash-verge-rev/src/components/proxy/provider-button.tsx +++ b/clash-verge-rev/src/components/proxy/provider-button.tsx @@ -66,12 +66,17 @@ export const ProviderButton = () => { await refreshProxy(); await refreshProxyProviders(); - showNotice("success", `${name} 更新成功`); - } catch (err: any) { - showNotice( - "error", - `${name} 更新失败: ${err?.message || err.toString()}`, + showNotice.success( + "proxies.feedback.notifications.provider.updateSuccess", + { + name, + }, ); + } catch (err) { + showNotice.error("proxies.feedback.notifications.provider.updateFailed", { + name, + message: String(err), + }); } finally { // 清除更新状态 setUpdating((prev) => ({ ...prev, [name]: false })); @@ -84,7 +89,7 @@ export const ProviderButton = () => { // 获取所有provider的名称 const allProviders = Object.keys(proxyProviders || {}); if (allProviders.length === 0) { - showNotice("info", "没有可更新的代理提供者"); + showNotice.info("proxies.feedback.notifications.provider.none"); return; } @@ -114,9 +119,11 @@ export const ProviderButton = () => { await refreshProxy(); await refreshProxyProviders(); - showNotice("success", "全部代理提供者更新成功"); - } catch (err: any) { - showNotice("error", `更新失败: ${err?.message || err.toString()}`); + showNotice.success("proxies.feedback.notifications.provider.allUpdated"); + } catch (err) { + showNotice.error("proxies.feedback.notifications.provider.genericError", { + message: String(err), + }); } finally { // 清除所有更新状态 setUpdating({}); @@ -138,7 +145,7 @@ export const ProviderButton = () => { onClick={() => setOpen(true)} sx={{ mr: 1 }} > - {t("Proxy Provider")} + {t("proxies.page.provider.title")} @@ -148,14 +155,17 @@ export const ProviderButton = () => { justifyContent="space-between" alignItems="center" > - {t("Proxy Provider")} + + {t("proxies.page.provider.title")} + @@ -246,7 +256,7 @@ export const ProviderButton = () => { color="text.secondary" noWrap > - {t("Update At")}: + {t("shared.labels.updateAt")}: {time.fromNow()} @@ -264,11 +274,17 @@ export const ProviderButton = () => { justifyContent: "space-between", }} > - + {parseTraffic(upload + download)} /{" "} {parseTraffic(total)} - + {parseExpire(expire)} @@ -313,7 +329,8 @@ export const ProviderButton = () => { "100%": { transform: "rotate(360deg)" }, }, }} - title={t("Update Provider") as string} + title={t("proxies.page.provider.actions.update")} + aria-label={t("proxies.page.provider.actions.update")} > @@ -326,7 +343,7 @@ export const ProviderButton = () => { diff --git a/clash-verge-rev/src/components/proxy/proxy-chain.tsx b/clash-verge-rev/src/components/proxy/proxy-chain.tsx index 651fcbc950..99bf104355 100644 --- a/clash-verge-rev/src/components/proxy/proxy-chain.tsx +++ b/clash-verge-rev/src/components/proxy/proxy-chain.tsx @@ -31,7 +31,7 @@ import { Typography, useTheme, } from "@mui/material"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import useSWR from "swr"; import { @@ -156,7 +156,11 @@ const SortableItem = ({ proxy, index, onRemove }: SortableItemProps) => { {proxy.delay !== undefined && ( 0 ? `${proxy.delay}ms` : t("timeout") || "超时"} + label={ + proxy.delay > 0 + ? `${proxy.delay}ms` + : t("shared.labels.timeout") || "超时" + } size="small" color={ proxy.delay > 0 && proxy.delay < 200 @@ -196,9 +200,10 @@ export const ProxyChain = ({ const theme = useTheme(); const { t } = useTranslation(); const { proxies } = useAppData(); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [isConnecting, setIsConnecting] = useState(false); - const [isConnected, setIsConnected] = useState(false); + const markUnsavedChanges = useCallback(() => { + onMarkUnsavedChanges?.(); + }, [onMarkUnsavedChanges]); // 获取当前代理信息以检查连接状态 const { data: currentProxies, mutate: mutateProxies } = useSWR( @@ -211,52 +216,26 @@ export const ProxyChain = ({ }, ); - // 检查连接状态 - useEffect(() => { + const isConnected = useMemo(() => { if (!currentProxies || proxyChain.length < 2) { - setIsConnected(false); - return; + return false; } - // 获取用户配置的最后一个节点 const lastNode = proxyChain[proxyChain.length - 1]; - // 根据模式确定要检查的代理组和当前选中的代理 if (mode === "global") { - // 全局模式:检查 global 对象 - if (!currentProxies.global || !currentProxies.global.now) { - setIsConnected(false); - return; - } - - // 检查当前选中的代理是否是配置的最后一个节点 - if (currentProxies.global.now === lastNode.name) { - setIsConnected(true); - } else { - setIsConnected(false); - } - } else { - // 规则模式:检查指定的代理组 - if (!selectedGroup) { - setIsConnected(false); - return; - } - - const proxyChainGroup = currentProxies.groups.find( - (group) => group.name === selectedGroup, - ); - if (!proxyChainGroup || !proxyChainGroup.now) { - setIsConnected(false); - return; - } - - // 检查当前选中的代理是否是配置的最后一个节点 - if (proxyChainGroup.now === lastNode.name) { - setIsConnected(true); - } else { - setIsConnected(false); - } + return currentProxies.global?.now === lastNode.name; } + + if (!selectedGroup || !Array.isArray(currentProxies.groups)) { + return false; + } + + const proxyChainGroup = currentProxies.groups.find( + (group) => group.name === selectedGroup, + ); + + return proxyChainGroup?.now === lastNode.name; }, [currentProxies, proxyChain, mode, selectedGroup]); // 监听链的变化,但排除从配置加载的情况 @@ -267,10 +246,10 @@ export const ProxyChain = ({ chainLengthRef.current !== proxyChain.length && chainLengthRef.current !== 0 ) { - setHasUnsavedChanges(true); + markUnsavedChanges(); } chainLengthRef.current = proxyChain.length; - }, [proxyChain.length]); + }, [proxyChain.length, markUnsavedChanges]); const sensors = useSensors( useSensor(PointerSensor), @@ -288,26 +267,21 @@ export const ProxyChain = ({ const newIndex = proxyChain.findIndex((item) => item.id === over?.id); onUpdateChain(arrayMove(proxyChain, oldIndex, newIndex)); - setHasUnsavedChanges(true); + markUnsavedChanges(); } }, - [proxyChain, onUpdateChain], + [proxyChain, onUpdateChain, markUnsavedChanges], ); const handleRemoveProxy = useCallback( (id: string) => { const newChain = proxyChain.filter((item) => item.id !== id); onUpdateChain(newChain); - setHasUnsavedChanges(true); + markUnsavedChanges(); }, - [proxyChain, onUpdateChain], + [proxyChain, onUpdateChain, markUnsavedChanges], ); - const handleClearAll = useCallback(() => { - onUpdateChain([]); - setHasUnsavedChanges(true); - }, [onUpdateChain]); - const handleConnect = useCallback(async () => { if (isConnected) { // 如果已连接,则断开连接 @@ -327,13 +301,9 @@ export const ProxyChain = ({ // 清空链式代理配置UI // onUpdateChain([]); - // setHasUnsavedChanges(false); - - // 强制更新连接状态 - setIsConnected(false); } catch (error) { console.error("Failed to disconnect from proxy chain:", error); - alert(t("Failed to disconnect from proxy chain") || "断开链式代理失败"); + alert(t("proxies.page.chain.disconnectFailed") || "断开链式代理失败"); } finally { setIsConnecting(false); } @@ -341,9 +311,7 @@ export const ProxyChain = ({ } if (proxyChain.length < 2) { - alert( - t("Chain proxy requires at least 2 nodes") || "链式代理至少需要2个节点", - ); + alert(t("proxies.page.chain.minimumNodes") || "链式代理至少需要2个节点"); return; } @@ -372,13 +340,10 @@ export const ProxyChain = ({ // 刷新代理信息以更新连接状态 mutateProxies(); - - // 清除未保存标记 - setHasUnsavedChanges(false); console.log("Successfully connected to proxy chain"); } catch (error) { console.error("Failed to connect to proxy chain:", error); - alert(t("Failed to connect to proxy chain") || "连接链式代理失败"); + alert(t("proxies.page.chain.connectFailed") || "连接链式代理失败"); } finally { setIsConnecting(false); } @@ -411,7 +376,6 @@ export const ProxyChain = ({ delay: undefined, })) || []; onUpdateChain(chainItems); - setHasUnsavedChanges(false); } catch (parseError) { console.error("Failed to parse YAML:", parseError); onUpdateChain([]); @@ -435,7 +399,6 @@ export const ProxyChain = ({ delay: undefined, })) || []; onUpdateChain(chainItems); - setHasUnsavedChanges(false); } catch (jsonError) { console.error("Failed to parse as JSON either:", jsonError); onUpdateChain([]); @@ -448,7 +411,6 @@ export const ProxyChain = ({ } else if (chainConfigData === "") { // Empty string means no proxies available, show empty state onUpdateChain([]); - setHasUnsavedChanges(false); } }, [chainConfigData, onUpdateChain]); @@ -511,7 +473,7 @@ export const ProxyChain = ({ mb: 2, }} > - {t("Chain Proxy Config")} + {t("proxies.page.chain.header")} {proxyChain.length > 0 && ( { updateProxyChainConfigInRuntime(null); onUpdateChain([]); - setHasUnsavedChanges(false); }} sx={{ color: theme.palette.error.main, @@ -527,7 +488,9 @@ export const ProxyChain = ({ backgroundColor: theme.palette.error.light + "20", }, }} - title={t("Delete Chain Config") || "删除链式配置"} + title={ + t("proxies.page.actions.clearChainConfig") || "删除链式配置" + } > @@ -548,16 +511,16 @@ export const ProxyChain = ({ }} title={ proxyChain.length < 2 - ? t("Chain proxy requires at least 2 nodes") || + ? t("proxies.page.chain.minimumNodes") || "链式代理至少需要2个节点" : undefined } > {isConnecting - ? t("Connecting...") || "连接中..." + ? t("proxies.page.actions.connecting") || "连接中..." : isConnected - ? t("Disconnect") || "断开" - : t("Connect") || "连接"} + ? t("proxies.page.actions.disconnect") || "断开" + : t("proxies.page.actions.connect") || "连接"} @@ -567,10 +530,9 @@ export const ProxyChain = ({ sx={{ mb: 2 }} > {proxyChain.length === 1 - ? t( - "Chain proxy requires at least 2 nodes. Please add one more node.", - ) || "链式代理至少需要2个节点,请再添加一个节点。" - : t("Click nodes in order to add to proxy chain") || + ? t("proxies.page.chain.minimumNodesHint") || + "链式代理至少需要2个节点,请再添加一个节点。" + : t("proxies.page.chain.instruction") || "按顺序点击节点添加到代理链中"} @@ -585,7 +547,7 @@ export const ProxyChain = ({ color: theme.palette.text.secondary, }} > - {t("No proxy chain configured")} + {t("proxies.page.chain.empty")} ) : ( void; + enableHoverJump?: boolean; + hoverDelay?: number; } +export const DEFAULT_HOVER_DELAY = 280; + // 提取代理组名的第一个字符 const getGroupDisplayChar = (groupName: string): string => { if (!groupName) return "?"; @@ -18,14 +22,59 @@ const getGroupDisplayChar = (groupName: string): string => { export const ProxyGroupNavigator = ({ proxyGroupNames, onGroupLocation, + enableHoverJump = true, + hoverDelay = DEFAULT_HOVER_DELAY, }: ProxyGroupNavigatorProps) => { + const lastHoveredRef = useRef(null); + const hoverTimerRef = useRef | null>(null); + + const hoverDelayMs = hoverDelay >= 0 ? hoverDelay : 0; + + const clearHoverTimer = useCallback(() => { + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + }, []); + + useEffect(() => { + if (!enableHoverJump) { + clearHoverTimer(); + lastHoveredRef.current = null; + } + return () => { + clearHoverTimer(); + }; + }, [clearHoverTimer, enableHoverJump]); + const handleGroupClick = useCallback( (groupName: string) => { + clearHoverTimer(); + lastHoveredRef.current = groupName; onGroupLocation(groupName); }, - [onGroupLocation], + [clearHoverTimer, onGroupLocation], ); + const handleGroupHover = useCallback( + (groupName: string) => { + if (!enableHoverJump) return; + if (lastHoveredRef.current === groupName) return; + clearHoverTimer(); + hoverTimerRef.current = setTimeout(() => { + hoverTimerRef.current = null; + lastHoveredRef.current = groupName; + onGroupLocation(groupName); + }, hoverDelayMs); + }, + [clearHoverTimer, enableHoverJump, hoverDelayMs, onGroupLocation], + ); + + const handleButtonLeave = useCallback(() => { + clearHoverTimer(); + lastHoveredRef.current = null; + }, [clearHoverTimer]); + // 处理代理组数据,去重和排序 const processedGroups = useMemo(() => { return proxyGroupNames @@ -66,6 +115,10 @@ export const ProxyGroupNavigator = ({ size="small" variant="text" onClick={() => handleGroupClick(name)} + onMouseEnter={() => handleGroupHover(name)} + onFocus={() => handleGroupHover(name)} + onMouseLeave={handleButtonLeave} + onBlur={handleButtonLeave} sx={{ minWidth: 28, minHeight: 28, diff --git a/clash-verge-rev/src/components/proxy/proxy-groups.tsx b/clash-verge-rev/src/components/proxy/proxy-groups.tsx index f3fd255c01..84e771c401 100644 --- a/clash-verge-rev/src/components/proxy/proxy-groups.tsx +++ b/clash-verge-rev/src/components/proxy/proxy-groups.tsx @@ -13,23 +13,22 @@ import { useLockFn } from "ahooks"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; -import useSWR from "swr"; import { delayGroup, healthcheckProxyProvider } from "tauri-plugin-mihomo-api"; import { useProxySelection } from "@/hooks/use-proxy-selection"; import { useVerge } from "@/hooks/use-verge"; import { useAppData } from "@/providers/app-data-context"; -import { - getRuntimeConfig, - updateProxyChainConfigInRuntime, -} from "@/services/cmds"; +import { updateProxyChainConfigInRuntime } from "@/services/cmds"; import delayManager from "@/services/delay"; import { BaseEmpty } from "../base"; import { ScrollTopButton } from "../layout/scroll-top-button"; import { ProxyChain } from "./proxy-chain"; -import { ProxyGroupNavigator } from "./proxy-group-navigator"; +import { + ProxyGroupNavigator, + DEFAULT_HOVER_DELAY, +} from "./proxy-group-navigator"; import { ProxyRender } from "./proxy-render"; import { useRenderList } from "./use-render-list"; @@ -46,6 +45,8 @@ interface ProxyChainItem { delay?: number; } +const VirtuosoFooter = () =>
    ; + export const ProxyGroups = (props: Props) => { const { t } = useTranslation(); const { mode, isChainMode = false, chainConfigData } = props; @@ -61,23 +62,35 @@ export const ProxyGroups = (props: Props) => { const { verge } = useVerge(); const { proxies: proxiesData } = useAppData(); + const groups = proxiesData?.groups; + const availableGroups = useMemo(() => groups ?? [], [groups]); - // 当链式代理模式且规则模式下,如果没有选择代理组,默认选择第一个 - useEffect(() => { - if ( - isChainMode && - mode === "rule" && - !selectedGroup && - proxiesData?.groups?.length > 0 - ) { - setSelectedGroup(proxiesData.groups[0].name); + const defaultRuleGroup = useMemo(() => { + if (isChainMode && mode === "rule" && availableGroups.length > 0) { + return availableGroups[0].name; } - }, [isChainMode, mode, selectedGroup, proxiesData]); + return null; + }, [availableGroups, isChainMode, mode]); + + const activeSelectedGroup = useMemo( + () => selectedGroup ?? defaultRuleGroup, + [selectedGroup, defaultRuleGroup], + ); const { renderList, onProxies, onHeadState } = useRenderList( mode, isChainMode, - selectedGroup, + activeSelectedGroup, + ); + + const getGroupHeadState = useCallback( + (groupName: string) => { + const headItem = renderList.find( + (item) => item.type === 1 && item.group?.name === groupName, + ); + return headItem?.headState; + }, + [renderList], ); // 统代理选择 @@ -102,6 +115,8 @@ export const ProxyGroups = (props: Props) => { useEffect(() => { if (renderList.length === 0) return; + let restoreTimer: ReturnType | null = null; + try { const savedPositions = localStorage.getItem("proxy-scroll-positions"); if (savedPositions) { @@ -110,7 +125,7 @@ export const ProxyGroups = (props: Props) => { const savedPosition = positions[mode]; if (savedPosition !== undefined) { - setTimeout(() => { + restoreTimer = setTimeout(() => { virtuosoRef.current?.scrollTo({ top: savedPosition, behavior: "auto", @@ -121,7 +136,13 @@ export const ProxyGroups = (props: Props) => { } catch (e) { console.error("Error restoring scroll position:", e); } - }, [mode, renderList]); + + return () => { + if (restoreTimer) { + clearTimeout(restoreTimer); + } + }; + }, [mode, renderList.length]); // 改为使用节流函数保存滚动位置 const saveScrollPosition = useCallback( @@ -140,25 +161,30 @@ export const ProxyGroups = (props: Props) => { ); // 使用改进的滚动处理 - const handleScroll = useCallback( - throttle((e: any) => { - const scrollTop = e.target.scrollTop; - setShowScrollTop(scrollTop > 100); - // 使用稳定的节流来保存位置,而不是setTimeout - saveScrollPosition(scrollTop); - }, 500), // 增加到500ms以确保平滑滚动 + const handleScroll = useMemo( + () => + throttle((event: Event) => { + const target = event.target as HTMLElement | null; + const scrollTop = target?.scrollTop ?? 0; + setShowScrollTop(scrollTop > 100); + // 使用稳定的节流来保存位置,而不是setTimeout + saveScrollPosition(scrollTop); + }, 500), // 增加到500ms以确保平滑滚动 [saveScrollPosition], ); // 添加和清理滚动事件监听器 useEffect(() => { - if (!scrollerRef.current) return; - scrollerRef.current.addEventListener("scroll", handleScroll, { - passive: true, - }); + const node = scrollerRef.current; + if (!node) return; + + const listener = handleScroll as EventListener; + const options: AddEventListenerOptions = { passive: true }; + + node.addEventListener("scroll", listener, options); return () => { - scrollerRef.current?.removeEventListener("scroll", handleScroll); + node.removeEventListener("scroll", listener, options); }; }, [handleScroll]); @@ -176,18 +202,14 @@ export const ProxyGroups = (props: Props) => { setDuplicateWarning({ open: false, message: "" }); }, []); - // 获取当前选中的代理组信息 - const getCurrentGroup = useCallback(() => { - if (!selectedGroup || !proxiesData?.groups) return null; - return proxiesData.groups.find( - (group: any) => group.name === selectedGroup, + const currentGroup = useMemo(() => { + if (!activeSelectedGroup) return null; + return ( + availableGroups.find( + (group: any) => group.name === activeSelectedGroup, + ) ?? null ); - }, [selectedGroup, proxiesData]); - - // 获取可用的代理组列表 - const getAvailableGroups = useCallback(() => { - return proxiesData?.groups || []; - }, [proxiesData]); + }, [activeSelectedGroup, availableGroups]); // 处理代理组选择菜单 const handleGroupMenuOpen = (event: React.MouseEvent) => { @@ -210,10 +232,6 @@ export const ProxyGroups = (props: Props) => { } }; - const currentGroup = getCurrentGroup(); - const availableGroups = getAvailableGroups(); - - // TODO: 频繁点击切换代理节点,导致应用卡死 const handleChangeProxy = useCallback( (group: IProxyGroupItem, proxy: IProxyItem) => { if (isChainMode) { @@ -221,7 +239,7 @@ export const ProxyGroups = (props: Props) => { setProxyChain((prev) => { // 检查是否已经存在相同名称的代理,防止重复添加 if (prev.some((item) => item.name === proxy.name)) { - const warningMessage = t("Proxy node already exists in chain"); + const warningMessage = t("proxies.page.chain.duplicateNode"); setDuplicateWarning({ open: true, message: warningMessage, @@ -298,9 +316,13 @@ export const ProxyGroups = (props: Props) => { console.log(`[ProxyGroups] 延迟测试完成,组: ${groupName}`); } catch (error) { console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error); + } finally { + const headState = getGroupHeadState(groupName); + if (headState?.sortType === 1) { + onHeadState(groupName, { sortType: headState.sortType }); + } + onProxies(); } - - onProxies(); }); // 滚到对应的节点 @@ -324,22 +346,6 @@ export const ProxyGroups = (props: Props) => { } }; - // 获取运行时配置 - const { data: runtimeConfig } = useSWR("getRuntimeConfig", getRuntimeConfig, { - revalidateOnFocus: false, - revalidateIfStale: true, - }); - - // 获取所有代理组名称 - const getProxyGroupNames = useCallback(() => { - const config = runtimeConfig as any; - if (!config?.["proxy-groups"]) return []; - - return config["proxy-groups"] - .map((group: any) => group.name) - .filter((name: string) => name && name.trim() !== ""); - }, [runtimeConfig]); - // 定位到指定的代理组 const handleGroupLocationByName = useCallback( (groupName: string) => { @@ -358,13 +364,15 @@ export const ProxyGroups = (props: Props) => { [renderList], ); - const proxyGroupNames = useMemo( - () => getProxyGroupNames(), - [getProxyGroupNames], - ); + const proxyGroupNames = useMemo(() => { + const names = renderList + .filter((item) => item.type === 0 && item.group?.name) + .map((item) => item.group!.name); + return Array.from(new Set(names)); + }, [renderList]); if (mode === "direct") { - return ; + return ; } if (isChainMode) { @@ -395,7 +403,7 @@ export const ProxyGroups = (props: Props) => { variant="h6" sx={{ fontWeight: 600, fontSize: "16px" }} > - {t("Proxy Rules")} + {t("proxies.page.rules.title")} {currentGroup && ( { variant="body2" sx={{ mr: 0.5, fontSize: "12px" }} > - {t("Select Rules")} + {t("proxies.page.rules.select")} @@ -459,7 +467,7 @@ export const ProxyGroups = (props: Props) => { scrollerRef.current = ref as Element; }} components={{ - Footer: () =>
    , + Footer: VirtuosoFooter, }} initialScrollTop={scrollPositionRef.current[mode]} computeItemKey={(index) => renderList[index].key} @@ -485,7 +493,7 @@ export const ProxyGroups = (props: Props) => { onUpdateChain={setProxyChain} chainConfigData={chainConfigData} mode={mode} - selectedGroup={selectedGroup} + selectedGroup={activeSelectedGroup} /> @@ -510,18 +518,20 @@ export const ProxyGroups = (props: Props) => { anchorEl={ruleMenuAnchor} open={Boolean(ruleMenuAnchor)} onClose={handleGroupMenuClose} - PaperProps={{ - sx: { - maxHeight: 300, - minWidth: 200, + slotProps={{ + paper: { + sx: { + maxHeight: 300, + minWidth: 200, + }, }, }} > - {availableGroups.map((group: any, _index: number) => ( + {availableGroups.map((group: any) => ( handleGroupSelect(group.name)} - selected={selectedGroup === group.name} + selected={activeSelectedGroup === group.name} sx={{ fontSize: "14px", py: 1, @@ -564,6 +574,8 @@ export const ProxyGroups = (props: Props) => { )} @@ -578,7 +590,7 @@ export const ProxyGroups = (props: Props) => { scrollerRef.current = ref as Element; }} components={{ - Footer: () =>
    , + Footer: VirtuosoFooter, }} // 添加平滑滚动设置 initialScrollTop={scrollPositionRef.current[mode]} diff --git a/clash-verge-rev/src/components/proxy/proxy-head.tsx b/clash-verge-rev/src/components/proxy/proxy-head.tsx index f10af411be..56a034bd09 100644 --- a/clash-verge-rev/src/components/proxy/proxy-head.tsx +++ b/clash-verge-rev/src/components/proxy/proxy-head.tsx @@ -31,8 +31,10 @@ interface Props { onHeadState: (val: Partial) => void; } +const defaultSx: SxProps = {}; + export const ProxyHead = ({ - sx = {}, + sx = defaultSx, url, groupName, headState, @@ -65,7 +67,7 @@ export const ProxyHead = ({ @@ -74,7 +76,7 @@ export const ProxyHead = ({ { console.log(`[ProxyHead] 点击延迟测试按钮,组: ${groupName}`); // Remind the user that it is custom test url @@ -92,9 +94,11 @@ export const ProxyHead = ({ size="small" color="inherit" title={ - [t("Sort by default"), t("Sort by delay"), t("Sort by name")][ - sortType - ] + [ + t("proxies.page.tooltips.sortDefault"), + t("proxies.page.tooltips.sortDelay"), + t("proxies.page.tooltips.sortName"), + ][sortType] } onClick={() => onHeadState({ sortType: ((sortType + 1) % 3) as ProxySortType }) @@ -108,7 +112,7 @@ export const ProxyHead = ({ onHeadState({ textState: textState === "url" ? null : "url" }) } @@ -123,7 +127,11 @@ export const ProxyHead = ({ onHeadState({ showType: !showType })} > {showType ? : } @@ -132,7 +140,7 @@ export const ProxyHead = ({ onHeadState({ textState: textState === "filter" ? null : "filter" }) } @@ -152,7 +160,7 @@ export const ProxyHead = ({ value={filterText} size="small" variant="outlined" - placeholder={t("Filter conditions")} + placeholder={t("shared.placeholders.filter")} onChange={(e) => onHeadState({ filterText: e.target.value })} sx={{ ml: 0.5, flex: "1 1 auto", input: { py: 0.65, px: 1 } }} /> @@ -167,7 +175,7 @@ export const ProxyHead = ({ value={testUrl} size="small" variant="outlined" - placeholder={t("Delay check URL")} + placeholder={t("proxies.page.placeholders.delayCheckUrl")} onChange={(e) => onHeadState({ testUrl: e.target.value })} sx={{ ml: 0.5, flex: "1 1 auto", input: { py: 0.65, px: 1 } }} /> diff --git a/clash-verge-rev/src/components/proxy/proxy-item-mini.tsx b/clash-verge-rev/src/components/proxy/proxy-item-mini.tsx index 8e456f2953..1bbb30f16b 100644 --- a/clash-verge-rev/src/components/proxy/proxy-item-mini.tsx +++ b/clash-verge-rev/src/components/proxy/proxy-item-mini.tsx @@ -1,12 +1,12 @@ import { CheckCircleOutlineRounded } from "@mui/icons-material"; import { alpha, Box, ListItemButton, styled, Typography } from "@mui/material"; import { useLockFn } from "ahooks"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useReducer } from "react"; import { useTranslation } from "react-i18next"; import { BaseLoading } from "@/components/base"; import { useVerge } from "@/hooks/use-verge"; -import delayManager from "@/services/delay"; +import delayManager, { DelayUpdate } from "@/services/delay"; interface Props { group: IProxyGroupItem; @@ -24,31 +24,66 @@ export const ProxyItemMini = (props: Props) => { const presetList = ["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"]; const isPreset = presetList.includes(proxy.name); - // -1/<=0 为 不显示 - // -2 为 loading - const [delay, setDelay] = useState(-1); + // -1/<=0 为不显示,-2 为 loading + const [delayState, setDelayState] = useReducer( + (_: DelayUpdate, next: DelayUpdate) => next, + { delay: -1, updatedAt: 0 }, + ); const { verge } = useVerge(); const timeout = verge?.default_latency_timeout || 10000; useEffect(() => { if (isPreset) return; - delayManager.setListener(proxy.name, group.name, setDelay); + delayManager.setListener(proxy.name, group.name, setDelayState); return () => { delayManager.removeListener(proxy.name, group.name); }; }, [isPreset, proxy.name, group.name]); - useEffect(() => { + const updateDelay = useCallback(() => { if (!proxy) return; - setDelay(delayManager.getDelayFix(proxy, group.name)); + const cachedUpdate = delayManager.getDelayUpdate(proxy.name, group.name); + if (cachedUpdate) { + setDelayState({ ...cachedUpdate }); + return; + } + + const fallbackDelay = delayManager.getDelayFix(proxy, group.name); + if (fallbackDelay === -1) { + setDelayState({ delay: -1, updatedAt: 0 }); + return; + } + + let updatedAt = 0; + const history = proxy.history; + if (history && history.length > 0) { + const lastRecord = history[history.length - 1]; + const parsed = Date.parse(lastRecord.time); + if (!Number.isNaN(parsed)) { + updatedAt = parsed; + } + } + + setDelayState({ + delay: fallbackDelay, + updatedAt, + }); }, [proxy, group.name]); + useEffect(() => { + updateDelay(); + }, [updateDelay]); + const onDelay = useLockFn(async () => { - setDelay(-2); - setDelay(await delayManager.checkDelay(proxy.name, group.name, timeout)); + setDelayState({ delay: -2, updatedAt: Date.now() }); + setDelayState( + await delayManager.checkDelay(proxy.name, group.name, timeout), + ); }); + const delayValue = delayState.delay; + return ( { }, ({ palette: { mode, primary } }) => { const bgcolor = mode === "light" ? "#ffffff" : "#24252f"; - const showDelay = delay > 0; + const showDelay = delayValue > 0; const selectColor = mode === "light" ? primary.main : primary.light; return { @@ -177,13 +212,13 @@ export const ProxyItemMini = (props: Props) => { - {delay === -2 && ( + {delayValue === -2 && ( )} - {!proxy.provider && delay !== -2 && ( - // provider的节点不支持检测 + {!proxy.provider && delayValue !== -2 && ( + // provider 的节点不支持检测 { @@ -192,7 +227,7 @@ export const ProxyItemMini = (props: Props) => { onDelay(); }} sx={({ palette }) => ({ - display: "none", // hover才显示 + display: "none", // hover 时显示 ":hover": { bgcolor: alpha(palette.primary.main, 0.15) }, })} > @@ -200,7 +235,7 @@ export const ProxyItemMini = (props: Props) => { )} - {delay >= 0 && ( + {delayValue >= 0 && ( // 显示延迟 { e.stopPropagation(); onDelay(); }} - color={delayManager.formatDelayColor(delay, timeout)} + color={delayManager.formatDelayColor(delayValue, timeout)} sx={({ palette }) => !proxy.provider ? { ":hover": { bgcolor: alpha(palette.primary.main, 0.15) } } : {} } > - {delayManager.formatDelay(delay, timeout)} + {delayManager.formatDelay(delayValue, timeout)} )} - {proxy.type !== "Direct" && delay !== -2 && delay < 0 && selected && ( - // 展示已选择的icon - - )} + {proxy.type !== "Direct" && + delayValue !== -2 && + delayValue < 0 && + selected && ( + // 展示已选择的 icon + + )} {group.fixed && group.fixed === proxy.name && ( - // 展示fixed状态 + // 展示 fixed 状态 📌 diff --git a/clash-verge-rev/src/components/proxy/proxy-item.tsx b/clash-verge-rev/src/components/proxy/proxy-item.tsx index 82308b0ec2..951a76b193 100644 --- a/clash-verge-rev/src/components/proxy/proxy-item.tsx +++ b/clash-verge-rev/src/components/proxy/proxy-item.tsx @@ -11,11 +11,11 @@ import { Theme, } from "@mui/material"; import { useLockFn } from "ahooks"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useReducer } from "react"; import { BaseLoading } from "@/components/base"; import { useVerge } from "@/hooks/use-verge"; -import delayManager from "@/services/delay"; +import delayManager, { DelayUpdate } from "@/services/delay"; interface Props { group: IProxyGroupItem; @@ -49,30 +49,66 @@ export const ProxyItem = (props: Props) => { const presetList = ["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"]; const isPreset = presetList.includes(proxy.name); - // -1/<=0 为 不显示 - // -2 为 loading - const [delay, setDelay] = useState(-1); + // -1/<=0 为不显示,-2 为 loading + const [delayState, setDelayState] = useReducer( + (_: DelayUpdate, next: DelayUpdate) => next, + { delay: -1, updatedAt: 0 }, + ); const { verge } = useVerge(); const timeout = verge?.default_latency_timeout || 10000; + useEffect(() => { if (isPreset) return; - delayManager.setListener(proxy.name, group.name, setDelay); + delayManager.setListener(proxy.name, group.name, setDelayState); return () => { delayManager.removeListener(proxy.name, group.name); }; }, [proxy.name, group.name, isPreset]); - useEffect(() => { + const updateDelay = useCallback(() => { if (!proxy) return; - setDelay(delayManager.getDelayFix(proxy, group.name)); - }, [group.name, proxy]); + const cachedUpdate = delayManager.getDelayUpdate(proxy.name, group.name); + if (cachedUpdate) { + setDelayState({ ...cachedUpdate }); + return; + } + + const fallbackDelay = delayManager.getDelayFix(proxy, group.name); + if (fallbackDelay === -1) { + setDelayState({ delay: -1, updatedAt: 0 }); + return; + } + + let updatedAt = 0; + const history = proxy.history; + if (history && history.length > 0) { + const lastRecord = history[history.length - 1]; + const parsed = Date.parse(lastRecord.time); + if (!Number.isNaN(parsed)) { + updatedAt = parsed; + } + } + + setDelayState({ + delay: fallbackDelay, + updatedAt, + }); + }, [proxy, group.name]); + + useEffect(() => { + updateDelay(); + }, [updateDelay]); const onDelay = useLockFn(async () => { - setDelay(-2); - setDelay(await delayManager.checkDelay(proxy.name, group.name, timeout)); + setDelayState({ delay: -2, updatedAt: Date.now() }); + setDelayState( + await delayManager.checkDelay(proxy.name, group.name, timeout), + ); }); + const delayValue = delayState.delay; + return ( { ({ palette: { mode, primary } }) => { const bgcolor = mode === "light" ? "#ffffff" : "#24252f"; const selectColor = mode === "light" ? primary.main : primary.light; - const showDelay = delay > 0; + const showDelay = delayValue > 0; return { "&:hover .the-check": { display: !showDelay ? "block" : "none" }, @@ -141,14 +177,14 @@ export const ProxyItem = (props: Props) => { display: isPreset ? "none" : "", }} > - {delay === -2 && ( + {delayValue === -2 && ( )} - {!proxy.provider && delay !== -2 && ( - // provider的节点不支持检测 + {!proxy.provider && delayValue !== -2 && ( + // provider 的节点不支持检测 { @@ -157,7 +193,7 @@ export const ProxyItem = (props: Props) => { onDelay(); }} sx={({ palette }) => ({ - display: "none", // hover才显示 + display: "none", // hover 时显示 ":hover": { bgcolor: alpha(palette.primary.main, 0.15) }, })} > @@ -165,7 +201,7 @@ export const ProxyItem = (props: Props) => { )} - {delay > 0 && ( + {delayValue > 0 && ( // 显示延迟 { e.stopPropagation(); onDelay(); }} - color={delayManager.formatDelayColor(delay, timeout)} + color={delayManager.formatDelayColor(delayValue, timeout)} sx={({ palette }) => !proxy.provider ? { ":hover": { bgcolor: alpha(palette.primary.main, 0.15) } } : {} } > - {delayManager.formatDelay(delay, timeout)} + {delayManager.formatDelay(delayValue, timeout)} )} - {delay !== -2 && delay <= 0 && selected && ( - // 展示已选择的icon + {delayValue !== -2 && delayValue <= 0 && selected && ( + // 展示已选择的 icon { onCheckAll, onHeadState, onChangeProxy, - isChainMode = false, + isChainMode: _ = false, } = props; const { type, group, headState, proxy, proxyCol } = item; const { verge } = useVerge(); @@ -59,23 +59,42 @@ export const ProxyRender = (props: RenderProps) => { const itembackgroundcolor = isDark ? "#282A36" : "#ffffff"; const [iconCachePath, setIconCachePath] = useState(""); - useEffect(() => { - initIconCachePath(); - }, [group]); - - async function initIconCachePath() { + const initIconCachePath = useCallback(async () => { if (group.icon && group.icon.trim().startsWith("http")) { const fileName = group.name.replaceAll(" ", "") + "-" + getFileName(group.icon); const iconPath = await downloadIconCache(group.icon, fileName); setIconCachePath(convertFileSrc(iconPath)); + } else { + setIconCachePath(""); } - } + }, [group.icon, group.name]); + + useEffect(() => { + initIconCachePath(); + }, [initIconCachePath]); function getFileName(url: string) { return url.substring(url.lastIndexOf("/") + 1); } + const proxyColItemsMemo = useMemo(() => { + if (type !== 4 || !proxyCol) { + return null; + } + + return proxyCol.map((proxyItem) => ( + onChangeProxy(group, proxyItem!)} + /> + )); + }, [type, proxyCol, item.key, group, headState, onChangeProxy]); + if (type === 0) { return ( { }} /> - + { } if (type === 4) { - const proxyColItemsMemo = useMemo(() => { - return proxyCol?.map((proxy) => ( - onChangeProxy(group, proxy!)} - /> - )); - }, [proxyCol, group, headState]); return ( count + 1, 0); useEffect(() => { let last = 0; @@ -21,7 +23,7 @@ export default function useFilterSort( const now = Date.now(); if (now - last > 666) { last = now; - setRefresh({}); + bumpRefresh(); } }); @@ -32,9 +34,20 @@ export default function useFilterSort( return useMemo(() => { const fp = filterProxies(proxies, groupName, filterText); - const sp = sortProxies(fp, groupName, sortType); + const sp = sortProxies( + fp, + groupName, + sortType, + verge?.default_latency_timeout, + ); return sp; - }, [proxies, groupName, filterText, sortType]); + }, [ + proxies, + groupName, + filterText, + sortType, + verge?.default_latency_timeout, + ]); } export function filterSort( @@ -42,9 +55,10 @@ export function filterSort( groupName: string, filterText: string, sortType: ProxySortType, + latencyTimeout?: number, ) { const fp = filterProxies(proxies, groupName, filterText); - const sp = sortProxies(fp, groupName, sortType); + const sp = sortProxies(fp, groupName, sortType, latencyTimeout); return sp; } @@ -102,21 +116,39 @@ function sortProxies( proxies: IProxyItem[], groupName: string, sortType: ProxySortType, + latencyTimeout?: number, ) { if (!proxies) return []; if (sortType === 0) return proxies; const list = proxies.slice(); + const effectiveTimeout = + typeof latencyTimeout === "number" && latencyTimeout > 0 + ? latencyTimeout + : 10000; if (sortType === 1) { + const categorizeDelay = (delay: number): [number, number] => { + if (!Number.isFinite(delay)) return [3, Number.MAX_SAFE_INTEGER]; + if (delay > 1e5) return [4, delay]; + if (delay === 0 || (delay >= effectiveTimeout && delay <= 1e5)) { + return [3, delay || effectiveTimeout]; + } + if (delay < 0) { + // sentinel delays (-1, -2, etc.) should always sort after real measurements + return [5, Number.MAX_SAFE_INTEGER]; + } + return [0, delay]; + }; + list.sort((a, b) => { const ad = delayManager.getDelayFix(a, groupName); const bd = delayManager.getDelayFix(b, groupName); + const [ar, av] = categorizeDelay(ad); + const [br, bv] = categorizeDelay(bd); - if (ad === -1 || ad === -2) return 1; - if (bd === -1 || bd === -2) return -1; - - return ad - bd; + if (ar !== br) return ar - br; + return av - bv; }); } else { list.sort((a, b) => a.name.localeCompare(b.name)); diff --git a/clash-verge-rev/src/components/proxy/use-head-state.ts b/clash-verge-rev/src/components/proxy/use-head-state.ts index 427bdff801..593290f7b5 100644 --- a/clash-verge-rev/src/components/proxy/use-head-state.ts +++ b/clash-verge-rev/src/components/proxy/use-head-state.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useReducer } from "react"; import { useProfiles } from "@/hooks/use-profiles"; @@ -25,15 +25,38 @@ export const DEFAULT_STATE: HeadState = { testUrl: "", }; +type HeadStateAction = + | { type: "reset" } + | { type: "replace"; payload: Record } + | { type: "update"; groupName: string; patch: Partial }; + +function headStateReducer( + state: Record, + action: HeadStateAction, +): Record { + switch (action.type) { + case "reset": + return {}; + case "replace": + return action.payload; + case "update": { + const prev = state[action.groupName] || DEFAULT_STATE; + return { ...state, [action.groupName]: { ...prev, ...action.patch } }; + } + default: + return state; + } +} + export function useHeadStateNew() { const { profiles } = useProfiles(); const current = profiles?.current || ""; - const [state, setState] = useState>({}); + const [state, dispatch] = useReducer(headStateReducer, {}); useEffect(() => { if (!current) { - setState({}); + dispatch({ type: "reset" }); return; } @@ -45,36 +68,39 @@ export function useHeadStateNew() { const value = data[current] || {}; if (value && typeof value === "object") { - setState(value); + dispatch({ type: "replace", payload: value }); } else { - setState({}); + dispatch({ type: "reset" }); } - } catch {} + } catch { + dispatch({ type: "reset" }); + } }, [current]); + useEffect(() => { + if (!current) return; + + const timer = setTimeout(() => { + try { + const item = localStorage.getItem(HEAD_STATE_KEY); + + let data = (item ? JSON.parse(item) : {}) as HeadStateStorage; + + if (!data || typeof data !== "object") data = {}; + + data[current] = state; + + localStorage.setItem(HEAD_STATE_KEY, JSON.stringify(data)); + } catch {} + }); + + return () => clearTimeout(timer); + }, [state, current]); + const setHeadState = useCallback( (groupName: string, obj: Partial) => { - setState((old) => { - const state = old[groupName] || DEFAULT_STATE; - const ret = { ...old, [groupName]: { ...state, ...obj } }; - - // 保存到存储中 - setTimeout(() => { - try { - const item = localStorage.getItem(HEAD_STATE_KEY); - - let data = (item ? JSON.parse(item) : {}) as HeadStateStorage; - - if (!data || typeof data !== "object") data = {}; - - data[current] = ret; - - localStorage.setItem(HEAD_STATE_KEY, JSON.stringify(data)); - } catch {} - }); - - return ret; - }); + if (!current) return; + dispatch({ type: "update", groupName, patch: obj }); }, [current], ); diff --git a/clash-verge-rev/src/components/proxy/use-render-list.ts b/clash-verge-rev/src/components/proxy/use-render-list.ts index 172d291569..7a5949ae3b 100644 --- a/clash-verge-rev/src/components/proxy/use-render-list.ts +++ b/clash-verge-rev/src/components/proxy/use-render-list.ts @@ -103,6 +103,7 @@ export const useRenderList = ( const { verge } = useVerge(); const { width } = useWindowWidth(); const [headStates, setHeadState] = useHeadStateNew(); + const latencyTimeout = verge?.default_latency_timeout; // 获取运行时配置用于链式代理模式 const { data: runtimeConfig } = useSWR( @@ -197,7 +198,13 @@ export const useRenderList = ( (g: any) => g.name === selectedGroup, ); if (targetGroup) { - const proxies = filterSort(targetGroup.all, targetGroup.name, "", 0); + const proxies = filterSort( + targetGroup.all, + targetGroup.name, + "", + 0, + latencyTimeout, + ); if (col > 1) { return groupProxies(proxies, col).map((proxyCol, colIndex) => ({ @@ -226,7 +233,13 @@ export const useRenderList = ( // 如果没有选择特定组,显示第一个组的节点(如果有组的话) if (allGroups.length > 0) { const firstGroup = allGroups[0]; - const proxies = filterSort(firstGroup.all, firstGroup.name, "", 0); + const proxies = filterSort( + firstGroup.all, + firstGroup.name, + "", + 0, + latencyTimeout, + ); if (col > 1) { return groupProxies(proxies, col).map((proxyCol, colIndex) => ({ @@ -391,6 +404,7 @@ export const useRenderList = ( group.name, headState.filterText, headState.sortType, + latencyTimeout, ); ret.push({ @@ -445,6 +459,7 @@ export const useRenderList = ( isChainMode, runtimeConfig, selectedGroup, + latencyTimeout, ]); return { diff --git a/clash-verge-rev/src/components/rule/provider-button.tsx b/clash-verge-rev/src/components/rule/provider-button.tsx index f1bd262e25..367fa3d6ff 100644 --- a/clash-verge-rev/src/components/rule/provider-button.tsx +++ b/clash-verge-rev/src/components/rule/provider-button.tsx @@ -58,12 +58,17 @@ export const ProviderButton = () => { await refreshRules(); await refreshRuleProviders(); - showNotice("success", `${name} 更新成功`); - } catch (err: any) { - showNotice( - "error", - `${name} 更新失败: ${err?.message || err.toString()}`, + showNotice.success( + "rules.feedback.notifications.provider.updateSuccess", + { + name, + }, ); + } catch (err) { + showNotice.error("rules.feedback.notifications.provider.updateFailed", { + name, + message: String(err), + }); } finally { // 清除更新状态 setUpdating((prev) => ({ ...prev, [name]: false })); @@ -76,7 +81,7 @@ export const ProviderButton = () => { // 获取所有provider的名称 const allProviders = Object.keys(ruleProviders || {}); if (allProviders.length === 0) { - showNotice("info", "没有可更新的规则提供者"); + showNotice.info("rules.feedback.notifications.provider.none"); return; } @@ -106,9 +111,11 @@ export const ProviderButton = () => { await refreshRules(); await refreshRuleProviders(); - showNotice("success", "全部规则提供者更新成功"); - } catch (err: any) { - showNotice("error", `更新失败: ${err?.message || err.toString()}`); + showNotice.success("rules.feedback.notifications.provider.allUpdated"); + } catch (err) { + showNotice.error("rules.feedback.notifications.provider.genericError", { + message: String(err), + }); } finally { // 清除所有更新状态 setUpdating({}); @@ -129,7 +136,7 @@ export const ProviderButton = () => { startIcon={} onClick={() => setOpen(true)} > - {t("Rule Provider")} + {t("rules.page.provider.trigger")} @@ -139,13 +146,15 @@ export const ProviderButton = () => { justifyContent="space-between" alignItems="center" > - {t("Rule Providers")} + + {t("rules.page.provider.dialogTitle")} + @@ -216,7 +225,7 @@ export const ProviderButton = () => { color="text.secondary" noWrap > - {t("Update At")}: + {t("shared.labels.updateAt")}: {time.fromNow()} @@ -246,6 +255,7 @@ export const ProviderButton = () => { color="primary" onClick={() => updateProvider(key)} disabled={isUpdating} + aria-label={t("rules.page.provider.actions.update")} sx={{ animation: isUpdating ? "spin 1s linear infinite" @@ -255,7 +265,7 @@ export const ProviderButton = () => { "100%": { transform: "rotate(360deg)" }, }, }} - title={t("Update Provider") as string} + title={t("rules.page.provider.actions.update")} > @@ -268,7 +278,7 @@ export const ProviderButton = () => { diff --git a/clash-verge-rev/src/components/setting/mods/auto-backup-settings.tsx b/clash-verge-rev/src/components/setting/mods/auto-backup-settings.tsx new file mode 100644 index 0000000000..c9736ec7a2 --- /dev/null +++ b/clash-verge-rev/src/components/setting/mods/auto-backup-settings.tsx @@ -0,0 +1,205 @@ +import { + InputAdornment, + ListItem, + ListItemText, + Stack, + TextField, +} from "@mui/material"; +import { useLockFn } from "ahooks"; +import { Fragment, useMemo, useState, type ChangeEvent } from "react"; +import { useTranslation } from "react-i18next"; + +import { Switch } from "@/components/base"; +import { useVerge } from "@/hooks/use-verge"; +import { showNotice } from "@/services/noticeService"; + +const MIN_INTERVAL_HOURS = 1; +const MAX_INTERVAL_HOURS = 168; + +interface AutoBackupState { + scheduleEnabled: boolean; + intervalHours: number; + changeEnabled: boolean; +} + +export function AutoBackupSettings() { + const { t } = useTranslation(); + const { verge, patchVerge } = useVerge(); + const derivedValues = useMemo(() => { + return { + scheduleEnabled: verge?.enable_auto_backup_schedule ?? false, + intervalHours: verge?.auto_backup_interval_hours ?? 24, + changeEnabled: verge?.auto_backup_on_change ?? true, + }; + }, [ + verge?.enable_auto_backup_schedule, + verge?.auto_backup_interval_hours, + verge?.auto_backup_on_change, + ]); + const [pendingValues, setPendingValues] = useState( + null, + ); + const values = useMemo(() => { + if (!pendingValues) { + return derivedValues; + } + if ( + pendingValues.scheduleEnabled === derivedValues.scheduleEnabled && + pendingValues.intervalHours === derivedValues.intervalHours && + pendingValues.changeEnabled === derivedValues.changeEnabled + ) { + return derivedValues; + } + return pendingValues; + }, [pendingValues, derivedValues]); + const [intervalInputDraft, setIntervalInputDraft] = useState( + null, + ); + + const applyPatch = useLockFn( + async ( + partial: Partial, + payload: Partial, + ) => { + const nextValues = { ...values, ...partial }; + setPendingValues(nextValues); + try { + await patchVerge(payload); + } catch (error) { + showNotice.error(error); + setPendingValues(null); + } + }, + ); + + const disabled = !verge; + + const handleScheduleToggle = ( + _: ChangeEvent, + checked: boolean, + ) => { + applyPatch( + { scheduleEnabled: checked }, + { + enable_auto_backup_schedule: checked, + auto_backup_interval_hours: values.intervalHours, + }, + ); + }; + + const handleChangeToggle = ( + _: ChangeEvent, + checked: boolean, + ) => { + applyPatch({ changeEnabled: checked }, { auto_backup_on_change: checked }); + }; + + const handleIntervalInputChange = (event: ChangeEvent) => { + setIntervalInputDraft(event.target.value); + }; + + const commitIntervalInput = () => { + const rawValue = intervalInputDraft ?? values.intervalHours.toString(); + const trimmed = rawValue.trim(); + if (trimmed === "") { + setIntervalInputDraft(null); + return; + } + + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + setIntervalInputDraft(null); + return; + } + + const clamped = Math.min( + MAX_INTERVAL_HOURS, + Math.max(MIN_INTERVAL_HOURS, Math.round(parsed)), + ); + + if (clamped === values.intervalHours) { + setIntervalInputDraft(null); + return; + } + + applyPatch( + { intervalHours: clamped }, + { auto_backup_interval_hours: clamped }, + ); + setIntervalInputDraft(null); + }; + + const scheduleDisabled = disabled || !values.scheduleEnabled; + + return ( + + + + + + + + + + + + { + if (event.key === "Enter") { + event.preventDefault(); + commitIntervalInput(); + } + }} + sx={{ minWidth: 160 }} + slotProps={{ + input: { + endAdornment: ( + + {t("shared.units.hours")} + + ), + }, + htmlInput: { + min: MIN_INTERVAL_HOURS, + max: MAX_INTERVAL_HOURS, + inputMode: "numeric", + }, + }} + /> + + + + + + + + + + + ); +} diff --git a/clash-verge-rev/src/components/setting/mods/backup-config-viewer.tsx b/clash-verge-rev/src/components/setting/mods/backup-config-viewer.tsx index 6821e50d82..21ce89c143 100644 --- a/clash-verge-rev/src/components/setting/mods/backup-config-viewer.tsx +++ b/clash-verge-rev/src/components/setting/mods/backup-config-viewer.tsx @@ -58,23 +58,16 @@ export const BackupConfigViewer = memo( webdav_username !== username || webdav_password !== password; - console.log( - "webdavChanged", - webdavChanged, - webdav_url, - webdav_username, - webdav_password, - ); - const handleClickShowPassword = () => { setShowPassword((prev) => !prev); }; useEffect(() => { - if (webdav_url && webdav_username && webdav_password) { - onInit(); + if (!webdav_url || !webdav_username || !webdav_password) { + return; } - }, []); + void onInit(); + }, [webdav_url, webdav_username, webdav_password, onInit]); const checkForm = () => { const username = usernameRef.current?.value; @@ -83,22 +76,22 @@ export const BackupConfigViewer = memo( if (!url) { urlRef.current?.focus(); - showNotice("error", t("WebDAV URL Required")); - throw new Error(t("WebDAV URL Required")); + showNotice.error("settings.modals.backup.messages.webdavUrlRequired"); + throw new Error(t("settings.modals.backup.messages.webdavUrlRequired")); } else if (!isValidUrl(url)) { urlRef.current?.focus(); - showNotice("error", t("Invalid WebDAV URL")); - throw new Error(t("Invalid WebDAV URL")); + showNotice.error("settings.modals.backup.messages.invalidWebdavUrl"); + throw new Error(t("settings.modals.backup.messages.invalidWebdavUrl")); } if (!username) { usernameRef.current?.focus(); - showNotice("error", t("WebDAV URL Required")); - throw new Error(t("Username Required")); + showNotice.error("settings.modals.backup.messages.usernameRequired"); + throw new Error(t("settings.modals.backup.messages.usernameRequired")); } if (!password) { passwordRef.current?.focus(); - showNotice("error", t("WebDAV URL Required")); - throw new Error(t("Password Required")); + showNotice.error("settings.modals.backup.messages.passwordRequired"); + throw new Error(t("settings.modals.backup.messages.passwordRequired")); } }; @@ -111,11 +104,17 @@ export const BackupConfigViewer = memo( data.username.trim(), data.password, ).then(() => { - showNotice("success", t("WebDAV Config Saved")); + showNotice.success( + "settings.modals.backup.messages.webdavConfigSaved", + ); onSaveSuccess(); }); } catch (error) { - showNotice("error", t("WebDAV Config Save Failed", { error }), 3000); + showNotice.error( + "settings.modals.backup.messages.webdavConfigSaveFailed", + { error }, + 3000, + ); } finally { setLoading(false); } @@ -126,11 +125,13 @@ export const BackupConfigViewer = memo( try { setLoading(true); await createWebdavBackup().then(async () => { - showNotice("success", t("Backup Created")); + showNotice.success("settings.modals.backup.messages.backupCreated"); await onBackupSuccess(); }); } catch (error) { - showNotice("error", t("Backup Failed", { error })); + showNotice.error("settings.modals.backup.messages.backupFailed", { + error, + }); } finally { setLoading(false); } @@ -144,7 +145,7 @@ export const BackupConfigViewer = memo( - {t("Save")} + {t("shared.actions.save")} ) : ( <> @@ -224,7 +225,7 @@ export const BackupConfigViewer = memo( type="button" size="large" > - {t("Backup")} + {t("settings.modals.backup.actions.backup")} )} diff --git a/clash-verge-rev/src/components/setting/mods/backup-history-viewer.tsx b/clash-verge-rev/src/components/setting/mods/backup-history-viewer.tsx new file mode 100644 index 0000000000..4a63fc7434 --- /dev/null +++ b/clash-verge-rev/src/components/setting/mods/backup-history-viewer.tsx @@ -0,0 +1,344 @@ +import DeleteOutline from "@mui/icons-material/DeleteOutline"; +import DownloadRounded from "@mui/icons-material/DownloadRounded"; +import RefreshRounded from "@mui/icons-material/RefreshRounded"; +import RestoreRounded from "@mui/icons-material/RestoreRounded"; +import { + Box, + Button, + IconButton, + List, + ListItem, + ListItemText, + ListSubheader, + Stack, + Tab, + Tabs, + Typography, +} from "@mui/material"; +import { save } from "@tauri-apps/plugin-dialog"; +import { useLockFn } from "ahooks"; +import dayjs from "dayjs"; +import customParseFormat from "dayjs/plugin/customParseFormat"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { BaseDialog, BaseLoadingOverlay } from "@/components/base"; +import { useVerge } from "@/hooks/use-verge"; +import { + deleteLocalBackup, + deleteWebdavBackup, + exportLocalBackup, + listLocalBackup, + listWebDavBackup, + restartApp, + restoreLocalBackup, + restoreWebDavBackup, +} from "@/services/cmds"; +import { showNotice } from "@/services/noticeService"; + +dayjs.extend(customParseFormat); +dayjs.extend(relativeTime); + +const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss"; +const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/; + +type BackupSource = "local" | "webdav"; + +interface BackupHistoryViewerProps { + open: boolean; + source: BackupSource; + page: number; + onSourceChange: (source: BackupSource) => void; + onPageChange: (page: number) => void; + onClose: () => void; +} + +interface BackupRow { + filename: string; + platform: string; + backup_time: dayjs.Dayjs; +} + +const confirmAsync = async (message: string) => { + const fn = window.confirm as (msg?: string) => boolean; + return fn(message); +}; + +export const BackupHistoryViewer = ({ + open, + source, + page, + onSourceChange, + onPageChange, + onClose, +}: BackupHistoryViewerProps) => { + const { t } = useTranslation(); + const { verge } = useVerge(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [isRestarting, setIsRestarting] = useState(false); + const isLocal = source === "local"; + const isWebDavConfigured = Boolean( + verge?.webdav_url && verge?.webdav_username && verge?.webdav_password, + ); + const shouldSkipWebDav = !isLocal && !isWebDavConfigured; + const pageSize = 8; + const isBusy = loading || isRestarting; + + const buildRow = useCallback((filename: string): BackupRow | null => { + const platform = filename.split("-")[0]; + const match = filename.match(FILENAME_PATTERN); + if (!match) return null; + return { + filename, + platform, + backup_time: dayjs(match[0], DATE_FORMAT), + }; + }, []); + + const fetchRows = useCallback(async () => { + if (!open) return; + if (shouldSkipWebDav) { + setRows([]); + return; + } + setLoading(true); + try { + const list = isLocal ? await listLocalBackup() : await listWebDavBackup(); + setRows( + list + .map((item) => buildRow(item.filename)) + .filter((item): item is BackupRow => item !== null) + .sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1)), + ); + } catch (error) { + console.error(error); + setRows([]); + showNotice.error(error); + } finally { + setLoading(false); + } + }, [buildRow, isLocal, open, shouldSkipWebDav]); + + useEffect(() => { + void fetchRows(); + }, [fetchRows]); + + const total = rows.length; + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const currentPage = Math.min(page, pageCount - 1); + const pagedRows = rows.slice( + currentPage * pageSize, + currentPage * pageSize + pageSize, + ); + + const summary = useMemo(() => { + if (shouldSkipWebDav) { + return t("settings.modals.backup.manual.webdav"); + } + if (!total) return t("settings.modals.backup.history.empty"); + const recent = rows[0]?.backup_time.fromNow(); + return t("settings.modals.backup.history.summary", { + count: total, + recent, + }); + }, [rows, shouldSkipWebDav, t, total]); + + const handleDelete = useLockFn(async (filename: string) => { + if (isRestarting) return; + if ( + !(await confirmAsync(t("settings.modals.backup.messages.confirmDelete"))) + ) + return; + if (isLocal) { + await deleteLocalBackup(filename); + } else { + await deleteWebdavBackup(filename); + } + await fetchRows(); + }); + + const handleRestore = useLockFn(async (filename: string) => { + if (isRestarting) return; + if ( + !(await confirmAsync(t("settings.modals.backup.messages.confirmRestore"))) + ) + return; + if (isLocal) { + await restoreLocalBackup(filename); + } else { + await restoreWebDavBackup(filename); + } + showNotice.success("settings.modals.backup.messages.restoreSuccess"); + setIsRestarting(true); + window.setTimeout(() => { + void restartApp().catch((err: unknown) => { + setIsRestarting(false); + showNotice.error(err); + }); + }, 1000); + }); + + const handleExport = useLockFn(async (filename: string) => { + if (isRestarting) return; + if (!isLocal) return; + const savePath = await save({ defaultPath: filename }); + if (!savePath || Array.isArray(savePath)) return; + try { + await exportLocalBackup(filename, savePath); + showNotice.success("settings.modals.backup.messages.localBackupExported"); + } catch (ignoreError: unknown) { + showNotice.error( + "settings.modals.backup.messages.localBackupExportFailed", + ); + } + }); + + const handleRefresh = () => { + if (isRestarting) return; + void fetchRows(); + }; + + return ( + + + + + + { + if (isBusy) return; + onSourceChange(val as BackupSource); + onPageChange(0); + }} + textColor="primary" + indicatorColor="primary" + > + + + + + + + + + {summary} + + + + {t("settings.modals.backup.history.title")} + + } + > + {pagedRows.length === 0 ? ( + + + + ) : ( + pagedRows.map((row) => ( + + {isLocal && ( + handleExport(row.filename)} + > + + + )} + handleDelete(row.filename)} + > + + + handleRestore(row.filename)} + > + + + + } + > + + + )) + )} + + + {pageCount > 1 && ( + + + {currentPage + 1} / {pageCount} + + + + + + + )} + + + + ); +}; diff --git a/clash-verge-rev/src/components/setting/mods/backup-table-viewer.tsx b/clash-verge-rev/src/components/setting/mods/backup-table-viewer.tsx deleted file mode 100644 index e68da158c9..0000000000 --- a/clash-verge-rev/src/components/setting/mods/backup-table-viewer.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import DeleteIcon from "@mui/icons-material/Delete"; -import RestoreIcon from "@mui/icons-material/Restore"; -import { - Box, - Paper, - IconButton, - Divider, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - TablePagination, -} from "@mui/material"; -import { Typography } from "@mui/material"; -import { useLockFn } from "ahooks"; -import { Dayjs } from "dayjs"; -import { SVGProps, memo } from "react"; -import { useTranslation } from "react-i18next"; - -import { - deleteWebdavBackup, - restoreWebDavBackup, - restartApp, -} from "@/services/cmds"; -import { showNotice } from "@/services/noticeService"; - -export type BackupFile = IWebDavFile & { - platform: string; - backup_time: Dayjs; - allow_apply: boolean; -}; - -export const DEFAULT_ROWS_PER_PAGE = 5; - -interface BackupTableViewerProps { - datasource: BackupFile[]; - page: number; - onPageChange: ( - event: React.MouseEvent | null, - page: number, - ) => void; - total: number; - onRefresh: () => Promise; -} - -export const BackupTableViewer = memo( - ({ - datasource, - page, - onPageChange, - total, - onRefresh, - }: BackupTableViewerProps) => { - const { t } = useTranslation(); - - const handleDelete = useLockFn(async (filename: string) => { - await deleteWebdavBackup(filename); - await onRefresh(); - }); - - const handleRestore = useLockFn(async (filename: string) => { - await restoreWebDavBackup(filename).then(() => { - showNotice("success", t("Restore Success, App will restart in 1s")); - }); - await restartApp(); - }); - - return ( - - - - - {t("Filename")} - {t("Backup Time")} - {t("Actions")} - - - - {datasource.length > 0 ? ( - datasource?.map((file, index) => ( - - - {file.platform === "windows" ? ( - - ) : file.platform === "linux" ? ( - - ) : ( - - )} - {file.filename} - - - {file.backup_time.fromNow()} - - - - { - e.preventDefault(); - const confirmed = await window.confirm( - t("Confirm to delete this backup file?"), - ); - if (confirmed) { - await handleDelete(file.filename); - } - }} - > - - - - { - e.preventDefault(); - const confirmed = await window.confirm( - t("Confirm to restore this backup file?"), - ); - if (confirmed) { - await handleRestore(file.filename); - } - }} - > - - - - - - )) - ) : ( - - - - - {t("No Backups")} - - - - - )} - -
    - -
    - ); - }, -); - -function LinuxIcon(props: SVGProps) { - return ( - - - - - - - - - - - - - - - - ); -} - -function WindowsIcon(props: SVGProps) { - return ( - - - - ); -} - -function MacIcon(props: SVGProps) { - return ( - - - - ); -} diff --git a/clash-verge-rev/src/components/setting/mods/backup-viewer.tsx b/clash-verge-rev/src/components/setting/mods/backup-viewer.tsx index ac24081f9d..b9b62b76ce 100644 --- a/clash-verge-rev/src/components/setting/mods/backup-viewer.tsx +++ b/clash-verge-rev/src/components/setting/mods/backup-viewer.tsx @@ -1,128 +1,213 @@ -import { Box, Divider, Paper } from "@mui/material"; -import dayjs from "dayjs"; -import customParseFormat from "dayjs/plugin/customParseFormat"; -import type { Ref } from "react"; -import { useCallback, useImperativeHandle, useMemo, useState } from "react"; +import { LoadingButton } from "@mui/lab"; +import { + Button, + List, + ListItem, + ListItemText, + Stack, + Typography, +} from "@mui/material"; +import { useLockFn } from "ahooks"; +import type { ReactNode, Ref } from "react"; +import { useImperativeHandle, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BaseDialog, BaseLoadingOverlay, DialogRef } from "@/components/base"; -import { listWebDavBackup } from "@/services/cmds"; +import { BaseDialog, DialogRef } from "@/components/base"; +import { createLocalBackup, createWebdavBackup } from "@/services/cmds"; +import { showNotice } from "@/services/noticeService"; -import { BackupConfigViewer } from "./backup-config-viewer"; -import { - BackupFile, - BackupTableViewer, - DEFAULT_ROWS_PER_PAGE, -} from "./backup-table-viewer"; -dayjs.extend(customParseFormat); +import { AutoBackupSettings } from "./auto-backup-settings"; +import { BackupHistoryViewer } from "./backup-history-viewer"; +import { BackupWebdavDialog } from "./backup-webdav-dialog"; -const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss"; -const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/; +type BackupSource = "local" | "webdav"; export function BackupViewer({ ref }: { ref?: Ref }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); - - const [isLoading, setIsLoading] = useState(false); - const [backupFiles, setBackupFiles] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(0); + const [busyAction, setBusyAction] = useState(null); + const [historyOpen, setHistoryOpen] = useState(false); + const [historySource, setHistorySource] = useState("local"); + const [historyPage, setHistoryPage] = useState(0); + const [webdavDialogOpen, setWebdavDialogOpen] = useState(false); useImperativeHandle(ref, () => ({ - open: () => { - setOpen(true); - }, + open: () => setOpen(true), close: () => setOpen(false), })); - // Handle page change - const handleChangePage = useCallback( - (_: React.MouseEvent | null, page: number) => { - setPage(page); - }, - [], - ); + const openHistory = (target: BackupSource) => { + setHistorySource(target); + setHistoryPage(0); + setHistoryOpen(true); + }; - const fetchAndSetBackupFiles = async () => { + const handleBackup = useLockFn(async (target: BackupSource) => { try { - setIsLoading(true); - const files = await getAllBackupFiles(); - setBackupFiles(files); - setTotal(files.length); + setBusyAction(target); + if (target === "local") { + await createLocalBackup(); + showNotice.success( + "settings.modals.backup.messages.localBackupCreated", + ); + } else { + await createWebdavBackup(); + showNotice.success("settings.modals.backup.messages.backupCreated"); + } } catch (error) { - setBackupFiles([]); - setTotal(0); console.error(error); - // Notice.error(t("Failed to fetch backup files")); + showNotice.error( + target === "local" + ? "settings.modals.backup.messages.localBackupFailed" + : "settings.modals.backup.messages.backupFailed", + target === "local" ? undefined : { error }, + ); } finally { - setIsLoading(false); + setBusyAction(null); } - }; - - const getAllBackupFiles = async () => { - const files = await listWebDavBackup(); - return files - .map((file) => { - const platform = file.filename.split("-")[0]; - const fileBackupTimeStr = file.filename.match(FILENAME_PATTERN)!; - - if (fileBackupTimeStr === null) { - return null; - } - - const backupTime = dayjs(fileBackupTimeStr[0], DATE_FORMAT); - const allowApply = true; - return { - ...file, - platform, - backup_time: backupTime, - allow_apply: allowApply, - } as BackupFile; - }) - .filter((item) => item !== null) - .sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1)); - }; - - const dataSource = useMemo( - () => - backupFiles.slice( - page * DEFAULT_ROWS_PER_PAGE, - page * DEFAULT_ROWS_PER_PAGE + DEFAULT_ROWS_PER_PAGE, - ), - [backupFiles, page], - ); + }); return ( setOpen(false)} - onCancel={() => setOpen(false)} + title={t("settings.modals.backup.title")} + contentSx={{ width: { xs: 360, sm: 520 } }} disableOk + cancelBtn={t("shared.actions.close")} + onCancel={() => setOpen(false)} + onClose={() => setOpen(false)} > - - - - - - - - + + `1px solid ${theme.palette.divider}`, + borderRadius: 2, + p: 2, + }} + > + + {t("settings.modals.backup.auto.title")} + + + + + + + `1px solid ${theme.palette.divider}`, + borderRadius: 2, + p: 2, + }} + > + + {t("settings.modals.backup.manual.title")} + + + {( + [ + { + key: "local" as BackupSource, + title: t("settings.modals.backup.tabs.local"), + description: t("settings.modals.backup.manual.local"), + actions: [ + handleBackup("local")} + > + {t("settings.modals.backup.actions.backup")} + , + , + ], + }, + { + key: "webdav" as BackupSource, + title: t("settings.modals.backup.tabs.webdav"), + description: t("settings.modals.backup.manual.webdav"), + actions: [ + handleBackup("webdav")} + > + {t("settings.modals.backup.actions.backup")} + , + , + , + ], + }, + ] satisfies Array<{ + key: BackupSource; + title: string; + description: string; + actions: ReactNode[]; + }> + ).map((item, idx) => ( + + + + + {item.actions} + + + + ))} + + + + + setHistoryOpen(false)} + /> + setWebdavDialogOpen(false)} + onBackupSuccess={() => openHistory("webdav")} + setBusy={(loading) => setBusyAction(loading ? "webdav" : null)} + /> ); } diff --git a/clash-verge-rev/src/components/setting/mods/backup-webdav-dialog.tsx b/clash-verge-rev/src/components/setting/mods/backup-webdav-dialog.tsx new file mode 100644 index 0000000000..8ad0e17a01 --- /dev/null +++ b/clash-verge-rev/src/components/setting/mods/backup-webdav-dialog.tsx @@ -0,0 +1,87 @@ +import { Box } from "@mui/material"; +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { BaseDialog, BaseLoadingOverlay } from "@/components/base"; +import { listWebDavBackup } from "@/services/cmds"; +import { showNotice } from "@/services/noticeService"; + +import { BackupConfigViewer } from "./backup-config-viewer"; + +interface BackupWebdavDialogProps { + open: boolean; + onClose: () => void; + onBackupSuccess?: () => void; + setBusy?: (loading: boolean) => void; +} + +export const BackupWebdavDialog = ({ + open, + onClose, + onBackupSuccess, + setBusy, +}: BackupWebdavDialogProps) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + + const handleLoading = useCallback( + (value: boolean) => { + setLoading(value); + setBusy?.(value); + }, + [setBusy], + ); + + const refreshWebdav = useCallback( + async (options?: { silent?: boolean }) => { + handleLoading(true); + try { + await listWebDavBackup(); + if (!options?.silent) { + showNotice.success( + "settings.modals.backup.messages.webdavRefreshSuccess", + ); + } + } catch (error) { + showNotice.error( + "settings.modals.backup.messages.webdavRefreshFailed", + { error }, + ); + } finally { + handleLoading(false); + } + }, + [handleLoading], + ); + + const refreshSilently = useCallback( + () => refreshWebdav({ silent: true }), + [refreshWebdav], + ); + + return ( + + + + { + await refreshSilently(); + onBackupSuccess?.(); + }} + onSaveSuccess={refreshSilently} + onRefresh={refreshWebdav} + onInit={refreshSilently} + /> + + + ); +}; diff --git a/clash-verge-rev/src/components/setting/mods/clash-core-viewer.tsx b/clash-verge-rev/src/components/setting/mods/clash-core-viewer.tsx index be951de6c3..614617788b 100644 --- a/clash-verge-rev/src/components/setting/mods/clash-core-viewer.tsx +++ b/clash-verge-rev/src/components/setting/mods/clash-core-viewer.tsx @@ -24,8 +24,16 @@ import { changeClashCore, restartCore } from "@/services/cmds"; import { showNotice } from "@/services/noticeService"; const VALID_CORE = [ - { name: "Mihomo", core: "verge-mihomo", chip: "Release Version" }, - { name: "Mihomo Alpha", core: "verge-mihomo-alpha", chip: "Alpha Version" }, + { + name: "Mihomo", + core: "verge-mihomo", + chipKey: "settings.modals.clashCore.variants.release", + }, + { + name: "Mihomo Alpha", + core: "verge-mihomo-alpha", + chipKey: "settings.modals.clashCore.variants.alpha", + }, ]; export function ClashCoreViewer({ ref }: { ref?: Ref }) { @@ -54,7 +62,7 @@ export function ClashCoreViewer({ ref }: { ref?: Ref }) { const errorMsg = await changeClashCore(core); if (errorMsg) { - showNotice("error", errorMsg); + showNotice.error(errorMsg); setChangingCore(null); return; } @@ -65,9 +73,9 @@ export function ClashCoreViewer({ ref }: { ref?: Ref }) { mutate("getVersion"); setChangingCore(null); }, 500); - } catch (err: any) { + } catch (err) { setChangingCore(null); - showNotice("error", err.message || err.toString()); + showNotice.error(err); } }); @@ -75,11 +83,13 @@ export function ClashCoreViewer({ ref }: { ref?: Ref }) { try { setRestarting(true); await restartCore(); - showNotice("success", t(`Clash Core Restarted`)); + showNotice.success( + t("settings.feedback.notifications.clash.restartSuccess"), + ); setRestarting(false); - } catch (err: any) { + } catch (err) { setRestarting(false); - showNotice("error", err.message || err.toString()); + showNotice.error(err); } }); @@ -88,14 +98,16 @@ export function ClashCoreViewer({ ref }: { ref?: Ref }) { setUpgrading(true); await upgradeCore(); setUpgrading(false); - showNotice("success", t(`Core Version Updated`)); + showNotice.success( + t("settings.feedback.notifications.clash.versionUpdated"), + ); } catch (err: any) { setUpgrading(false); - const errMsg = err.response?.data?.message || err.toString(); + const errMsg = err?.response?.data?.message ?? String(err); const showMsg = errMsg.includes("already using latest version") - ? "Already Using Latest Core Version" + ? t("settings.feedback.notifications.clash.alreadyLatestVersion") : errMsg; - showNotice("error", t(showMsg)); + showNotice.info(showMsg); } }); @@ -104,7 +116,7 @@ export function ClashCoreViewer({ ref }: { ref?: Ref }) { open={open} title={ - {t("Clash Core")} + {t("settings.sections.clash.form.fields.clashCore")} }) { sx={{ marginRight: "8px" }} onClick={onUpgrade} > - {t("Upgrade")} + {t("shared.actions.upgrade")} }) { disabled={upgrading} onClick={onRestart} > - {t("Restart")} + {t("shared.actions.restart")} @@ -141,7 +153,7 @@ export function ClashCoreViewer({ ref }: { ref?: Ref }) { marginTop: "-8px", }} disableOk - cancelBtn={t("Close")} + cancelBtn={t("shared.actions.close")} onClose={() => setOpen(false)} onCancel={() => setOpen(false)} > @@ -157,7 +169,7 @@ export function ClashCoreViewer({ ref }: { ref?: Ref }) { {changingCore === each.core ? ( ) : ( - + )} ))} diff --git a/clash-verge-rev/src/components/setting/mods/clash-port-viewer.tsx b/clash-verge-rev/src/components/setting/mods/clash-port-viewer.tsx index 5f0db1bae3..b0ed403fa5 100644 --- a/clash-verge-rev/src/components/setting/mods/clash-port-viewer.tsx +++ b/clash-verge-rev/src/components/setting/mods/clash-port-viewer.tsx @@ -69,10 +69,13 @@ export const ClashPortViewer = forwardRef((_, ref) => { manual: true, onSuccess: () => { setOpen(false); - showNotice("success", t("Port settings saved")); + showNotice.success("settings.modals.clashPort.messages.saved"); }, - onError: () => { - showNotice("error", t("Failed to save port settings")); + onError: (error) => { + showNotice.error( + "settings.modals.clashPort.messages.saveFailed", + error, + ); }, }, ); @@ -149,7 +152,7 @@ export const ClashPortViewer = forwardRef((_, ref) => { return ( ((_, ref) => { loading ? ( - {t("Saving...")} + {t("shared.statuses.saving")} ) : ( - t("Save") + t("shared.actions.save") ) } - cancelBtn={t("Cancel")} + cancelBtn={t("shared.actions.cancel")} onClose={() => setOpen(false)} onCancel={() => setOpen(false)} onOk={onSave} @@ -171,7 +174,7 @@ export const ClashPortViewer = forwardRef((_, ref) => {
    @@ -187,7 +190,7 @@ export const ClashPortViewer = forwardRef((_, ref) => { setMixedPort(generateRandomPort())} - title={t("Random Port")} + title={t("settings.modals.clashPort.actions.random")} sx={{ mr: 0.5 }} > @@ -203,7 +206,7 @@ export const ClashPortViewer = forwardRef((_, ref) => {
    @@ -220,7 +223,7 @@ export const ClashPortViewer = forwardRef((_, ref) => { setSocksPort(generateRandomPort())} - title={t("Random Port")} + title={t("settings.modals.clashPort.actions.random")} disabled={!socksEnabled} sx={{ mr: 0.5 }} > @@ -237,7 +240,7 @@ export const ClashPortViewer = forwardRef((_, ref) => {
    @@ -254,7 +257,7 @@ export const ClashPortViewer = forwardRef((_, ref) => { setHttpPort(generateRandomPort())} - title={t("Random Port")} + title={t("settings.modals.clashPort.actions.random")} disabled={!httpEnabled} sx={{ mr: 0.5 }} > @@ -272,7 +275,7 @@ export const ClashPortViewer = forwardRef((_, ref) => { {OS !== "windows" && (
    @@ -289,7 +292,7 @@ export const ClashPortViewer = forwardRef((_, ref) => { setRedirPort(generateRandomPort())} - title={t("Random Port")} + title={t("settings.modals.clashPort.actions.random")} disabled={!redirEnabled} sx={{ mr: 0.5 }} > @@ -308,7 +311,7 @@ export const ClashPortViewer = forwardRef((_, ref) => { {OS === "linux" && (
    @@ -325,7 +328,7 @@ export const ClashPortViewer = forwardRef((_, ref) => { setTproxyPort(generateRandomPort())} - title={t("Random Port")} + title={t("settings.modals.clashPort.actions.random")} disabled={!tproxyEnabled} sx={{ mr: 0.5 }} > diff --git a/clash-verge-rev/src/components/setting/mods/config-viewer.tsx b/clash-verge-rev/src/components/setting/mods/config-viewer.tsx index 26609caa0a..564a56dcc5 100644 --- a/clash-verge-rev/src/components/setting/mods/config-viewer.tsx +++ b/clash-verge-rev/src/components/setting/mods/config-viewer.tsx @@ -27,8 +27,8 @@ export const ConfigViewer = forwardRef((_, ref) => { open={true} title={ - {t("Runtime Config")} - + {t("settings.components.verge.advanced.fields.runtimeConfig")} + } initialData={Promise.resolve(runtimeConfig)} diff --git a/clash-verge-rev/src/components/setting/mods/controller-viewer.tsx b/clash-verge-rev/src/components/setting/mods/controller-viewer.tsx index 105fb34e91..7b10675910 100644 --- a/clash-verge-rev/src/components/setting/mods/controller-viewer.tsx +++ b/clash-verge-rev/src/components/setting/mods/controller-viewer.tsx @@ -56,12 +56,16 @@ export function ControllerViewer({ ref }: { ref?: Ref }) { // 如果启用了外部控制器,则保存控制器地址和密钥 if (enableController) { if (!controller.trim()) { - showNotice("error", t("Controller address cannot be empty")); + showNotice.error( + "settings.sections.externalController.messages.addressRequired", + ); return; } if (!secret.trim()) { - showNotice("error", t("Secret cannot be empty")); + showNotice.error( + "settings.sections.externalController.messages.secretRequired", + ); return; } @@ -71,12 +75,12 @@ export function ControllerViewer({ ref }: { ref?: Ref }) { await patchInfo({ "external-controller": "" }); } - showNotice("success", t("Configuration saved successfully")); + showNotice.success("shared.feedback.notifications.common.saveSuccess"); setOpen(false); - } catch (err: any) { - showNotice( - "error", - err.message || t("Failed to save configuration"), + } catch (err) { + showNotice.error( + "shared.feedback.notifications.common.saveFailed", + err, 4000, ); } finally { @@ -93,7 +97,9 @@ export function ControllerViewer({ ref }: { ref?: Ref }) { setTimeout(() => setCopySuccess(null)); } catch (err) { console.warn("[ControllerViewer] copy to clipboard failed:", err); - showNotice("error", t("Failed to copy")); + showNotice.error( + "settings.sections.externalController.messages.copyFailed", + ); } }, ); @@ -101,19 +107,19 @@ export function ControllerViewer({ ref }: { ref?: Ref }) { return ( - {t("Saving...")} + {t("shared.statuses.saving")} ) : ( - t("Save") + t("shared.actions.save") ) } - cancelBtn={t("Cancel")} + cancelBtn={t("shared.actions.cancel")} onClose={() => setOpen(false)} onCancel={() => setOpen(false)} onOk={onSave} @@ -126,7 +132,9 @@ export function ControllerViewer({ ref }: { ref?: Ref }) { justifyContent: "space-between", }} > - + }) { justifyContent: "space-between", }} > - + }) { pointerEvents: enableController ? "auto" : "none", }} value={controller} - placeholder="Required" + placeholder={t( + "settings.sections.externalController.placeholders.address", + )} onChange={(e) => setController(e.target.value)} disabled={isSaving || !enableController} /> - + handleCopyToClipboard(controller, "controller")} @@ -176,7 +190,9 @@ export function ControllerViewer({ ref }: { ref?: Ref }) { justifyContent: "space-between", }} > - + }) { pointerEvents: enableController ? "auto" : "none", }} value={secret} - placeholder={t("Recommended")} + placeholder={t( + "settings.sections.externalController.placeholders.secret", + )} onChange={(e) => setSecret(e.target.value)} disabled={isSaving || !enableController} /> - + handleCopyToClipboard(secret, "secret")} @@ -211,8 +231,10 @@ export function ControllerViewer({ ref }: { ref?: Ref }) { > {copySuccess === "controller" - ? t("Controller address copied to clipboard") - : t("Secret copied to clipboard")} + ? t( + "settings.sections.externalController.messages.controllerCopied", + ) + : t("settings.sections.externalController.messages.secretCopied")} diff --git a/clash-verge-rev/src/components/setting/mods/dns-viewer.tsx b/clash-verge-rev/src/components/setting/mods/dns-viewer.tsx index 3286cb3781..0b0f520d2a 100644 --- a/clash-verge-rev/src/components/setting/mods/dns-viewer.tsx +++ b/clash-verge-rev/src/components/setting/mods/dns-viewer.tsx @@ -1,3 +1,4 @@ +import MonacoEditor from "@monaco-editor/react"; import { RestartAltRounded } from "@mui/icons-material"; import { Box, @@ -16,9 +17,15 @@ import { invoke } from "@tauri-apps/api/core"; import { useLockFn } from "ahooks"; import yaml from "js-yaml"; import type { Ref } from "react"; -import { useEffect, useImperativeHandle, useState } from "react"; +import { + useCallback, + useEffect, + useImperativeHandle, + useReducer, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; -import MonacoEditor from "react-monaco-editor"; import { BaseDialog, DialogRef, Switch } from "@/components/base"; import { useClash } from "@/hooks/use-clash"; @@ -35,6 +42,91 @@ const Item = styled(ListItem)(() => ({ }, })); +type NameserverPolicy = Record; + +function parseNameserverPolicy(str: string): NameserverPolicy { + const result: NameserverPolicy = {}; + if (!str) return result; + + const ruleRegex = /\s*([^=]+?)\s*=\s*([^,]+)(?:,|$)/g; + let match: RegExpExecArray | null; + + while ((match = ruleRegex.exec(str)) !== null) { + const [, domainsPart, serversPart] = match; + + const domains = [domainsPart.trim()]; + const servers = serversPart.split(";").map((s) => s.trim()); + + domains.forEach((domain) => { + result[domain] = servers; + }); + } + + return result; +} + +function formatNameserverPolicy(policy: unknown): string { + if (!policy || typeof policy !== "object") return ""; + + return Object.entries(policy as Record) + .map(([domain, servers]) => { + const serversStr = Array.isArray(servers) ? servers.join(";") : servers; + return `${domain}=${serversStr}`; + }) + .join(", "); +} + +function formatHosts(hosts: unknown): string { + if (!hosts || typeof hosts !== "object") return ""; + + const result: string[] = []; + + Object.entries(hosts as Record).forEach( + ([domain, value]) => { + if (Array.isArray(value)) { + const ipsStr = value.join(";"); + result.push(`${domain}=${ipsStr}`); + } else { + result.push(`${domain}=${value}`); + } + }, + ); + + return result.join(", "); +} + +function parseHosts(str: string): NameserverPolicy { + const result: NameserverPolicy = {}; + if (!str) return result; + + str.split(",").forEach((item) => { + const parts = item.trim().split("="); + if (parts.length < 2) return; + + const domain = parts[0].trim(); + const valueStr = parts.slice(1).join("=").trim(); + + if (valueStr.includes(";")) { + result[domain] = valueStr + .split(";") + .map((s) => s.trim()) + .filter(Boolean); + } else { + result[domain] = valueStr; + } + }); + + return result; +} + +function parseList(str: string): string[] { + if (!str?.trim()) return []; + return str + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + // 默认DNS配置 const DEFAULT_DNS_CONFIG = { enable: true, @@ -95,6 +187,7 @@ export function DnsViewer({ ref }: { ref?: Ref }) { const [open, setOpen] = useState(false); const [visualization, setVisualization] = useState(true); + const skipYamlSyncRef = useRef(false); const [values, setValues] = useState<{ enable: boolean; listen: string; @@ -150,304 +243,91 @@ export function DnsViewer({ ref }: { ref?: Ref }) { }); // 用于YAML编辑模式 - const [yamlContent, setYamlContent] = useState(""); - - useImperativeHandle(ref, () => ({ - open: () => { - setOpen(true); - // 获取DNS配置文件并初始化表单 - initDnsConfig(); - }, - close: () => setOpen(false), - })); - - // 初始化DNS配置 - const initDnsConfig = async () => { - try { - // 尝试从dns_config.yaml文件读取配置 - const dnsConfigExists = await invoke( - "check_dns_config_exists", - {}, - ); - - if (dnsConfigExists) { - // 如果存在配置文件,加载其内容 - const dnsConfig = await invoke("get_dns_config_content", {}); - const config = yaml.load(dnsConfig) as any; - - // 更新表单数据 - updateValuesFromConfig(config); - // 更新YAML编辑器内容 - setYamlContent(dnsConfig); - } else { - // 如果不存在配置文件,使用默认值 - resetToDefaults(); - } - } catch (err) { - console.error("Failed to initialize DNS config", err); - resetToDefaults(); - } - }; + const [yamlContent, setYamlContent] = useReducer( + (_: string, next: string) => next, + "", + ); // 从配置对象更新表单值 - const updateValuesFromConfig = (config: any) => { - if (!config) return; + const updateValuesFromConfig = useCallback( + (config: any) => { + if (!config) return; - // 提取dns配置 - const dnsConfig = config.dns || {}; - // 提取hosts配置(与dns同级) - const hostsConfig = config.hosts || {}; + const dnsConfig = config.dns || {}; + const hostsConfig = config.hosts || {}; - const enhancedMode = - dnsConfig["enhanced-mode"] || DEFAULT_DNS_CONFIG["enhanced-mode"]; - const validEnhancedMode = - enhancedMode === "fake-ip" || enhancedMode === "redir-host" - ? enhancedMode - : DEFAULT_DNS_CONFIG["enhanced-mode"]; + const enhancedMode = + dnsConfig["enhanced-mode"] || DEFAULT_DNS_CONFIG["enhanced-mode"]; + const validEnhancedMode = + enhancedMode === "fake-ip" || enhancedMode === "redir-host" + ? enhancedMode + : DEFAULT_DNS_CONFIG["enhanced-mode"]; - const fakeIpFilterMode = - dnsConfig["fake-ip-filter-mode"] || - DEFAULT_DNS_CONFIG["fake-ip-filter-mode"]; - const validFakeIpFilterMode = - fakeIpFilterMode === "blacklist" || fakeIpFilterMode === "whitelist" - ? fakeIpFilterMode - : DEFAULT_DNS_CONFIG["fake-ip-filter-mode"]; + const fakeIpFilterMode = + dnsConfig["fake-ip-filter-mode"] || + DEFAULT_DNS_CONFIG["fake-ip-filter-mode"]; + const validFakeIpFilterMode = + fakeIpFilterMode === "blacklist" || fakeIpFilterMode === "whitelist" + ? fakeIpFilterMode + : DEFAULT_DNS_CONFIG["fake-ip-filter-mode"]; - setValues({ - enable: dnsConfig.enable ?? DEFAULT_DNS_CONFIG.enable, - listen: dnsConfig.listen ?? DEFAULT_DNS_CONFIG.listen, - enhancedMode: validEnhancedMode, - fakeIpRange: - dnsConfig["fake-ip-range"] ?? DEFAULT_DNS_CONFIG["fake-ip-range"], - fakeIpFilterMode: validFakeIpFilterMode, - preferH3: dnsConfig["prefer-h3"] ?? DEFAULT_DNS_CONFIG["prefer-h3"], - respectRules: - dnsConfig["respect-rules"] ?? DEFAULT_DNS_CONFIG["respect-rules"], - useHosts: dnsConfig["use-hosts"] ?? DEFAULT_DNS_CONFIG["use-hosts"], - useSystemHosts: - dnsConfig["use-system-hosts"] ?? DEFAULT_DNS_CONFIG["use-system-hosts"], - ipv6: dnsConfig.ipv6 ?? DEFAULT_DNS_CONFIG.ipv6, - fakeIpFilter: - dnsConfig["fake-ip-filter"]?.join(", ") ?? - DEFAULT_DNS_CONFIG["fake-ip-filter"].join(", "), - nameserver: - dnsConfig.nameserver?.join(", ") ?? - DEFAULT_DNS_CONFIG.nameserver.join(", "), - fallback: - dnsConfig.fallback?.join(", ") ?? - DEFAULT_DNS_CONFIG.fallback.join(", "), - defaultNameserver: - dnsConfig["default-nameserver"]?.join(", ") ?? - DEFAULT_DNS_CONFIG["default-nameserver"].join(", "), - proxyServerNameserver: - dnsConfig["proxy-server-nameserver"]?.join(", ") ?? - (DEFAULT_DNS_CONFIG["proxy-server-nameserver"]?.join(", ") || ""), - directNameserver: - dnsConfig["direct-nameserver"]?.join(", ") ?? - (DEFAULT_DNS_CONFIG["direct-nameserver"]?.join(", ") || ""), - directNameserverFollowPolicy: - dnsConfig["direct-nameserver-follow-policy"] ?? - DEFAULT_DNS_CONFIG["direct-nameserver-follow-policy"], - fallbackGeoip: - dnsConfig["fallback-filter"]?.geoip ?? - DEFAULT_DNS_CONFIG["fallback-filter"].geoip, - fallbackGeoipCode: - dnsConfig["fallback-filter"]?.["geoip-code"] ?? - DEFAULT_DNS_CONFIG["fallback-filter"]["geoip-code"], - fallbackIpcidr: - dnsConfig["fallback-filter"]?.ipcidr?.join(", ") ?? - DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr.join(", "), - fallbackDomain: - dnsConfig["fallback-filter"]?.domain?.join(", ") ?? - DEFAULT_DNS_CONFIG["fallback-filter"].domain.join(", "), - nameserverPolicy: - formatNameserverPolicy(dnsConfig["nameserver-policy"]) || "", - hosts: formatHosts(hostsConfig) || "", - }); - }; - - // 重置为默认值 - const resetToDefaults = () => { - setValues({ - enable: DEFAULT_DNS_CONFIG.enable, - listen: DEFAULT_DNS_CONFIG.listen, - enhancedMode: DEFAULT_DNS_CONFIG["enhanced-mode"], - fakeIpRange: DEFAULT_DNS_CONFIG["fake-ip-range"], - fakeIpFilterMode: DEFAULT_DNS_CONFIG["fake-ip-filter-mode"], - preferH3: DEFAULT_DNS_CONFIG["prefer-h3"], - respectRules: DEFAULT_DNS_CONFIG["respect-rules"], - useHosts: DEFAULT_DNS_CONFIG["use-hosts"], - useSystemHosts: DEFAULT_DNS_CONFIG["use-system-hosts"], - ipv6: DEFAULT_DNS_CONFIG.ipv6, - fakeIpFilter: DEFAULT_DNS_CONFIG["fake-ip-filter"].join(", "), - defaultNameserver: DEFAULT_DNS_CONFIG["default-nameserver"].join(", "), - nameserver: DEFAULT_DNS_CONFIG.nameserver.join(", "), - fallback: DEFAULT_DNS_CONFIG.fallback.join(", "), - proxyServerNameserver: - DEFAULT_DNS_CONFIG["proxy-server-nameserver"]?.join(", ") || "", - directNameserver: - DEFAULT_DNS_CONFIG["direct-nameserver"]?.join(", ") || "", - directNameserverFollowPolicy: - DEFAULT_DNS_CONFIG["direct-nameserver-follow-policy"] || false, - fallbackGeoip: DEFAULT_DNS_CONFIG["fallback-filter"].geoip, - fallbackGeoipCode: DEFAULT_DNS_CONFIG["fallback-filter"]["geoip-code"], - fallbackIpcidr: - DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr?.join(", ") || "", - fallbackDomain: - DEFAULT_DNS_CONFIG["fallback-filter"].domain?.join(", ") || "", - nameserverPolicy: "", - hosts: "", - }); - - // 更新YAML编辑器内容 - updateYamlFromValues(); - }; - - // 从表单值更新YAML内容 - const updateYamlFromValues = () => { - const config: Record = {}; - - const dnsConfig = generateDnsConfig(); - if (Object.keys(dnsConfig).length > 0) { - config.dns = dnsConfig; - } - - const hosts = parseHosts(values.hosts); - if (Object.keys(hosts).length > 0) { - config.hosts = hosts; - } - - setYamlContent(yaml.dump(config, { forceQuotes: true })); - }; - - // 从YAML更新表单值 - const updateValuesFromYaml = () => { - try { - const parsedYaml = yaml.load(yamlContent) as any; - if (!parsedYaml) return; - - updateValuesFromConfig(parsedYaml); - } catch { - showNotice("error", t("Invalid YAML format")); - } - }; - - // 解析nameserver-policy为对象 - const parseNameserverPolicy = (str: string): Record => { - const result: Record = {}; - if (!str) return result; - - // 处理geosite:xxx,yyy格式 - const ruleRegex = /\s*([^=]+?)\s*=\s*([^,]+)(?:,|$)/g; - let match; - - while ((match = ruleRegex.exec(str)) !== null) { - const [, domainsPart, serversPart] = match; - - // 处理域名部分 - let domains; - if (domainsPart.startsWith("geosite:")) { - domains = [domainsPart.trim()]; - } else { - domains = [domainsPart.trim()]; - } - - // 处理服务器部分 - const servers = serversPart.split(";").map((s) => s.trim()); - - // 为每个域名组分配相同的服务器列表 - domains.forEach((domain) => { - result[domain] = servers; + setValues({ + enable: dnsConfig.enable ?? DEFAULT_DNS_CONFIG.enable, + listen: dnsConfig.listen ?? DEFAULT_DNS_CONFIG.listen, + enhancedMode: validEnhancedMode, + fakeIpRange: + dnsConfig["fake-ip-range"] ?? DEFAULT_DNS_CONFIG["fake-ip-range"], + fakeIpFilterMode: validFakeIpFilterMode, + preferH3: dnsConfig["prefer-h3"] ?? DEFAULT_DNS_CONFIG["prefer-h3"], + respectRules: + dnsConfig["respect-rules"] ?? DEFAULT_DNS_CONFIG["respect-rules"], + useHosts: dnsConfig["use-hosts"] ?? DEFAULT_DNS_CONFIG["use-hosts"], + useSystemHosts: + dnsConfig["use-system-hosts"] ?? + DEFAULT_DNS_CONFIG["use-system-hosts"], + ipv6: dnsConfig.ipv6 ?? DEFAULT_DNS_CONFIG.ipv6, + fakeIpFilter: + dnsConfig["fake-ip-filter"]?.join(", ") ?? + DEFAULT_DNS_CONFIG["fake-ip-filter"].join(", "), + nameserver: + dnsConfig.nameserver?.join(", ") ?? + DEFAULT_DNS_CONFIG.nameserver.join(", "), + fallback: + dnsConfig.fallback?.join(", ") ?? + DEFAULT_DNS_CONFIG.fallback.join(", "), + defaultNameserver: + dnsConfig["default-nameserver"]?.join(", ") ?? + DEFAULT_DNS_CONFIG["default-nameserver"].join(", "), + proxyServerNameserver: + dnsConfig["proxy-server-nameserver"]?.join(", ") ?? + (DEFAULT_DNS_CONFIG["proxy-server-nameserver"]?.join(", ") || ""), + directNameserver: + dnsConfig["direct-nameserver"]?.join(", ") ?? + (DEFAULT_DNS_CONFIG["direct-nameserver"]?.join(", ") || ""), + directNameserverFollowPolicy: + dnsConfig["direct-nameserver-follow-policy"] ?? + DEFAULT_DNS_CONFIG["direct-nameserver-follow-policy"], + fallbackGeoip: + dnsConfig["fallback-filter"]?.geoip ?? + DEFAULT_DNS_CONFIG["fallback-filter"].geoip, + fallbackGeoipCode: + dnsConfig["fallback-filter"]?.["geoip-code"] ?? + DEFAULT_DNS_CONFIG["fallback-filter"]["geoip-code"], + fallbackIpcidr: + dnsConfig["fallback-filter"]?.ipcidr?.join(", ") ?? + DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr.join(", "), + fallbackDomain: + dnsConfig["fallback-filter"]?.domain?.join(", ") ?? + DEFAULT_DNS_CONFIG["fallback-filter"].domain.join(", "), + nameserverPolicy: + formatNameserverPolicy(dnsConfig["nameserver-policy"]) || "", + hosts: formatHosts(hostsConfig) || "", }); - } + }, + [setValues], + ); - return result; - }; - - // 格式化nameserver-policy为字符串 - const formatNameserverPolicy = (policy: any): string => { - if (!policy || typeof policy !== "object") return ""; - - // 直接将对象转换为字符串格式 - return Object.entries(policy) - .map(([domain, servers]) => { - const serversStr = Array.isArray(servers) ? servers.join(";") : servers; - return `${domain}=${serversStr}`; - }) - .join(", "); - }; - - // 格式化hosts为字符串 - const formatHosts = (hosts: any): string => { - if (!hosts || typeof hosts !== "object") return ""; - - const result: string[] = []; - - Object.entries(hosts).forEach(([domain, value]) => { - if (Array.isArray(value)) { - // 处理数组格式的IP - const ipsStr = value.join(";"); - result.push(`${domain}=${ipsStr}`); - } else { - // 处理单个IP或域名 - result.push(`${domain}=${value}`); - } - }); - - return result.join(", "); - }; - - // 解析hosts字符串为对象 - const parseHosts = (str: string): Record => { - const result: Record = {}; - if (!str) return result; - - str.split(",").forEach((item) => { - const parts = item.trim().split("="); - if (parts.length < 2) return; - - const domain = parts[0].trim(); - const valueStr = parts.slice(1).join("=").trim(); - - // 检查是否包含多个分号分隔的IP - if (valueStr.includes(";")) { - result[domain] = valueStr - .split(";") - .map((s) => s.trim()) - .filter(Boolean); - } else { - result[domain] = valueStr; - } - }); - - return result; - }; - - // 初始化时设置默认YAML - useEffect(() => { - updateYamlFromValues(); - }, []); - - // 切换编辑模式时的处理 - useEffect(() => { - if (visualization) { - updateValuesFromYaml(); - } else { - updateYamlFromValues(); - } - }, [visualization]); - - // 解析列表字符串为数组 - const parseList = (str: string): string[] => { - if (!str?.trim()) return []; - return str - .split(",") - .map((item) => item.trim()) - .filter(Boolean); - }; - - // 生成DNS配置对象 - const generateDnsConfig = () => { + const generateDnsConfig = useCallback(() => { const dnsConfig: any = { enable: values.enable, listen: values.listen, @@ -481,8 +361,132 @@ export function DnsViewer({ ref }: { ref?: Ref }) { } return dnsConfig; - }; + }, [values]); + const updateYamlFromValues = useCallback(() => { + const config: Record = {}; + + const dnsConfig = generateDnsConfig(); + if (Object.keys(dnsConfig).length > 0) { + config.dns = dnsConfig; + } + + const hosts = parseHosts(values.hosts); + if (Object.keys(hosts).length > 0) { + config.hosts = hosts; + } + + setYamlContent(yaml.dump(config, { forceQuotes: true })); + }, [generateDnsConfig, setYamlContent, values.hosts]); + + // 重置为默认值 + const resetToDefaults = useCallback(() => { + setValues({ + enable: DEFAULT_DNS_CONFIG.enable, + listen: DEFAULT_DNS_CONFIG.listen, + enhancedMode: DEFAULT_DNS_CONFIG["enhanced-mode"], + fakeIpRange: DEFAULT_DNS_CONFIG["fake-ip-range"], + fakeIpFilterMode: DEFAULT_DNS_CONFIG["fake-ip-filter-mode"], + preferH3: DEFAULT_DNS_CONFIG["prefer-h3"], + respectRules: DEFAULT_DNS_CONFIG["respect-rules"], + useHosts: DEFAULT_DNS_CONFIG["use-hosts"], + useSystemHosts: DEFAULT_DNS_CONFIG["use-system-hosts"], + ipv6: DEFAULT_DNS_CONFIG.ipv6, + fakeIpFilter: DEFAULT_DNS_CONFIG["fake-ip-filter"].join(", "), + defaultNameserver: DEFAULT_DNS_CONFIG["default-nameserver"].join(", "), + nameserver: DEFAULT_DNS_CONFIG.nameserver.join(", "), + fallback: DEFAULT_DNS_CONFIG.fallback.join(", "), + proxyServerNameserver: + DEFAULT_DNS_CONFIG["proxy-server-nameserver"]?.join(", ") || "", + directNameserver: + DEFAULT_DNS_CONFIG["direct-nameserver"]?.join(", ") || "", + directNameserverFollowPolicy: + DEFAULT_DNS_CONFIG["direct-nameserver-follow-policy"] || false, + fallbackGeoip: DEFAULT_DNS_CONFIG["fallback-filter"].geoip, + fallbackGeoipCode: DEFAULT_DNS_CONFIG["fallback-filter"]["geoip-code"], + fallbackIpcidr: + DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr?.join(", ") || "", + fallbackDomain: + DEFAULT_DNS_CONFIG["fallback-filter"].domain?.join(", ") || "", + nameserverPolicy: "", + hosts: "", + }); + + updateYamlFromValues(); + }, [setValues, updateYamlFromValues]); + + // 从YAML更新表单值 + const updateValuesFromYaml = useCallback(() => { + try { + const parsedYaml = yaml.load(yamlContent) as any; + if (!parsedYaml) return; + + skipYamlSyncRef.current = true; + updateValuesFromConfig(parsedYaml); + } catch { + showNotice.error("settings.modals.dns.errors.invalidYaml"); + } + }, [yamlContent, updateValuesFromConfig]); + + useEffect(() => { + if (skipYamlSyncRef.current) { + skipYamlSyncRef.current = false; + return; + } + updateYamlFromValues(); + }, [updateYamlFromValues]); + + const latestUpdateValuesFromYamlRef = useRef(updateValuesFromYaml); + const latestUpdateYamlFromValuesRef = useRef(updateYamlFromValues); + + useEffect(() => { + latestUpdateValuesFromYamlRef.current = updateValuesFromYaml; + latestUpdateYamlFromValuesRef.current = updateYamlFromValues; + }, [updateValuesFromYaml, updateYamlFromValues]); + + useEffect(() => { + if (visualization) { + latestUpdateValuesFromYamlRef.current(); + } else { + latestUpdateYamlFromValuesRef.current(); + } + }, [visualization]); + + const initDnsConfig = useCallback(async () => { + try { + const dnsConfigExists = await invoke( + "check_dns_config_exists", + {}, + ); + + if (dnsConfigExists) { + const dnsConfig = await invoke("get_dns_config_content", {}); + const config = yaml.load(dnsConfig) as any; + + updateValuesFromConfig(config); + setYamlContent(dnsConfig); + } else { + resetToDefaults(); + } + } catch (err) { + console.error("Failed to initialize DNS config", err); + resetToDefaults(); + } + }, [resetToDefaults, setYamlContent, updateValuesFromConfig]); + + useImperativeHandle( + ref, + () => ({ + open: () => { + setOpen(true); + void initDnsConfig(); + }, + close: () => setOpen(false), + }), + [initDnsConfig], + ); + + // 生成DNS配置对象 // 处理保存操作 const onSave = useLockFn(async () => { try { @@ -505,7 +509,7 @@ export function DnsViewer({ ref }: { ref?: Ref }) { // 使用YAML编辑器的值 const parsedConfig = yaml.load(yamlContent); if (typeof parsedConfig !== "object" || parsedConfig === null) { - throw new Error(t("Invalid configuration")); + throw new Error(t("settings.modals.dns.errors.invalid")); } config = parsedConfig as Record; } @@ -543,9 +547,9 @@ export function DnsViewer({ ref }: { ref?: Ref }) { } } - showNotice( - "error", - t("DNS configuration error") + ": " + cleanErrorMsg, + showNotice.error( + "settings.modals.dns.messages.configError", + cleanErrorMsg, ); return; } @@ -557,19 +561,19 @@ export function DnsViewer({ ref }: { ref?: Ref }) { } setOpen(false); - showNotice("success", t("DNS settings saved")); - } catch (err: any) { - showNotice("error", err.message || err.toString()); + showNotice.success("settings.modals.dns.messages.saved"); + } catch (err) { + showNotice.error(err); } }); // YAML编辑器内容变更处理 - const handleYamlChange = (value: string) => { + const handleYamlChange = (value?: string) => { setYamlContent(value || ""); // 允许YAML编辑后立即分析和更新表单值 try { - const config = yaml.load(value) as any; + const config = yaml.load(value || "") as any; if (config && typeof config === "object") { setTimeout(() => { updateValuesFromConfig(config); @@ -609,7 +613,7 @@ export function DnsViewer({ ref }: { ref?: Ref }) { open={open} title={ - {t("DNS Overwrite")} + {t("settings.modals.dns.dialog.title")} @@ -639,8 +645,8 @@ export function DnsViewer({ ref }: { ref?: Ref }) { ? {} : { padding: "0 24px", display: "flex", flexDirection: "column" }), }} - okBtn={t("Save")} - cancelBtn={t("Cancel")} + okBtn={t("shared.actions.save")} + cancelBtn={t("shared.actions.cancel")} onClose={() => setOpen(false)} onCancel={() => setOpen(false)} onOk={onSave} @@ -651,7 +657,7 @@ export function DnsViewer({ ref }: { ref?: Ref }) { color="warning.main" sx={{ mb: 2, mt: 0, fontStyle: "italic" }} > - {t("DNS Settings Warning")} + {t("settings.modals.dns.dialog.warning")} {visualization ? ( @@ -660,11 +666,11 @@ export function DnsViewer({ ref }: { ref?: Ref }) { variant="subtitle1" sx={{ mt: 1, mb: 1, fontWeight: "bold" }} > - {t("DNS Settings")} + {t("settings.modals.dns.sections.general")} - + }) { - + }) { - + }) { }) { }) { }) { }) { }) { }) { }) { }) { }) { }) { }) { }) { }) { variant="subtitle2" sx={{ mt: 2, mb: 1, fontWeight: "bold" }} > - {t("Fallback Filter Settings")} + {t("settings.modals.dns.sections.fallbackFilter")} }) { - + }) { }) { }) { variant="subtitle1" sx={{ mt: 3, mb: 0, fontWeight: "bold" }} > - {t("Hosts Settings")} + {t("settings.modals.dns.sections.hosts")} }) { height="100vh" language="yaml" value={yamlContent} - theme={themeMode === "light" ? "vs" : "vs-dark"} + theme={themeMode === "light" ? "light" : "vs-dark"} className="flex-grow" options={{ tabSize: 2, diff --git a/clash-verge-rev/src/components/setting/mods/external-controller-cors.tsx b/clash-verge-rev/src/components/setting/mods/external-controller-cors.tsx index d2522f359f..b4ded7c4fd 100644 --- a/clash-verge-rev/src/components/setting/mods/external-controller-cors.tsx +++ b/clash-verge-rev/src/components/setting/mods/external-controller-cors.tsx @@ -1,7 +1,7 @@ import { Delete as DeleteIcon } from "@mui/icons-material"; import { Box, Button, Divider, List, ListItem, TextField } from "@mui/material"; import { useLockFn, useRequest } from "ahooks"; -import { forwardRef, useImperativeHandle, useState } from "react"; +import { forwardRef, useImperativeHandle, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { BaseDialog, Switch } from "@/components/base"; @@ -140,10 +140,12 @@ export const HeaderConfiguration = forwardRef( manual: true, onSuccess: () => { setOpen(false); - showNotice("success", t("Configuration saved successfully")); + showNotice.success( + "shared.feedback.notifications.common.saveSuccess", + ); }, onError: () => { - showNotice("error", t("Failed to save configuration")); + showNotice.error("shared.feedback.notifications.common.saveFailed"); }, }, ); @@ -165,13 +167,26 @@ export const HeaderConfiguration = forwardRef( await saveConfig(); }); + const originEntries = useMemo(() => { + const counts: Record = {}; + return corsConfig.allowOrigins.map((origin, index) => { + const occurrence = (counts[origin] = (counts[origin] ?? 0) + 1); + const keyBase = origin || "origin"; + return { + origin, + index, + key: `${keyBase}-${occurrence}`, + }; + }); + }, [corsConfig.allowOrigins]); + return ( setOpen(false)} onCancel={() => setOpen(false)} onOk={handleSave} @@ -185,7 +200,7 @@ export const HeaderConfiguration = forwardRef( width="100%" > - {t("Allow private network access")} + {t("settings.sections.externalCors.fields.allowPrivateNetwork")} (
    - {t("Allowed Origins")} + {t("settings.sections.externalCors.fields.allowedOrigins")}
    - {corsConfig.allowOrigins.map((origin, index) => ( + {originEntries.map(({ origin, index, key }) => (
    ( sx={{ fontSize: 14, marginRight: 2 }} value={origin} onChange={(e) => handleUpdateOrigin(index, e.target.value)} - placeholder={t("Please enter a valid url")} + placeholder={t( + "settings.sections.externalCors.placeholders.origin", + )} inputProps={{ style: { fontSize: 14 } }} />
    (
    - {t("Always included origins: {{urls}}", { + {t("settings.sections.externalCors.messages.alwaysIncluded", { urls: DEV_URLS.join(", "), })}
    diff --git a/clash-verge-rev/src/components/setting/mods/guard-state.tsx b/clash-verge-rev/src/components/setting/mods/guard-state.tsx index 0e632ad8c7..4889749da4 100644 --- a/clash-verge-rev/src/components/setting/mods/guard-state.tsx +++ b/clash-verge-rev/src/components/setting/mods/guard-state.tsx @@ -1,4 +1,4 @@ -import { cloneElement, isValidElement, ReactNode, useRef } from "react"; +import { createElement, isValidElement, ReactNode, useRef } from "react"; import noop from "@/utils/noop"; @@ -24,7 +24,7 @@ export function GuardState(props: Props) { onGuard = noop, onCatch = noop, onChange = noop, - onFormat = (v: T) => v, + onFormat, } = props; const lockRef = useRef(false); @@ -45,7 +45,7 @@ export function GuardState(props: Props) { lockRef.current = true; try { - const newValue = (onFormat as any)(...args); + const newValue = onFormat ? (onFormat as any)(...args) : (args[0] as T); // 先在ui上响应操作 onChange(newValue); @@ -81,5 +81,7 @@ export function GuardState(props: Props) { } lockRef.current = false; }; - return cloneElement(children, childProps); + const { children: nestedChildren, ...restProps } = childProps; + + return createElement(children.type, restProps, nestedChildren); } diff --git a/clash-verge-rev/src/components/setting/mods/hotkey-input.tsx b/clash-verge-rev/src/components/setting/mods/hotkey-input.tsx index c4fdd71672..9270d06337 100644 --- a/clash-verge-rev/src/components/setting/mods/hotkey-input.tsx +++ b/clash-verge-rev/src/components/setting/mods/hotkey-input.tsx @@ -7,7 +7,7 @@ import { parseHotkey } from "@/utils/parse-hotkey"; const KeyWrapper = styled("div")(({ theme }) => ({ position: "relative", - width: 165, + width: 230, minHeight: 36, "> input": { @@ -39,6 +39,7 @@ const KeyWrapper = styled("div")(({ theme }) => ({ }, }, ".item": { + fontSize: "14px", color: theme.palette.text.primary, border: "1px solid", borderColor: alpha(theme.palette.text.secondary, 0.2), @@ -76,11 +77,10 @@ export const HotkeyInput = (props: Props) => { } }} onKeyDown={(e) => { - const evt = e.nativeEvent; e.preventDefault(); e.stopPropagation(); - const key = parseHotkey(evt.key); + const key = parseHotkey(e); if (key === "UNIDENTIFIED") return; changeRef.current = [...new Set([...changeRef.current, key])]; @@ -102,7 +102,7 @@ export const HotkeyInput = (props: Props) => { { onChange([]); diff --git a/clash-verge-rev/src/components/setting/mods/hotkey-viewer.tsx b/clash-verge-rev/src/components/setting/mods/hotkey-viewer.tsx index cd09e244c0..574abea4b5 100644 --- a/clash-verge-rev/src/components/setting/mods/hotkey-viewer.tsx +++ b/clash-verge-rev/src/components/setting/mods/hotkey-viewer.tsx @@ -24,7 +24,19 @@ const HOTKEY_FUNC = [ "toggle_system_proxy", "toggle_tun_mode", "entry_lightweight_mode", -]; +] as const; + +const HOTKEY_FUNC_LABELS: Record<(typeof HOTKEY_FUNC)[number], string> = { + open_or_close_dashboard: + "settings.modals.hotkey.functions.openOrCloseDashboard", + clash_mode_rule: "settings.modals.hotkey.functions.rule", + clash_mode_global: "settings.modals.hotkey.functions.global", + clash_mode_direct: "settings.modals.hotkey.functions.direct", + toggle_system_proxy: "settings.modals.hotkey.functions.toggleSystemProxy", + toggle_tun_mode: "settings.modals.hotkey.functions.toggleTunMode", + entry_lightweight_mode: + "settings.modals.hotkey.functions.entryLightweightMode", +}; export const HotkeyViewer = forwardRef((props, ref) => { const { t } = useTranslation(); @@ -33,7 +45,7 @@ export const HotkeyViewer = forwardRef((props, ref) => { const { verge, patchVerge } = useVerge(); const [hotkeyMap, setHotkeyMap] = useState>({}); - const [enableGlobalHotkey, setEnableHotkey] = useState( + const [enableGlobalHotkey, setEnableGlobalHotkey] = useState( verge?.enable_global_hotkey ?? true, ); @@ -81,34 +93,36 @@ export const HotkeyViewer = forwardRef((props, ref) => { enable_global_hotkey: enableGlobalHotkey, }); setOpen(false); - } catch (err: any) { - showNotice("error", err.toString()); + } catch (err) { + showNotice.error(err); } }); return ( setOpen(false)} onCancel={() => setOpen(false)} onOk={onSave} > - {t("Enable Global Hotkey")} + + {t("settings.modals.hotkey.toggles.enableGlobal")} + setEnableHotkey(e.target.checked)} + onChange={(e) => setEnableGlobalHotkey(e.target.checked)} /> {HOTKEY_FUNC.map((func) => ( - {t(func)} + {t(HOTKEY_FUNC_LABELS[func])} setHotkeyMap((m) => ({ ...m, [func]: v }))} diff --git a/clash-verge-rev/src/components/setting/mods/layout-viewer.tsx b/clash-verge-rev/src/components/setting/mods/layout-viewer.tsx index ad28ae3d86..ebc9588df1 100644 --- a/clash-verge-rev/src/components/setting/mods/layout-viewer.tsx +++ b/clash-verge-rev/src/components/setting/mods/layout-viewer.tsx @@ -1,11 +1,13 @@ import { Box, Button, + InputAdornment, List, ListItem, ListItemText, MenuItem, Select, + TextField, styled, } from "@mui/material"; import { convertFileSrc } from "@tauri-apps/api/core"; @@ -17,15 +19,24 @@ import { useTranslation } from "react-i18next"; import { BaseDialog, DialogRef, Switch } from "@/components/base"; import { TooltipIcon } from "@/components/base/base-tooltip-icon"; +import { DEFAULT_HOVER_DELAY } from "@/components/proxy/proxy-group-navigator"; import { useVerge } from "@/hooks/use-verge"; import { useWindowDecorations } from "@/hooks/use-window"; import { copyIconFile, getAppDir } from "@/services/cmds"; import { showNotice } from "@/services/noticeService"; import getSystem from "@/utils/get-system"; + import { GuardState } from "./guard-state"; const OS = getSystem(); +const clampHoverDelay = (value: number) => { + if (!Number.isFinite(value)) { + return DEFAULT_HOVER_DELAY; + } + return Math.min(5000, Math.max(0, Math.round(value))); +}; + const getIcons = async (icon_dir: string, name: string) => { const updateTime = localStorage.getItem(`icon_${name}_update_time`) || ""; @@ -38,7 +49,7 @@ const getIcons = async (icon_dir: string, name: string) => { }; }; -export const LayoutViewer = forwardRef((props, ref) => { +export const LayoutViewer = forwardRef((_, ref) => { const { t } = useTranslation(); const { verge, patchVerge, mutateVerge } = useVerge(); @@ -93,7 +104,7 @@ export const LayoutViewer = forwardRef((props, ref) => { const onSwitchFormat = (_e: any, value: boolean) => value; const onError = (err: any) => { - showNotice("error", err.message || err.toString()); + showNotice.error(err); }; const onChangeData = (patch: Partial) => { mutateVerge({ ...verge, ...patch }, false); @@ -102,22 +113,26 @@ export const LayoutViewer = forwardRef((props, ref) => { return ( setOpen(false)} onCancel={() => setOpen(false)} > - + { + onChange={async () => { await toggleDecorations(); }} > @@ -126,7 +141,9 @@ export const LayoutViewer = forwardRef((props, ref) => { - + ((props, ref) => { - + ((props, ref) => { - + ((props, ref) => { - {t("Hover Jump Navigator")} + + {t("settings.components.verge.layout.fields.hoverNavigator")} + @@ -192,25 +219,94 @@ export const LayoutViewer = forwardRef((props, ref) => { - + + + {t( + "settings.components.verge.layout.fields.hoverNavigatorDelay", + )} + + + + } + /> + clampHoverDelay(Number(e.target.value))} + onChange={(value) => + onChangeData({ + hover_jump_navigator_delay: clampHoverDelay(value), + }) + } + onGuard={(value) => + patchVerge({ hover_jump_navigator_delay: clampHoverDelay(value) }) + } + > + + {t("shared.units.milliseconds")} + + ), + }, + htmlInput: { + min: 0, + max: 5000, + step: 20, + }, + }} + /> + + + + + e.target.value} - onChange={(e) => onChangeData({ menu_icon: e })} - onGuard={(e) => patchVerge({ menu_icon: e })} + onChange={(value) => onChangeData({ menu_icon: value })} + onGuard={(value) => patchVerge({ menu_icon: value })} > {OS === "macos" && ( - + ((props, ref) => { size="small" sx={{ width: 140, "> div": { py: "7.5px" } }} > - {t("Monochrome")} - {t("Colorful")} + + {t( + "settings.components.verge.layout.options.icon.monochrome", + )} + + + {t("settings.components.verge.layout.options.icon.colorful")} + )} {/* {OS === "macos" && ( - + ((props, ref) => { )} */} - {OS === "macos" && ( + {/* {OS === "macos" && ( - + ((props, ref) => { - )} + )} */} + + + onChangeData({ tray_inline_proxy_groups: e })} + onGuard={(e) => patchVerge({ tray_inline_proxy_groups: e })} + > + + + - + ((props, ref) => { await initIconPath(); onChangeData({ common_tray_icon: true }); patchVerge({ common_tray_icon: true }); - console.log(); } } }} > - {verge?.common_tray_icon ? t("Clear") : t("Browse")} + {verge?.common_tray_icon + ? t("shared.actions.clear") + : t("settings.components.verge.basic.actions.browse")} - + ((props, ref) => { } }} > - {verge?.sysproxy_tray_icon ? t("Clear") : t("Browse")} + {verge?.sysproxy_tray_icon + ? t("shared.actions.clear") + : t("settings.components.verge.basic.actions.browse")} - + ((props, ref) => { } }} > - {verge?.tun_tray_icon ? t("Clear") : t("Browse")} + {verge?.tun_tray_icon + ? t("shared.actions.clear") + : t("settings.components.verge.basic.actions.browse")} diff --git a/clash-verge-rev/src/components/setting/mods/lite-mode-viewer.tsx b/clash-verge-rev/src/components/setting/mods/lite-mode-viewer.tsx index 241b84b9d6..cabd10d329 100644 --- a/clash-verge-rev/src/components/setting/mods/lite-mode-viewer.tsx +++ b/clash-verge-rev/src/components/setting/mods/lite-mode-viewer.tsx @@ -45,25 +45,27 @@ export function LiteModeViewer({ ref }: { ref?: Ref }) { auto_light_weight_minutes: values.autoEnterLiteModeDelay, }); setOpen(false); - } catch (err: any) { - showNotice("error", err.message || err.toString()); + } catch (err) { + showNotice.error(err); } }); return ( setOpen(false)} onCancel={() => setOpen(false)} onOk={onSave} > - + }) { }} onClick={async () => await entry_lightweight_mode()} > - {t("Enable")} + {t("shared.actions.enable")} }) { {values.autoEnterLiteMode && ( <> - + }) { input: { endAdornment: ( - {t("mins")} + {t("shared.units.minutes")} ), }, @@ -133,10 +137,9 @@ export function LiteModeViewer({ ref }: { ref?: Ref }) { color="text.secondary" sx={{ fontStyle: "italic" }} > - {t( - "When closing the window, LightWeight Mode will be automatically activated after _n minutes", - { n: values.autoEnterLiteModeDelay }, - )} + {t("settings.modals.liteMode.messages.autoEnterHint", { + n: values.autoEnterLiteModeDelay, + })} diff --git a/clash-verge-rev/src/components/setting/mods/misc-viewer.tsx b/clash-verge-rev/src/components/setting/mods/misc-viewer.tsx index b0048fd817..62109e070b 100644 --- a/clash-verge-rev/src/components/setting/mods/misc-viewer.tsx +++ b/clash-verge-rev/src/components/setting/mods/misc-viewer.tsx @@ -29,6 +29,7 @@ export const MiscViewer = forwardRef((props, ref) => { autoCheckUpdate: true, enableBuiltinEnhanced: true, proxyLayoutColumn: 6, + enableAutoDelayDetection: false, defaultLatencyTest: "", autoLogClean: 2, defaultLatencyTimeout: 10000, @@ -45,6 +46,7 @@ export const MiscViewer = forwardRef((props, ref) => { autoCheckUpdate: verge?.auto_check_update ?? true, enableBuiltinEnhanced: verge?.enable_builtin_enhanced ?? true, proxyLayoutColumn: verge?.proxy_layout_column || 6, + enableAutoDelayDetection: verge?.enable_auto_delay_detection ?? false, defaultLatencyTest: verge?.default_latency_test || "", autoLogClean: verge?.auto_log_clean || 0, defaultLatencyTimeout: verge?.default_latency_timeout || 10000, @@ -57,34 +59,39 @@ export const MiscViewer = forwardRef((props, ref) => { try { await patchVerge({ app_log_level: values.appLogLevel, + app_log_max_size: values.appLogMaxSize, + app_log_max_count: values.appLogMaxCount, auto_close_connection: values.autoCloseConnection, auto_check_update: values.autoCheckUpdate, enable_builtin_enhanced: values.enableBuiltinEnhanced, proxy_layout_column: values.proxyLayoutColumn, + enable_auto_delay_detection: values.enableAutoDelayDetection, default_latency_test: values.defaultLatencyTest, default_latency_timeout: values.defaultLatencyTimeout, auto_log_clean: values.autoLogClean as any, }); setOpen(false); - } catch (err: any) { - showNotice("error", err.toString()); + } catch (err) { + showNotice.error(err); } }); return ( setOpen(false)} onCancel={() => setOpen(false)} onOk={onSave} > - + div": { py: "7.5px" } }} @@ -227,7 +242,7 @@ export const MiscViewer = forwardRef((props, ref) => { } > - {t("Auto Columns")} + {t("settings.modals.misc.options.proxyLayoutColumns.auto")} {[1, 2, 3, 4, 5].map((i) => ( @@ -238,7 +253,9 @@ export const MiscViewer = forwardRef((props, ref) => { - + div": { py: "7.5px" } }}> - Debug - Info - Warn - Error - Silent + + {t("settings.sections.clash.form.options.logLevel.debug")} + + + {t("settings.sections.clash.form.options.logLevel.info")} + + + {t("settings.sections.clash.form.options.logLevel.warning")} + + + {t("settings.sections.clash.form.options.logLevel.error")} + + + {t("settings.sections.clash.form.options.logLevel.silent")} + - + { {t("External")}} + label={t("settings.sections.clash.form.fields.external")} extra={ { e.stopPropagation(); @@ -234,10 +249,13 @@ const SettingClash = ({ onError }: Props) => { }} /> - webRef.current?.open()} label={t("Web UI")} /> + webRef.current?.open()} + label={t("settings.sections.clash.form.fields.webUI")} + /> { {isWIN && ( } /> )} - + ); }; diff --git a/clash-verge-rev/src/components/setting/setting-system.tsx b/clash-verge-rev/src/components/setting/setting-system.tsx index fcc03a8a9e..bcaf47f65b 100644 --- a/clash-verge-rev/src/components/setting/setting-system.tsx +++ b/clash-verge-rev/src/components/setting/setting-system.tsx @@ -41,20 +41,26 @@ const SettingSystem = ({ onError }: Props) => { }; return ( - + - + - + @@ -72,9 +78,8 @@ const SettingSystem = ({ onError }: Props) => { }} onGuard={async (e) => { if (isAdminMode) { - showNotice( - "info", - t("Administrator mode may not support auto launch"), + showNotice.info( + "settings.sections.system.tooltips.autoLaunchAdmin", ); } @@ -96,9 +101,12 @@ const SettingSystem = ({ onError }: Props) => { + } > { try { const info = await checkUpdate(); if (!info?.available) { - showNotice("success", t("Currently on the Latest Version")); + showNotice.success( + "settings.components.verge.advanced.notifications.latestVersion", + ); } else { updateRef.current?.open(); } } catch (err: any) { - showNotice("error", err.message || err.toString()); + showNotice.error(err); } }; const onExportDiagnosticInfo = useCallback(async () => { await exportDiagnosticInfo(); - showNotice("success", t("Copy Success"), 1000); - }, [t]); + showNotice.success( + "shared.feedback.notifications.common.copySuccess", + 1000, + ); + }, []); const copyVersion = useCallback(() => { navigator.clipboard.writeText(`v${version}`).then(() => { - showNotice("success", t("Version copied to clipboard"), 1000); + showNotice.success( + "settings.components.verge.advanced.notifications.versionCopied", + 1000, + ); }); - }, [t]); + }, []); return ( - + @@ -80,10 +88,10 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => { backupRef.current?.open()} - label={t("Backup Setting")} + label={t("settings.components.verge.advanced.fields.backupSetting")} extra={ } @@ -91,33 +99,45 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => { configRef.current?.open()} - label={t("Runtime Config")} + label={t("settings.components.verge.advanced.fields.runtimeConfig")} /> } /> - - - - - - - + + + + + + + } @@ -128,11 +148,11 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => { onClick={() => { exitApp(); }} - label={t("Exit")} + label={t("settings.components.verge.advanced.fields.exit")} /> { > } > diff --git a/clash-verge-rev/src/components/setting/setting-verge-basic.tsx b/clash-verge-rev/src/components/setting/setting-verge-basic.tsx index a5a0438db0..2f36677983 100644 --- a/clash-verge-rev/src/components/setting/setting-verge-basic.tsx +++ b/clash-verge-rev/src/components/setting/setting-verge-basic.tsx @@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next"; import { DialogRef } from "@/components/base"; import { TooltipIcon } from "@/components/base/base-tooltip-icon"; import { useVerge } from "@/hooks/use-verge"; -import { routers } from "@/pages/_routers"; +import { navItems } from "@/pages/_routers"; import { copyClashEnv } from "@/services/cmds"; import { supportedLanguages } from "@/services/i18n"; import { showNotice } from "@/services/noticeService"; @@ -76,11 +76,14 @@ const SettingVergeBasic = ({ onError }: Props) => { const onCopyClashEnv = useCallback(async () => { await copyClashEnv(); - showNotice("success", t("Copy Success"), 1000); - }, [t]); + showNotice.success( + "shared.feedback.notifications.common.copySuccess", + 1000, + ); + }, []); return ( - + @@ -89,7 +92,7 @@ const SettingVergeBasic = ({ onError }: Props) => { - + { - + { {OS !== "linux" && ( - + { onGuard={(e) => patchVerge({ tray_event: e })} > )} } @@ -161,7 +180,9 @@ const SettingVergeBasic = ({ onError }: Props) => { - + { onGuard={(e) => patchVerge({ start_page: e })} >