diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d28cc301e..0892e0574 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,8 +4,8 @@ "features": { "ghcr.io/devcontainers/features/node:2": { - "version": "24.22.1", - "pnpmVersion": "12.15.0" + "version": "26.13.0", + "pnpmVersion": "12.16.1" }, "ghcr.io/devcontainers/features/rust:1": { "version": "1.99.1", diff --git a/.github/workflows/autobuild.yml b/.github/workflows/autobuild.yml index 577d7d360..683171e50 100644 --- a/.github/workflows/autobuild.yml +++ b/.github/workflows/autobuild.yml @@ -51,7 +51,7 @@ jobs: - name: Install Node uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' - name: Install dependencies run: pnpm install --frozen-lockfile @@ -185,7 +185,7 @@ jobs: - name: Install Node uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' cache: 'pnpm' - name: Pnpm Cache @@ -282,7 +282,7 @@ jobs: - name: Install Node uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' cache: 'pnpm' - name: Pnpm Cache @@ -426,7 +426,7 @@ jobs: - name: Install Node uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' cache: 'pnpm' - name: Pnpm Cache @@ -524,7 +524,7 @@ jobs: - name: Install Node uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' - uses: pnpm/action-setup@v6.1.0 name: Install pnpm diff --git a/.github/workflows/contract-parity.yml b/.github/workflows/contract-parity.yml index d83f437a9..50e03fdf3 100644 --- a/.github/workflows/contract-parity.yml +++ b/.github/workflows/contract-parity.yml @@ -63,7 +63,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' cache: 'pnpm' - run: pnpm install --frozen-lockfile diff --git a/.github/workflows/cross_check.yaml b/.github/workflows/cross_check.yaml index 1366e0c7a..e923222bb 100644 --- a/.github/workflows/cross_check.yaml +++ b/.github/workflows/cross_check.yaml @@ -43,7 +43,7 @@ jobs: - name: Install Node uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' - uses: pnpm/action-setup@v6 name: Install pnpm diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index ec8a7d320..c3256c5d4 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -103,7 +103,7 @@ jobs: if: github.event.inputs[matrix.input] == 'true' uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' cache: 'pnpm' - name: Pnpm Cache diff --git a/.github/workflows/frontend-check.yml b/.github/workflows/frontend-check.yml index d18b3993d..923a7e61c 100644 --- a/.github/workflows/frontend-check.yml +++ b/.github/workflows/frontend-check.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' cache: 'pnpm' - name: Restore pnpm cache diff --git a/.github/workflows/lint-clippy.yml b/.github/workflows/lint-clippy.yml index 8a623f515..75983ef23 100644 --- a/.github/workflows/lint-clippy.yml +++ b/.github/workflows/lint-clippy.yml @@ -8,6 +8,7 @@ on: - "Cargo.toml" - "Cargo.lock" - "rust-toolchain.toml" + - ".cargo/config.toml" - ".github/workflows/lint-clippy.yml" workflow_dispatch: env: diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 000000000..912b7c821 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,79 @@ +name: Prepare Release + +on: + pull_request_target: + types: [edited, closed] + branches: [dev] + workflow_dispatch: + inputs: + channel: + description: Release channel + required: true + type: choice + options: [rc, stable] + default: rc + release_tag: + description: 'Optional tag: vX.Y.Z-rc[.N] for RC, vX.Y.Z for stable' + required: false + type: string + +permissions: + contents: write + pull-requests: write + +concurrency: + group: prepare-release + cancel-in-progress: false + +jobs: + prepare: + if: >- + ${{ github.event_name == 'workflow_dispatch' || + (github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login == 'github-actions[bot]' && + ((startsWith(github.event.pull_request.head.ref, 'release/v') && + ((github.event.action == 'closed' && github.event.pull_request.merged == true) || + (github.event.changes.body && github.event.pull_request.state == 'open' && + contains(github.event.pull_request.body, '[x] Refresh this PR from latest dev')))) || + (startsWith(github.event.pull_request.head.ref, 'next-dev/v') && + github.event.changes.body && github.event.pull_request.state == 'open' && + (contains(github.event.pull_request.body, '[x] Refresh this PR from latest dev') || + contains(github.event.pull_request.body, '[x] Use the next minor version') != + contains(github.event.changes.body.from, '[x] Use the next minor version'))))) }} + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Require the dev branch + if: github.event_name == 'workflow_dispatch' + env: + SOURCE_REF: ${{ github.ref }} + run: | + if [[ "$SOURCE_REF" != 'refs/heads/dev' ]]; then + echo '::error::Run Prepare Release from the dev branch.' + exit 1 + fi + + - uses: actions/checkout@v7 + with: + ref: refs/heads/dev + fetch-depth: 0 + + - uses: actions/setup-node@v7 + with: + node-version: '26.13.0' + + - uses: pnpm/action-setup@v6 + if: github.event.action != 'closed' + with: + run_install: false + + - run: pnpm install --frozen-lockfile --ignore-scripts + if: github.event.action != 'closed' + + - name: Create or refresh version or publication PR + env: + RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_CHANNEL: ${{ inputs.channel }} + RELEASE_COMMAND: ${{ github.event.action == 'closed' && 'publish-pr' || startsWith(github.event.pull_request.head.ref, 'next-dev/') && 'next-dev' || 'prepare' }} + run: node scripts-workflow/release.mjs "$RELEASE_COMMAND" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e075ad35..335f99087 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,9 @@ name: Release Build on: + pull_request: + types: [edited] + branches: [main] push: # -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新 tags: @@ -11,75 +14,59 @@ env: RUST_BACKTRACE: short HUSKY: 0 concurrency: - group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}' - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + group: release-publication + cancel-in-progress: false jobs: - check_tag_version: - name: Check Release Tag and package.json Version Consistency + prepare_release: + name: Approve Snapshot and Prepare Draft Release + if: >- + ${{ github.event_name == 'push' || + (github.event.changes.body && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login == 'github-actions[bot]' && + startsWith(github.event.pull_request.head.ref, 'publish/v') && + contains(github.event.pull_request.body, '[x] Publish this approved release')) }} runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + outputs: + release_tag: ${{ steps.source.outputs.release_tag }} + source_sha: ${{ steps.source.outputs.source_sha }} + channel: ${{ steps.source.outputs.channel }} + release_id: ${{ steps.draft.outputs.id }} steps: - - name: Checkout repository + - name: Checkout trusted release tooling uses: actions/checkout@v7 with: + ref: refs/heads/dev fetch-depth: 0 - - - name: Check if tag is from main branch - run: | - TAG_REF="${GITHUB_REF##*/}" - echo "Checking if tag $TAG_REF is from main branch..." - - TAG_COMMIT=$(git rev-list -n 1 $TAG_REF) - MAIN_COMMITS=$(git rev-list origin/main) - - if echo "$MAIN_COMMITS" | grep -q "$TAG_COMMIT"; then - echo "✅ Tag $TAG_REF is from main branch" - else - echo "❌ Tag $TAG_REF is not from main branch" - echo "This release workflow only accepts tags from main branch." - exit 1 - fi - - - name: Check tag and package.json version - run: | - TAG_REF="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" - echo "Current tag: $TAG_REF" - - PKG_VERSION=$(jq -r .version package.json) - echo "package.json version: $PKG_VERSION" - - EXPECTED_TAG="v$PKG_VERSION" - - if [[ "$TAG_REF" != "$EXPECTED_TAG" ]]; then - echo "❌ Version mismatch:" - echo " Git tag : $TAG_REF" - echo " package.json : $EXPECTED_TAG" - exit 1 - fi - - echo "✅ Tag and package.json version are consistent." - - prepare_release: - name: Prepare Draft Release - runs-on: ubuntu-latest - needs: [check_tag_version] - outputs: - release_id: ${{ steps.prepare_release.outputs.id }} - steps: - - name: Create or update draft release - id: prepare_release - uses: softprops/action-gh-release@v3 + - uses: actions/setup-node@v7 with: - tag_name: ${{ github.ref_name }} - target_commitish: ${{ github.sha }} - name: 'Clash Verge Rev ${{ github.ref_name }}' - body: 'Draft release, will be updated later.' - token: ${{ secrets.GITHUB_TOKEN }} - draft: true - prerelease: ${{ contains(github.ref_name, '-rc') }} + node-version: '26.13.0' + - name: Validate source and publish the approved snapshot + id: source + env: + RELEASE_COMMAND: ${{ github.event_name == 'push' && 'validate-tag' || 'publish' }} + run: node scripts-workflow/release.mjs "$RELEASE_COMMAND" + + - name: Create or reuse draft release + id: draft + env: + RELEASE_TAG: ${{ steps.source.outputs.release_tag }} + SOURCE_SHA: ${{ steps.source.outputs.source_sha }} + run: node scripts-workflow/release.mjs prepare-draft update_tag: - name: Update tag + name: Publish Completed Release + env: + RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }} + outputs: + source_sha: ${{ needs.prepare_release.outputs.source_sha }} + channel: ${{ needs.prepare_release.outputs.channel }} runs-on: ubuntu-latest needs: [ @@ -91,6 +78,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare_release.outputs.source_sha }} - name: Group changelog platforms run: bash ./scripts-workflow/group_platforms.sh @@ -103,7 +92,7 @@ jobs: - name: Set Env run: | echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV - TAG_REF="${GITHUB_REF##*/}" + TAG_REF="$RELEASE_TAG" echo "TAG_NAME=$TAG_REF" >> $GITHUB_ENV VERSION=$(echo "$TAG_REF" | sed 's/^v//') echo "VERSION=$VERSION" >> $GITHUB_ENV @@ -149,10 +138,16 @@ jobs: Created at ${{ env.BUILDTIME }}. EOF + - name: Verify release tag still points to the approved source + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SOURCE_SHA: ${{ needs.prepare_release.outputs.source_sha }} + run: node scripts-workflow/release.mjs verify-target + - name: Publish Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PRERELEASE: ${{ contains(github.ref_name, '-rc') }} + PRERELEASE: ${{ contains(env.RELEASE_TAG, '-rc') }} run: | release_id="${{ needs.prepare_release.outputs.release_id }}" @@ -181,6 +176,8 @@ jobs: release: name: Release Build needs: [prepare_release] + env: + RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }} strategy: fail-fast: false matrix: @@ -200,6 +197,8 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare_release.outputs.source_sha }} - name: Install Rust Stable uses: dtolnay/rust-toolchain@master @@ -230,7 +229,7 @@ jobs: - name: Install Node uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' - uses: pnpm/action-setup@v6 name: Install pnpm @@ -249,7 +248,6 @@ jobs: echo "Rust target ${{ matrix.target }} installed." - name: Tauri build - # 上游 5.24 修改了 latest.json 的生成逻辑,且依赖 tauri-plugin-update 2.10.0 暂未发布,故锁定在 0.5.23 版本 uses: tauri-apps/tauri-action@v1.0.0 env: NODE_OPTIONS: '--max_old_space_size=4096' @@ -264,11 +262,14 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} with: releaseId: ${{ needs.prepare_release.outputs.release_id }} - tagName: ${{ github.ref_name }} + tagName: ${{ env.RELEASE_TAG }} releaseBody: 'Draft release, will be updated later.' + releaseDraft: true + prerelease: ${{ needs.prepare_release.outputs.channel == 'rc' }} + releaseCommitish: ${{ needs.prepare_release.outputs.source_sha }} tauriScript: pnpm args: --target ${{ matrix.target }} - includeUpdaterJson: true + uploadUpdaterJson: ${{ needs.prepare_release.outputs.channel == 'stable' }} - name: Attest Windows bundles if: matrix.os == 'windows-latest' @@ -293,6 +294,8 @@ jobs: release-for-linux-arm: name: Release Build for Linux ARM needs: [prepare_release] + env: + RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }} strategy: fail-fast: false matrix: @@ -307,6 +310,8 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare_release.outputs.source_sha }} - name: Install Rust Stable uses: dtolnay/rust-toolchain@master @@ -331,7 +336,7 @@ jobs: - name: Install Node uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' - name: Install pnpm uses: pnpm/action-setup@v6 @@ -420,7 +425,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release upload "${{ github.ref_name }}" \ + gh release upload "$RELEASE_TAG" \ target/${{ matrix.target }}/release/bundle/deb/*.deb \ target/${{ matrix.target }}/release/bundle/rpm/*.rpm \ --clobber @@ -428,6 +433,8 @@ jobs: release-for-fixed-webview2: name: Release Build for Fixed WebView2 needs: [prepare_release] + env: + RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }} strategy: fail-fast: false matrix: @@ -442,6 +449,8 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare_release.outputs.source_sha }} - name: Install Rust Stable uses: dtolnay/rust-toolchain@master @@ -466,7 +475,7 @@ jobs: - name: Install Node uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' - uses: pnpm/action-setup@v6 name: Install pnpm @@ -532,22 +541,55 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | $files = Get-ChildItem "target\${{ matrix.target }}\release\bundle\nsis\*setup*" | Select-Object -ExpandProperty FullName - gh release upload "${{ github.ref_name }}" $files --clobber + gh release upload "$env:RELEASE_TAG" $files --clobber if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + prepare-next-dev: + name: Prepare Next Dev Version + if: ${{ needs.update_tag.outputs.channel == 'stable' }} + needs: [prepare_release, update_tag] + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + concurrency: + group: prepare-release + cancel-in-progress: false + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }} + SOURCE_SHA: ${{ needs.prepare_release.outputs.source_sha }} + steps: + - uses: actions/checkout@v7 + with: + ref: refs/heads/dev + fetch-depth: 0 + - uses: actions/setup-node@v7 + with: + node-version: '26.13.0' + - uses: pnpm/action-setup@v6 + with: + run_install: false + - name: Prepare version and changelog PR + run: | + pnpm install --frozen-lockfile --ignore-scripts + node scripts-workflow/release.mjs next-dev + release-update: - if: ${{ !contains(github.ref_name, '-rc') }} + if: ${{ needs.update_tag.outputs.channel == 'stable' }} name: Release Update runs-on: ubuntu-latest needs: [update_tag] steps: - name: Checkout repository uses: actions/checkout@v7 + with: + ref: ${{ needs.update_tag.outputs.source_sha }} - name: Install Node uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' - uses: pnpm/action-setup@v6 name: Install pnpm @@ -563,17 +605,19 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} release-update-for-fixed-webview2: - if: ${{ !contains(github.ref_name, '-rc') }} + if: ${{ needs.update_tag.outputs.channel == 'stable' }} runs-on: ubuntu-latest needs: [update_tag] steps: - name: Checkout repository uses: actions/checkout@v7 + with: + ref: ${{ needs.update_tag.outputs.source_sha }} - name: Install Node uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' - uses: pnpm/action-setup@v6 name: Install pnpm @@ -589,7 +633,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} submit-to-winget: - if: ${{ !contains(github.ref_name, '-rc') }} + if: ${{ needs.update_tag.outputs.channel == 'stable' }} name: Submit to Winget runs-on: ubuntu-latest needs: [update_tag, release-update] @@ -597,6 +641,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 with: + ref: ${{ needs.update_tag.outputs.source_sha }} fetch-depth: 0 - name: Get Version run: | @@ -613,7 +658,7 @@ jobs: token: ${{ secrets.WINGET_TOKEN }} notify-telegram: - if: ${{ !contains(github.ref_name, '-rc') }} + if: ${{ needs.update_tag.outputs.channel == 'stable' }} name: Notify Telegram runs-on: ubuntu-latest needs: @@ -626,6 +671,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v7 + with: + ref: ${{ needs.update_tag.outputs.source_sha }} - name: Group changelog platforms run: bash ./scripts-workflow/group_platforms.sh @@ -638,7 +685,7 @@ jobs: - name: Install Node uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' - uses: pnpm/action-setup@v6 name: Install pnpm diff --git a/.github/workflows/rustfmt.yml b/.github/workflows/rustfmt.yml index 1ae6035dd..99df739eb 100644 --- a/.github/workflows/rustfmt.yml +++ b/.github/workflows/rustfmt.yml @@ -9,6 +9,8 @@ on: paths: - "src-tauri/**" - "**/*.rs" + - "rustfmt.toml" + - "rust-toolchain.toml" - ".github/workflows/rustfmt.yml" permissions: diff --git a/.github/workflows/signpath-test.yml b/.github/workflows/signpath-test.yml index 2d2055d24..5fbe310ef 100644 --- a/.github/workflows/signpath-test.yml +++ b/.github/workflows/signpath-test.yml @@ -28,7 +28,7 @@ jobs: - name: Install Node uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' - name: Install pnpm uses: pnpm/action-setup@v6 diff --git a/.github/workflows/telegram-notify.yml b/.github/workflows/telegram-notify.yml index ac3e7e82d..c0a484d60 100644 --- a/.github/workflows/telegram-notify.yml +++ b/.github/workflows/telegram-notify.yml @@ -34,7 +34,7 @@ jobs: - name: Install Node uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' - uses: pnpm/action-setup@v6 name: Install pnpm diff --git a/.github/workflows/updater.yml b/.github/workflows/updater.yml index f55927bf6..947e0ee9f 100644 --- a/.github/workflows/updater.yml +++ b/.github/workflows/updater.yml @@ -15,7 +15,7 @@ jobs: - name: Install Node uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' - uses: pnpm/action-setup@v6 name: Install pnpm @@ -39,7 +39,7 @@ jobs: - name: Install Node uses: actions/setup-node@v7 with: - node-version: '24.22.1' + node-version: '26.13.0' - uses: pnpm/action-setup@v6 name: Install pnpm diff --git a/.tool-versions b/.tool-versions index f10dc6119..eeb93234c 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -nodejs 24.22.1 -pnpm 12.15.0 +nodejs 26.13.0 +pnpm 12.16.1 rust 1.99.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1b464a17..005d67bdc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,7 @@ Before contributing, you need to set up your development environment. Follow the ### Prerequisites 1. **Install Rust and Node.js** - Our project requires both Rust and Node.js. Follow the official installation instructions [here](https://tauri.app/start/prerequisites/). + Follow the [Tauri prerequisites](https://tauri.app/start/prerequisites/) for your operating system. Use the Node.js version in [`.tool-versions`](.tool-versions); [`rust-toolchain.toml`](rust-toolchain.toml) selects the Rust version and required components when you run Cargo from this repository. ### Windows Users @@ -51,18 +51,21 @@ rustup set default-host x86_64-pc-windows-msvc ### Install Node.js Package Manager -Enable `corepack`: +[Corepack is no longer bundled with Node.js starting with Node.js 25](https://nodejs.org/api/corepack.html). Install it globally if it is not already available, then enable it: ```bash +npm install --global corepack corepack enable ``` +Run pnpm from this repository so Corepack selects the version pinned by `packageManager` in [`package.json`](package.json). + ### Install Project Dependencies Node.js dependencies: ```bash -pnpm install +pnpm install --frozen-lockfile ``` Ubuntu-only system packages: @@ -78,14 +81,24 @@ pnpm run prebuild pnpm run prebuild --force # Re-download and overwrite Mihomo core and service binaries ``` +### Prepare the Development Service Source + +`pnpm dev` builds the development service from a sibling checkout of [`clash-verge-service-ipc`](https://github.com/clash-verge-rev/clash-verge-service-ipc). The binaries downloaded by `pnpm prebuild` do not replace this source checkout. + +Check the `clash_verge_service_ipc` dependency in [`src-tauri/Cargo.toml`](src-tauri/Cargo.toml), then clone the service repository alongside `clash-verge-rev` and check out the matching tag. If the sibling directory already exists, reuse it and check its version rather than overwriting it. Without this checkout, `pnpm dev` fails when it tries to access `../clash-verge-service-ipc/Cargo.toml`. + ### Run the Development Server +Run these commands from the root of `clash-verge-rev`: + ```bash -pnpm dev # Standard -pnpm dev:diff # If an app instance already exists -pnpm dev:tauri # Run Tauri development mode +pnpm dev # Build the development service and start the desktop app +pnpm dev:diff # Alias of pnpm dev +pnpm dev:tauri # Run Tauri directly with the tauri-dev feature ``` +For frontend-only development, use `pnpm web:dev`. It does not start the Rust backend or build the development service. + ### Build the Project Standard build: diff --git a/Cargo.lock b/Cargo.lock index 384c6330f..2cfa5fdd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,6 +33,7 @@ dependencies = [ "cipher", "cpubits", "cpufeatures 0.3.0", + "zeroize", ] [[package]] @@ -135,15 +136,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "arboard" version = "3.6.1" @@ -1175,7 +1167,7 @@ dependencies = [ "windows-service", "windows-sys 0.61.2", "winreg 0.56.0", - "zip 9.0.4", + "zip", ] [[package]] @@ -1342,7 +1334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1870,17 +1862,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -2034,7 +2015,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2293,7 +2274,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2809,7 +2790,7 @@ dependencies = [ "gobject-sys 0.21.5", "libc", "system-deps 7.0.8", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3363,7 +3344,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -4051,9 +4032,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lzma-rust2" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb" +checksum = "f1c46f3fcd2e888afdb01a07efc071cb21300e07c68d32c3e8b01b5c8cb1fcaa" dependencies = [ "sha2 0.11.1", ] @@ -4234,7 +4215,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4401,7 +4382,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4878,7 +4859,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5543,7 +5524,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6047,7 +6028,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6060,7 +6041,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6119,7 +6100,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6739,7 +6720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7088,9 +7069,9 @@ checksum = "c0e973b34477b7823833469eb0f5a3a60370fef7a453e02d751b59180d0a5a05" [[package]] name = "tao" -version = "0.37.1" +version = "0.37.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" +checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548" dependencies = [ "bitflags 2.13.2", "block2", @@ -7575,11 +7556,11 @@ dependencies = [ [[package]] name = "tauri-plugin-updater" -version = "2.14.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7259989d4fe11b2bf51f7d671642249b1837c4ae2da1430dc713ae38f98ceeec" +checksum = "d957abbcb3f9f6c76d22a3a0f012f30880640d0bccb5634d2b12a9937e4c872b" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "dirs 7.0.0", "flate2", "futures-util", @@ -7604,7 +7585,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.61.2", - "zip 4.6.1", + "zip", ] [[package]] @@ -7805,10 +7786,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8474,7 +8455,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8553,9 +8534,9 @@ dependencies = [ [[package]] name = "typed-path" -version = "0.12.3" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" [[package]] name = "typeid" @@ -8583,7 +8564,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9125,7 +9106,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9146,7 +9127,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "windows-sys 0.61.2", + "windows-sys 0.60.2", "windows-version", ] @@ -9721,7 +9702,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9762,11 +9743,11 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wry" -version = "0.57.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" +checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "block2", "cookie", "crossbeam-channel", @@ -9787,7 +9768,6 @@ dependencies = [ "objc2-foundation", "objc2-ui-kit", "objc2-web-kit", - "once_cell", "percent-encoding", "raw-window-handle", "sha2 0.10.9", @@ -10054,21 +10034,9 @@ dependencies = [ [[package]] name = "zip" -version = "4.6.1" +version = "9.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" -dependencies = [ - "arbitrary", - "crc32fast", - "indexmap 2.14.0", - "memchr", -] - -[[package]] -name = "zip" -version = "9.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22" +checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" dependencies = [ "aes", "bzip2", @@ -10087,6 +10055,7 @@ dependencies = [ "sha1 0.11.0", "time", "typed-path", + "windows-sys 0.61.2", "zeroize", "zopfli", "zstd", diff --git a/Changelog.md b/Changelog.md index a8213f79c..c1af4d713 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,15 +1,25 @@ ## v2.5.9 +- **Mihomo(Meta) 内核升级至 v1.19.33** +
🐞 修复问题 - 修复规则扩展包含空列表项时,「编辑规则」界面报错的问题 +- 修复 重启应用后定时自动备份不执行的问题 - 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口 - 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置 - 修复 首页 网站测速失败后卡片一直显示测试中 - 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新 - 修复 代理 在全局模式下无法展开节点组 - 修复 自定义 CSS 无法修改编辑器字体和字号的问题 +- 修复恢复备份的同时修改配置,可能导致配置丢失或错乱的问题 +- 修复切换规则模式与配置变更并发时可能丢失或错乱的问题 +- 修复 WebDAV 设置清空后无法保存 + +**🍎 macOS** + +- macOS:修复 Bigsur 上首页测速和流量图无法显示的问题 **🐧 Linux** diff --git a/eslint.config.ts b/eslint.config.ts index 1e1840416..2f3aa9dc0 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -129,6 +129,7 @@ export default defineConfig([ 'eslint.config.ts', `vite.config.mts`, 'scripts/*.mjs', + 'scripts-workflow/*.mjs', 'tests/*.ts', 'src/polyfills/*.js', 'tests/*.mjs', @@ -238,6 +239,13 @@ export default defineConfig([ }, }, }, + { + files: ['scripts-workflow/*.mjs'], + languageOptions: { + globals: globals.node, + }, + rules: { 'es-x/no-object-groupby': 'off' }, + }, { files: ['scripts/perf/*.{mjs,mts}'], languageOptions: { diff --git a/package.json b/package.json index 2595c6d64..53eed07ba 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "@tauri-apps/plugin-fs": "2.7.1", "@tauri-apps/plugin-http": "2.8.3", "@tauri-apps/plugin-opener": "2.7.2", - "@tauri-apps/plugin-updater": "2.14.0", + "@tauri-apps/plugin-updater": "2.14.1", "ahooks": "^3.10.0", "cidr-block": "^2.3.0", "compare-versions": "^6.1.1", @@ -79,12 +79,11 @@ "react-dom": "19.3.1", "react-error-boundary": "6.1.6", "react-hook-form": "^7.88.0", - "react-i18next": "17.1.8", + "react-i18next": "17.1.9", "react-markdown": "10.1.0", "react-router": "^8.4.0", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", - "remark-gfm": "^4.0.1", "swr": "^2.5.1", "tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main", "types-pac": "^1.0.3", @@ -97,7 +96,7 @@ "@eslint/js": "^10.0.1", "@tauri-apps/cli": "2.12.3", "@types/lodash-es": "^4.17.12", - "@types/node": "^24.13.6", + "@types/node": "^26.0.0", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@types/validator": "^13.15.10", @@ -118,12 +117,12 @@ "eslint-plugin-react-refresh": "^0.5.7", "eslint-plugin-sukka": "^10.2.0", "eslint-plugin-unused-imports": "^4.4.1", - "glob": "^13.0.6", + "glob": "^14.0.0", "globals": "^17.12.0", "https-proxy-agent": "^9.1.0", "husky": "^9.1.7", "jiti": "^2.7.0", - "knip": "6.43.0", + "knip": "6.44.1", "lint-staged": "^17.5.1", "sass": "^1.104.1", "tar": "^7.5.22", @@ -135,7 +134,7 @@ "vite-plugin-svgr": "^5.2.0", "vitest": "^5.0.1", "webdriverio": "10.1.0", - "ws": "8.22.0" + "ws": "8.22.1" }, "lint-staged": { "*.{ts,tsx,js,mjs}": [ @@ -147,5 +146,5 @@ ] }, "type": "module", - "packageManager": "pnpm@12.15.0" + "packageManager": "pnpm@12.16.1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b3e45be0..a38283819 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.15.0 - version: 12.15.0 + specifier: 12.16.1 + version: 12.16.1 packages: - '@pnpm/exe.android-arm64@12.15.0': - resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + '@pnpm/exe.android-arm64@12.16.1': + resolution: {integrity: sha512-PKf78Ew+i0CBYf0Xc+eMcqQiqyBT+u8XCVg1F5roc98/6wWfdYlWhrUMmEBiKWaJ3JqHxrtt8l1NwZ7qfJt2/Q==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.15.0': - resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + '@pnpm/exe.android-x64@12.16.1': + resolution: {integrity: sha512-xdu7gMbB98pdvhwMeA6612Of515FDlRUtfA/1f7nWPZfPLnEdSRBO2u+1N0XBtQ/ennhPH/lrxB1gC6gnR/cdg==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.15.0': - resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + '@pnpm/exe.darwin-arm64@12.16.1': + resolution: {integrity: sha512-pVbmIIbhixiLvADLu1WA3TaW8FZy/D72R1De27WbbFx8/2mxvnsTyFVm4fO9kjpjMY7RzJd48CiQ96p8fO3ebQ==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.15.0': - resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + '@pnpm/exe.darwin-x64@12.16.1': + resolution: {integrity: sha512-xZvQBFqcZjO7vFKTK3XsJpqY6lnBL9EWSLCzVUPq3kZ94+D96gwJCa76xxGNK8Cj32f4eeM91Qg+yNRR4sRpDw==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.15.0': - resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + '@pnpm/exe.freebsd-x64@12.16.1': + resolution: {integrity: sha512-zTtunfi9mBhRXiXEJYfwiJj0tk7MgTSfqDSRh+Az9FZ9THazVIiQiVNS8jpcsftDOqu7LKNffQdjmgwHDThfUg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.15.0': - resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + '@pnpm/exe.linux-arm64-musl@12.16.1': + resolution: {integrity: sha512-BJ3mOmcmQgcUo92Ee/WfEOt1pRiv3y2MMAkhEiYHd85UiPpHTfFhv+SpCbALmfExzXk3UlaRtMYwHlkQymc4VA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.15.0': - resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + '@pnpm/exe.linux-arm64@12.16.1': + resolution: {integrity: sha512-JaRd5gNxwdNGBBkrZFt8bknrK4YmHVJBW0JMhUQ0fJ0dEYp6C2GT9ExaCqOO8EwYEyqM+jpKgJIK+8WFaJy3ew==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.15.0': - resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + '@pnpm/exe.linux-ppc64@12.16.1': + resolution: {integrity: sha512-FnSV0Jv40S1cMv7vFNWwvu0X567Lec4L4HCRead3CFcD6zQDNbGw81s8N5bcKPahtPD9ApVuhsbmFCx4rw6RMw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.15.0': - resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + '@pnpm/exe.linux-riscv64@12.16.1': + resolution: {integrity: sha512-C1Sww4FN5IttD4atRRhAKmQtSGWVp04ZAKNxO8pfu2bkRPwOW+7v+2DH1Xs1W+kZYYpO86BYDvUcadefF+AgaQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.15.0': - resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + '@pnpm/exe.linux-s390x@12.16.1': + resolution: {integrity: sha512-kn2fautsTcxIMODoovQVqZyfpDRdNgGkoeVqNdrKqbeBBvrEl+fXLkmZiq5xDht1LMr4sYiCNcsMfNMT6JPFmg==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.15.0': - resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + '@pnpm/exe.linux-x64-musl@12.16.1': + resolution: {integrity: sha512-PM6ccHCOe/O9SsTflTJTYWfwogJZa6EmUvpnwZgiPguJBddh20wDQKgxUPXf48rDsPm9vMsIij0uHswwwbXL8g==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.15.0': - resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + '@pnpm/exe.linux-x64@12.16.1': + resolution: {integrity: sha512-RK//8ZLcXGW8A2k8bQOXZDBN1Qu92zN4dYnG1bzj1zoQ1fkZ6jNzZ1AvXtsfGVma795CJtvoaF5bLXf4l/FIUw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.15.0': - resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + '@pnpm/exe.win32-arm64@12.16.1': + resolution: {integrity: sha512-Ox3kNNVxtGw6pK7SVejmeOUgHhUTZgAM7NQQ6cU7frPw3ix9+j7d2MGbRg3fyEFAaKWq8ZAZLTQGcDyLnAu+qA==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.15.0': - resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + '@pnpm/exe.win32-x64@12.16.1': + resolution: {integrity: sha512-k+FhRJ0ttphFoFIVB1Sk4v76YJAulafmA/Ly57cysesUhhIzVlApeOShUz0jXubKx317TThlEfpQrukldnqORA==} cpu: [x64] os: [win32] - pnpm@12.15.0: - resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + pnpm@12.16.1: + resolution: {integrity: sha512-CR0d7nknV1Zhsl2rPCrjHK/BxU1rce1qZgDPDByxBDSkR+rhQBXxsj4ua+3u1535SpMuIWTSgyKA3OP7MvZW0Q==} engines: {node: '>=18'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.15.0': + '@pnpm/exe.android-arm64@12.16.1': optional: true - '@pnpm/exe.android-x64@12.15.0': + '@pnpm/exe.android-x64@12.16.1': optional: true - '@pnpm/exe.darwin-arm64@12.15.0': + '@pnpm/exe.darwin-arm64@12.16.1': optional: true - '@pnpm/exe.darwin-x64@12.15.0': + '@pnpm/exe.darwin-x64@12.16.1': optional: true - '@pnpm/exe.freebsd-x64@12.15.0': + '@pnpm/exe.freebsd-x64@12.16.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.15.0': + '@pnpm/exe.linux-arm64-musl@12.16.1': optional: true - '@pnpm/exe.linux-arm64@12.15.0': + '@pnpm/exe.linux-arm64@12.16.1': optional: true - '@pnpm/exe.linux-ppc64@12.15.0': + '@pnpm/exe.linux-ppc64@12.16.1': optional: true - '@pnpm/exe.linux-riscv64@12.15.0': + '@pnpm/exe.linux-riscv64@12.16.1': optional: true - '@pnpm/exe.linux-s390x@12.15.0': + '@pnpm/exe.linux-s390x@12.16.1': optional: true - '@pnpm/exe.linux-x64-musl@12.15.0': + '@pnpm/exe.linux-x64-musl@12.16.1': optional: true - '@pnpm/exe.linux-x64@12.15.0': + '@pnpm/exe.linux-x64@12.16.1': optional: true - '@pnpm/exe.win32-arm64@12.15.0': + '@pnpm/exe.win32-arm64@12.16.1': optional: true - '@pnpm/exe.win32-x64@12.15.0': + '@pnpm/exe.win32-x64@12.16.1': optional: true - pnpm@12.15.0: + pnpm@12.16.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.15.0 - '@pnpm/exe.android-x64': 12.15.0 - '@pnpm/exe.darwin-arm64': 12.15.0 - '@pnpm/exe.darwin-x64': 12.15.0 - '@pnpm/exe.freebsd-x64': 12.15.0 - '@pnpm/exe.linux-arm64': 12.15.0 - '@pnpm/exe.linux-arm64-musl': 12.15.0 - '@pnpm/exe.linux-ppc64': 12.15.0 - '@pnpm/exe.linux-riscv64': 12.15.0 - '@pnpm/exe.linux-s390x': 12.15.0 - '@pnpm/exe.linux-x64': 12.15.0 - '@pnpm/exe.linux-x64-musl': 12.15.0 - '@pnpm/exe.win32-arm64': 12.15.0 - '@pnpm/exe.win32-x64': 12.15.0 + '@pnpm/exe.android-arm64': 12.16.1 + '@pnpm/exe.android-x64': 12.16.1 + '@pnpm/exe.darwin-arm64': 12.16.1 + '@pnpm/exe.darwin-x64': 12.16.1 + '@pnpm/exe.freebsd-x64': 12.16.1 + '@pnpm/exe.linux-arm64': 12.16.1 + '@pnpm/exe.linux-arm64-musl': 12.16.1 + '@pnpm/exe.linux-ppc64': 12.16.1 + '@pnpm/exe.linux-riscv64': 12.16.1 + '@pnpm/exe.linux-s390x': 12.16.1 + '@pnpm/exe.linux-x64': 12.16.1 + '@pnpm/exe.linux-x64-musl': 12.16.1 + '@pnpm/exe.win32-arm64': 12.16.1 + '@pnpm/exe.win32-x64': 12.16.1 --- lockfileVersion: '9.0' @@ -218,8 +218,8 @@ importers: specifier: 2.7.2 version: 2.7.2 '@tauri-apps/plugin-updater': - specifier: 2.14.0 - version: 2.14.0 + specifier: 2.14.1 + version: 2.14.1 ahooks: specifier: ^3.10.0 version: 3.10.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -275,8 +275,8 @@ importers: specifier: ^7.88.0 version: 7.88.0(react@19.3.1) react-i18next: - specifier: 17.1.8 - version: 17.1.8(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3) + specifier: 17.1.9 + version: 17.1.9(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3) react-markdown: specifier: 10.1.0 version: 10.1.0(@types/react@19.3.0)(react@19.3.1)(supports-color@7.2.0) @@ -289,9 +289,6 @@ importers: rehype-sanitize: specifier: ^6.0.0 version: 6.0.0 - remark-gfm: - specifier: ^4.0.1 - version: 4.0.1(supports-color@7.2.0) swr: specifier: ^2.5.1 version: 2.5.1(react@19.3.1) @@ -324,8 +321,8 @@ importers: specifier: ^4.17.12 version: 4.17.12 '@types/node': - specifier: ^24.13.6 - version: 24.13.6 + specifier: ^26.0.0 + version: 26.6.5 '@types/react': specifier: 19.3.0 version: 19.3.0 @@ -337,10 +334,10 @@ importers: version: 13.15.10 '@vitejs/plugin-legacy': specifier: ^8.2.3 - version: 8.2.3(supports-color@7.2.0)(terser@5.51.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)) + version: 8.2.3(supports-color@7.2.0)(terser@5.51.2)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)) '@vitejs/plugin-react': specifier: ^6.1.1 - version: 6.1.1(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)) + version: 6.1.1(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)) '@wdio/tauri-service': specifier: 1.5.0 version: 1.5.0(expect-webdriverio@6.1.0)(supports-color@7.2.0)(webdriverio@10.1.0(supports-color@7.2.0)(yauzl@2.10.0)) @@ -387,8 +384,8 @@ importers: specifier: ^4.4.1 version: 4.4.1(@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.11.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0)(supports-color@7.2.0)) glob: - specifier: ^13.0.6 - version: 13.0.6 + specifier: ^14.0.0 + version: 14.0.3 globals: specifier: ^17.12.0 version: 17.12.0 @@ -402,8 +399,8 @@ importers: specifier: ^2.7.0 version: 2.7.0 knip: - specifier: 6.43.0 - version: 6.43.0 + specifier: 6.44.1 + version: 6.44.1 lint-staged: specifier: ^17.5.1 version: 17.5.1 @@ -427,19 +424,19 @@ importers: version: 8.70.0(eslint@10.11.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) vite: specifier: ^8.3.0 - version: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1) + version: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1) vite-plugin-svgr: specifier: ^5.2.0 - version: 5.2.0(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)) + version: 5.2.0(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)) vitest: specifier: ^5.0.1 - version: 5.0.1(@types/node@24.13.6)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)) + version: 5.0.1(@types/node@26.6.5)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)) webdriverio: specifier: 10.1.0 version: 10.1.0(supports-color@7.2.0)(yauzl@2.10.0) ws: - specifier: 8.22.0 - version: 8.22.0 + specifier: 8.22.1 + version: 8.22.1 packages: @@ -1387,6 +1384,9 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@isaacs/nonrecursive-glob-match@1.0.2': + resolution: {integrity: sha512-GJPHIgPPtFl+nLhFHyF0zZoFcOFLsZWbHFFzo+Zr+PWDXJbl9hHxb2w+9SiF2fHmLgKaXIpUzQo3SbBBZ9WObw==} + '@jest/diff-sequences@30.5.0': resolution: {integrity: sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2218,8 +2218,8 @@ packages: '@tauri-apps/plugin-opener@2.7.2': resolution: {integrity: sha512-LyybP1VSzTMQiaFYcsbAse47KpivlyXdLZMQzGLEnkgQacGESnpjAfFoQ6h5sqWgAdZDH9szYjPzHMpv0Sz+TQ==} - '@tauri-apps/plugin-updater@2.14.0': - resolution: {integrity: sha512-gdylJ9tger4hkyOn24QZd6MNOKVo2Eh53iw5th2Qj1vKTR/+u4PIwJWuiis5hohYvobn6iOvlnALfrqIPSzh7Q==} + '@tauri-apps/plugin-updater@2.14.1': + resolution: {integrity: sha512-Hr7nlBm1wT6xbwzjZ/S36YULazu8MNlj5vvOwMl47fXI7HTa5Bjl1PbDEwVHaZTAKzE1VZsUy35UqnbrQxmYQw==} '@tsrs/darwin-arm64@0.20.0-ts7.1.0-beta': resolution: {integrity: sha512-mhHj8QGUmdJDBvJEnMXx5P/jvAKXYU3hy/z3qxrWBo8XHlbLES32PgC0ZpTRbJA960ReTpVqqvCOYaC6EL32/Q==} @@ -2321,8 +2321,8 @@ packages: '@types/node@22.20.5': resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} - '@types/node@24.13.6': - resolution: {integrity: sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==} + '@types/node@26.6.5': + resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -3238,10 +3238,6 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - escape-string-regexp@5.0.0: - resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} - engines: {node: '>=12'} - eslint-import-context@0.1.9: resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -3594,6 +3590,10 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} + glob@14.0.3: + resolution: {integrity: sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow==} + engines: {node: 18 || 20 || >=22} + globals@17.12.0: resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} engines: {node: '>=18'} @@ -3861,8 +3861,8 @@ packages: keyv@5.6.0: resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} - knip@6.43.0: - resolution: {integrity: sha512-8l3cCOg/EXoILO0qcSoMra49yQM91+lEIR4fu5Ko8rrArGX8xKdKZiGI9psPiY6rhLmfLERcMfFEByF0wli5CA==} + knip@6.44.1: + resolution: {integrity: sha512-Hbd8nvkIsNgFfXgC7Jsb5LHv7jj6VwA3tmfHd53XPX2z9Vc9RnAZqV0BIiP4OrtYqKwCoLseFHQZerb1bNlbSw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -4011,9 +4011,6 @@ packages: magic-string@1.4.1: resolution: {integrity: sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==} - markdown-table@3.0.4: - resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - marked@14.0.0: resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} engines: {node: '>= 18'} @@ -4023,30 +4020,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mdast-util-find-and-replace@3.0.2: - resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} - mdast-util-from-markdown@2.0.3: resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} - mdast-util-gfm-autolink-literal@2.0.1: - resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} - - mdast-util-gfm-footnote@2.1.0: - resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} - - mdast-util-gfm-strikethrough@2.0.0: - resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} - - mdast-util-gfm-table@2.0.0: - resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} - - mdast-util-gfm-task-list-item@2.0.0: - resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} - - mdast-util-gfm@3.1.0: - resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} - mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -4083,27 +4059,6 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} - micromark-extension-gfm-autolink-literal@2.1.0: - resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} - - micromark-extension-gfm-footnote@2.1.0: - resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} - - micromark-extension-gfm-strikethrough@2.1.0: - resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} - - micromark-extension-gfm-table@2.1.2: - resolution: {integrity: sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==} - - micromark-extension-gfm-tagfilter@2.0.0: - resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} - - micromark-extension-gfm-task-list-item@2.1.0: - resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} - - micromark-extension-gfm@3.0.0: - resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} - micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -4176,6 +4131,10 @@ packages: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} + minimatch@11.0.2: + resolution: {integrity: sha512-pWTgz5CN3tjqyZNSK2tePlSmu9IwM41q8yn8Usdqqwe9TIHh+9dEpLLjF4uApsfPEAZY71qtSiA2B2APEr+1Yw==} + engines: {node: 18 || 20 || >=22} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -4337,6 +4296,10 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-scurry@3.0.0: + resolution: {integrity: sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==} + engines: {node: 18 || 20 || >=22} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -4443,8 +4406,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 - react-i18next@17.1.8: - resolution: {integrity: sha512-vGH+Tbhqbv5LdN9KvC9GEmQ6ATxJAjyxPEK0zSCSRlkeDqCPNxVtEOvDNRAz+51HTxc6QPwhnHaFD7ICu6pD1A==} + react-i18next@17.1.9: + resolution: {integrity: sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==} peerDependencies: i18next: '>= 26.2.0' react: '>= 16.8.0' @@ -4536,18 +4499,12 @@ packages: rehype-sanitize@6.0.0: resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} - remark-gfm@4.0.1: - resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} - remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} - remark-stringify@11.0.0: - resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} - resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} @@ -4865,8 +4822,8 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} undici@6.28.1: resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==} @@ -5117,8 +5074,8 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} - ws@8.22.0: - resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + ws@8.22.1: + resolution: {integrity: sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -6303,6 +6260,8 @@ snapshots: dependencies: minipass: 7.1.3 + '@isaacs/nonrecursive-glob-match@1.0.2': {} + '@jest/diff-sequences@30.5.0': {} '@jest/expect-utils@30.5.2': @@ -6313,7 +6272,7 @@ snapshots: '@jest/pattern@30.5.0': dependencies: - '@types/node': 24.13.6 + '@types/node': 26.6.5 jest-regex-util: 30.5.0 '@jest/schemas@30.5.0': @@ -6326,7 +6285,7 @@ snapshots: '@jest/schemas': 30.5.0 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 24.13.6 + '@types/node': 26.6.5 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -6932,7 +6891,7 @@ snapshots: dependencies: '@tauri-apps/api': 2.12.4 - '@tauri-apps/plugin-updater@2.14.0': + '@tauri-apps/plugin-updater@2.14.1': dependencies: '@tauri-apps/api': 2.12.4 @@ -7017,9 +6976,9 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@24.13.6': + '@types/node@26.6.5': dependencies: - undici-types: 7.18.2 + undici-types: 8.9.0 '@types/parse-json@4.0.2': {} @@ -7041,7 +7000,7 @@ snapshots: '@types/split2@4.2.3': dependencies: - '@types/node': 24.13.6 + '@types/node': 26.6.5 '@types/stack-utils@2.0.3': {} @@ -7056,7 +7015,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 24.13.6 + '@types/node': 26.6.5 '@types/yargs-parser@21.0.3': {} @@ -7227,7 +7186,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vitejs/plugin-legacy@8.2.3(supports-color@7.2.0)(terser@5.51.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))': + '@vitejs/plugin-legacy@8.2.3(supports-color@7.2.0)(terser@5.51.2)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) @@ -7242,23 +7201,23 @@ snapshots: regenerator-runtime: 0.14.1 systemjs: 6.15.1 terser: 5.51.2 - vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1) + vite: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))': + '@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1) + vite: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1) - '@vitest/mocker@5.0.1(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))': + '@vitest/mocker@5.0.1(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.1 estree-walker: 3.0.3 magic-string: 1.4.1 optionalDependencies: - vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1) + vite: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1) '@vitest/pretty-format@4.1.11': dependencies: @@ -7989,8 +7948,6 @@ snapshots: escape-string-regexp@4.0.0: {} - escape-string-regexp@5.0.0: {} - eslint-import-context@0.1.9(unrs-resolver@1.12.2): dependencies: get-tsconfig: 4.14.3 @@ -8436,6 +8393,12 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 + glob@14.0.3: + dependencies: + minimatch: 11.0.2 + minipass: 7.1.3 + path-scurry: 3.0.0 + globals@17.12.0: {} gopd@1.2.0: {} @@ -8685,7 +8648,7 @@ snapshots: dependencies: '@jest/expect-utils': 30.5.2 '@jest/types': 30.5.1 - '@types/node': 24.13.6 + '@types/node': 26.6.5 jest-util: 30.5.1 jest-regex-util@30.5.0: {} @@ -8693,7 +8656,7 @@ snapshots: jest-util@30.5.1: dependencies: '@jest/types': 30.5.1 - '@types/node': 24.13.6 + '@types/node': 26.6.5 chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 @@ -8738,7 +8701,7 @@ snapshots: dependencies: '@keyv/serialize': 1.1.1 - knip@6.43.0: + knip@6.44.1: dependencies: formatly: 0.11.4 get-tsconfig: 4.14.3 @@ -8875,19 +8838,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 - markdown-table@3.0.4: {} - marked@14.0.0: {} math-intrinsics@1.1.0: {} - mdast-util-find-and-replace@3.0.2: - dependencies: - '@types/mdast': 4.0.4 - escape-string-regexp: 5.0.0 - unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 @@ -8905,63 +8859,6 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-gfm-autolink-literal@2.0.1: - dependencies: - '@types/mdast': 4.0.4 - ccount: 2.0.1 - devlop: 1.1.0 - mdast-util-find-and-replace: 3.0.2 - micromark-util-character: 2.1.1 - - mdast-util-gfm-footnote@2.1.0(supports-color@7.2.0): - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) - mdast-util-to-markdown: 2.1.2 - micromark-util-normalize-identifier: 2.0.1 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0): - dependencies: - '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-table@2.0.0(supports-color@7.2.0): - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0): - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm@3.1.0(supports-color@7.2.0): - dependencies: - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) - mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0) - mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0) - mdast-util-gfm-table: 2.0.0(supports-color@7.2.0) - mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0) - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - mdast-util-mdx-expression@2.0.1(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 @@ -9068,64 +8965,6 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-extension-gfm-autolink-literal@2.1.0: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-footnote@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-strikethrough@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-util-chunked: 2.0.1 - micromark-util-classify-character: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-table@2.1.2: - dependencies: - devlop: 1.1.0 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-tagfilter@2.0.0: - dependencies: - micromark-util-types: 2.0.2 - - micromark-extension-gfm-task-list-item@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm@3.0.0: - dependencies: - micromark-extension-gfm-autolink-literal: 2.1.0 - micromark-extension-gfm-footnote: 2.1.0 - micromark-extension-gfm-strikethrough: 2.1.0 - micromark-extension-gfm-table: 2.1.2 - micromark-extension-gfm-tagfilter: 2.0.0 - micromark-extension-gfm-task-list-item: 2.1.0 - micromark-util-combine-extensions: 2.0.1 - micromark-util-types: 2.0.2 - micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -9250,6 +9089,11 @@ snapshots: dependencies: brace-expansion: 5.0.12 + minimatch@11.0.2: + dependencies: + '@isaacs/nonrecursive-glob-match': 1.0.2 + brace-expansion: 5.0.12 + minipass@7.1.3: {} minizlib@3.1.0: @@ -9450,6 +9294,11 @@ snapshots: lru-cache: 11.5.3 minipass: 7.1.3 + path-scurry@3.0.0: + dependencies: + lru-cache: 11.5.3 + minipass: 7.1.3 + path-type@4.0.0: {} pathe@2.0.3: {} @@ -9529,7 +9378,7 @@ snapshots: dependencies: react: 19.3.1 - react-i18next@17.1.8(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3): + react-i18next@17.1.9(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3): dependencies: '@babel/runtime': 7.29.7 html-parse-stringify: 4.0.1 @@ -9641,17 +9490,6 @@ snapshots: '@types/hast': 3.0.5 hast-util-sanitize: 5.0.2 - remark-gfm@4.0.1(supports-color@7.2.0): - dependencies: - '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0(supports-color@7.2.0) - micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0(supports-color@7.2.0) - remark-stringify: 11.0.0 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - remark-parse@11.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 @@ -9669,12 +9507,6 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 - remark-stringify@11.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-to-markdown: 2.1.2 - unified: 11.0.5 - resize-observer-polyfill@1.5.1: {} resolve-from@4.0.0: {} @@ -9983,7 +9815,7 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.18.2: {} + undici-types@8.9.0: {} undici@6.28.1: {} @@ -10101,18 +9933,18 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plugin-svgr@5.2.0(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)): + vite-plugin-svgr@5.2.0(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)): dependencies: '@rollup/pluginutils': 5.4.0 '@svgr/core': 8.1.0(supports-color@7.2.0)(typescript@6.0.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0) - vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1) + vite: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1) transitivePeerDependencies: - rollup - supports-color - typescript - vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1): + vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -10120,7 +9952,7 @@ snapshots: rolldown: 1.2.9 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 24.13.6 + '@types/node': 26.6.5 esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 @@ -10128,10 +9960,10 @@ snapshots: terser: 5.51.2 yaml: 2.9.1 - vitest@5.0.1(@types/node@24.13.6)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)): + vitest@5.0.1(@types/node@26.6.5)(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)) + '@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -10142,10 +9974,10 @@ snapshots: tinybench: 6.1.4 tinyexec: 1.3.0 tinyglobby: 0.2.17 - vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1) + vite: 8.3.0(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.13.6 + '@types/node': 26.6.5 transitivePeerDependencies: - msw @@ -10191,7 +10023,7 @@ snapshots: deepmerge-ts: 8.0.2 https-proxy-agent: 9.1.0(supports-color@7.2.0) undici: 8.11.2 - ws: 8.22.0 + ws: 8.22.1 transitivePeerDependencies: - bufferutil - kerberos @@ -10259,7 +10091,7 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 - ws@8.22.0: {} + ws@8.22.1: {} y18n@5.0.8: {} diff --git a/scripts-workflow/bump_changelog.sh b/scripts-workflow/bump_changelog.sh index d8ca606c8..bd829c084 100755 --- a/scripts-workflow/bump_changelog.sh +++ b/scripts-workflow/bump_changelog.sh @@ -41,7 +41,10 @@ fi tmpfile=$(mktemp) { - cat "$CHANGELOG" + node -e ' + const fs = require("node:fs"); + process.stdout.write(fs.readFileSync(process.argv[1], "utf8").replace(//g, "").trimStart()); + ' "$CHANGELOG" echo echo "" if [ -f "$HISTORY" ]; then diff --git a/scripts-workflow/release.mjs b/scripts-workflow/release.mjs new file mode 100644 index 000000000..730273659 --- /dev/null +++ b/scripts-workflow/release.mjs @@ -0,0 +1,801 @@ +import { execFileSync } from 'node:child_process' +import { appendFileSync, readFileSync, writeFileSync } from 'node:fs' + +const env = process.env +const repository = env.GITHUB_REPOSITORY +const refreshLabel = 'Refresh this PR from latest dev' +const publishLabel = 'Publish this approved release' +const minorLabel = 'Use the next minor version' +const checked = (body, label = refreshLabel) => + (body || '') + .split('\n') + .some((line) => line.toLowerCase() === `- [x] ${label}`.toLowerCase()) +const versionPattern = '(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)' +const rcPattern = new RegExp(`^v${versionPattern}-rc(?:\\.(0|[1-9][0-9]*))?$`) +const stablePattern = new RegExp(`^v${versionPattern}$`) +const versionFiles = [ + 'package.json', + 'src-tauri/tauri.conf.json', + 'src-tauri/Cargo.toml', + 'Cargo.lock', +] +const read = (path) => readFileSync(path, 'utf8') +const json = (path) => JSON.parse(read(path)) +const run = (command, args, options = {}) => + execFileSync(command, args, { encoding: 'utf8', ...options }).trim() +const git = (...args) => run('git', args) +const gitFile = (sha, path) => + execFileSync('git', ['show', `${sha}:${path}`], { encoding: 'utf8' }) +const isRc = (tag) => tag.match(rcPattern)?.[0] === tag +const isRelease = (tag) => isRc(tag) || stablePattern.test(tag) +const titleFor = (tag) => `${isRc(tag) ? 'Pre-Release' : 'Release'}: ${tag}` +const branchTag = (branch, prefix = 'release/') => + branch.startsWith(prefix) && isRelease(branch.slice(prefix.length)) + ? branch.slice(prefix.length) + : undefined +const latest = (tags) => + tags.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })).at(-1) +const summary = (text) => appendFileSync(env.GITHUB_STEP_SUMMARY, `${text}\n`) +const endpoint = (path) => + path === 'graphql' ? path : `repos/${repository}/${path}` +const list = (path) => + JSON.parse(run('gh', ['api', '--paginate', '--slurp', endpoint(path)])).flat() + +function api(path, body, method = 'POST') { + const response = run( + 'gh', + ['api', '--method', method, endpoint(path), '--input', '-'], + { + input: JSON.stringify(body), + }, + ) + const result = response ? JSON.parse(response) : null + if (result?.errors) throw new Error(JSON.stringify(result.errors)) + return result +} + +const get = (path) => JSON.parse(run('gh', ['api', endpoint(path)])) +const canWrite = (login) => + ['write', 'maintain', 'admin'].includes( + get(`collaborators/${login}/permission`).permission, + ) + +function output(values) { + appendFileSync( + env.GITHUB_OUTPUT, + Object.entries(values) + .map(([key, value]) => `${key}=${value}\n`) + .join(''), + ) +} + +function versionPatternFor(path) { + if (path.endsWith('.json')) return /("version"\s*:\s*")[^"]+/ + return path === 'Cargo.lock' + ? /(\[\[package\]\]\r?\nname = "clash-verge"\r?\nversion = ")[^"]+/ + : /(^version\s*=\s*")[^"]+/m +} + +function validateVersions(tag, sha) { + for (const path of versionFiles) { + const content = sha ? gitFile(sha, path) : read(path) + const version = path.endsWith('.json') + ? JSON.parse(content).version + : content.match(versionPatternFor(path))?.[0].split('"').at(-1) + if (`v${version}` !== tag) + throw new Error(`${path} version does not match ${tag}.`) + } +} + +function validateVersionCommit(tag, sha) { + validateVersions(tag, sha) + const commit = get(`commits/${sha}`) + if (!commit.commit.verification.verified || commit.parents.length !== 1) + throw new Error('The version commit must be signed and have one parent.') + const title = commit.commit.message.split('\n')[0] + if (title.replace(/ \(#\d+\)$/, '') !== titleFor(tag)) + throw new Error('The final version commit has an unexpected title.') + const parent = commit.parents[0].sha + const changed = git('diff', '--name-only', parent, sha) + .split('\n') + .filter(Boolean) + if ( + changed.some((path) => !versionFiles.includes(path)) || + git('diff', '--summary', parent, sha) + ) + throw new Error( + 'The version commit changes files outside the four version files.', + ) + for (const path of versionFiles) { + const before = gitFile(parent, path) + const after = gitFile(sha, path) + if ( + before.replace(versionPatternFor(path), '$1VERSION') !== + after.replace(versionPatternFor(path), '$1VERSION') + ) + throw new Error(`${path} contains changes other than its version.`) + } +} + +function remoteRefs() { + return git('ls-remote', '--heads', '--tags', '--refs', 'origin') + .split('\n') + .filter(Boolean) + .map((line) => line.split('\t')) +} + +function fetchBranches() { + git( + 'fetch', + '--no-tags', + 'origin', + '+refs/heads/dev:refs/remotes/origin/dev', + '+refs/heads/main:refs/remotes/origin/main', + ) +} + +function discardClosedCandidate(branch, base, prefix) { + const candidates = list( + `pulls?state=all&base=${base}&head=${repository.split('/')[0]}:${branch}&per_page=100`, + ) + const latest = candidates[0] + if (!latest || candidates.some((pr) => pr.state === 'open')) + throw new Error( + `Branch ${branch} is reserved by an active or unknown candidate.`, + ) + const pr = get(`pulls/${latest.number}`) + requireBotPr(pr, base, prefix) + if (pr.state !== 'closed' || pr.merged) + throw new Error( + `Close the unmerged candidate before retrying: ${pr.html_url}`, + ) + updateRefs(pr.base.repo.node_id, [ + { + name: `refs/heads/${branch}`, + beforeOid: pr.head.sha, + afterOid: '0'.repeat(40), + }, + ]) + summary(`Released the unused branch from closed PR: ${pr.html_url}`) +} + +function prepare() { + let refresh + if (env.GITHUB_EVENT_NAME === 'pull_request_target') { + const event = json(env.GITHUB_EVENT_PATH) + if (checked(event.changes?.body?.from) || !checked(event.pull_request.body)) + return + if (!canWrite(event.sender.login)) + throw new Error('Refreshing a version PR requires write access.') + refresh = get(`pulls/${event.pull_request.number}`) + if (refresh.state !== 'open' || !checked(refresh.body)) return + requireBotPr(refresh, 'dev', 'release/') + } + fetchBranches() + git('checkout', '--detach', 'origin/dev') + const channel = refresh + ? isRc(branchTag(refresh.head.ref)) + ? 'rc' + : 'stable' + : env.RELEASE_CHANNEL || 'rc' + if (!['rc', 'stable'].includes(channel)) + throw new Error('Unknown release channel.') + const requested = refresh + ? branchTag(refresh.head.ref) + : env.RELEASE_TAG || '' + if ( + requested && + !(channel === 'rc' ? isRc(requested) : stablePattern.test(requested)) + ) + throw new Error( + channel === 'rc' + ? 'Expected vX.Y.Z-rc or vX.Y.Z-rc.N.' + : 'Expected vX.Y.Z.', + ) + const existing = + !refresh && + list('pulls?state=open&base=dev&per_page=100').find( + (pr) => + pr.head.repo?.full_name === repository && + pr.user.login === 'github-actions[bot]' && + branchTag(pr.head.ref) && + isRc(branchTag(pr.head.ref)) === (channel === 'rc') && + (!requested || branchTag(pr.head.ref) === requested), + ) + if (existing) { + api(`issues/${existing.number}/labels`, { labels: ['release'] }) + output({ existing_pr: existing.html_url }) + summary( + `Existing version PR: ${existing.html_url}\nClose it before preparing a replacement; its candidate is unchanged.`, + ) + return + } + const base = (requested || `v${json('package.json').version}`).split( + /[-+]/, + )[0] + if (!stablePattern.test(base)) + throw new Error(`Invalid base version: ${base}`) + const refs = remoteRefs().map(([, ref]) => ref) + const tags = refs + .filter((ref) => ref.startsWith('refs/tags/')) + .map((ref) => ref.slice(10)) + const reserved = refs + .filter((ref) => ref.startsWith('refs/heads/')) + .filter((ref) => !refresh || ref !== `refs/heads/${refresh.head.ref}`) + .map( + (ref) => branchTag(ref.slice(11)) || branchTag(ref.slice(11), 'publish/'), + ) + .filter(Boolean) + const sameBase = (tag) => isRc(tag) && tag.startsWith(`${base}-rc`) + const occupied = [...tags, ...reserved] + const last = latest(occupied.filter(sameBase)) + const tag = + requested || + (channel === 'stable' + ? base + : last + ? `${base}-rc.${BigInt(last.match(rcPattern)[4] ?? 0) + 1n}` + : `${base}-rc`) + if (tags.includes(tag)) throw new Error(`Tag ${tag} already exists.`) + for (const [prefix, target] of [ + ['release/', 'dev'], + ['publish/', 'main'], + ]) { + const branch = `${prefix}${tag}` + if (refs.includes(`refs/heads/${branch}`) && branch !== refresh?.head.ref) + discardClosedCandidate(branch, target, prefix) + } + const sha = git('rev-parse', 'HEAD') + const body = releaseBody(tag, sha, tags, false) + summary(body) + if ( + remoteRefs().some( + ([, ref]) => + ref === `refs/tags/${tag}` || ref === `refs/heads/publish/${tag}`, + ) + ) + throw new Error(`Tag ${tag} already exists or is reserved for publication.`) + if (json('package.json').version !== tag.slice(1)) + run('node', ['scripts/release-version.mjs', tag]) + validateVersions(tag) + const title = titleFor(tag) + const branch = refresh + ? `release-refresh/${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}` + : `release/${tag}` + writeVersionPr({ sha, body, refresh, title, branch, files: versionFiles }) +} + +function releaseBody(tag, sha, tags, publishing) { + const previous = + (isRc(tag) && + latest( + tags.filter( + (value) => + isRc(value) && + value.startsWith(`${tag.split('-')[0]}-rc`) && + value !== tag, + ), + )) || + latest(tags.filter((value) => stablePattern.test(value) && value !== tag)) + const url = `${env.GITHUB_SERVER_URL}/${repository}` + let range = sha + let comparison = 'No previous release tag' + if (previous) { + git('fetch', '--no-tags', 'origin', `refs/tags/${previous}`) + range = `${git('rev-parse', 'FETCH_HEAD^{commit}')}..${sha}` + comparison = `Changes since **${previous}** · [Compare changes](${url}/compare/${previous}...${sha})` + } + comparison += ` · **${git('rev-list', '--count', range)} commits**` + const commits = git('log', '--format=%h %s', range) + const groups = Object.groupBy(commits ? commits.split('\n') : [], (line) => { + if (/^\S+ chore\(deps\)!?: /i.test(line)) return 'deps' + const type = line.match(/^\S+ (\w+)(?:\([^)]*\))?!?: /)?.[1]?.toLowerCase() + return type === 'doc' ? 'docs' : type || 'other' + }) + const labels = { + fix: 'Fixes', + feat: 'Features', + perf: 'Performance', + revert: 'Reverts', + refactor: 'Refactoring', + build: 'Build', + deps: 'Dependencies', + ci: 'CI', + docs: 'Documentation', + test: 'Tests', + chore: 'Maintenance', + style: 'Style', + other: 'Other', + } + const names = { + macos: 'macOS', + windows: 'Windows', + ui: 'UI', + ci: 'CI', + tun: 'TUN', + } + const order = Object.keys(labels) + const rank = (type) => + order.includes(type) ? order.indexOf(type) : order.length + const scope = + Object.entries(groups) + .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b)) + .map(([type, lines]) => { + const entries = lines.map((line) => { + const hash = line.slice(0, line.indexOf(' ')) + const title = line + .slice(hash.length + 1) + .replace(/\s*\((?:[\w.-]+\/[\w.-]+)?#\d+\)/g, '') + .replace(/(?:[\w.-]+\/[\w.-]+)?#\d+\b/g, '') + .replace(/\s{2,}/g, ' ') + .trim() + .replace( + /^\w+(?:\(([^)]*)\))?(!)?:\s*/, + (_, scope, breaking) => + `${breaking ? 'Breaking: ' : ''}${scope ? `${Object.hasOwn(names, scope) ? names[scope] : scope}: ` : ''}`, + ) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replace(/[\\`*_[\]]/g, '\\$&') + return `- ${title} [#${hash}](${url}/commit/${hash})` + }) + return `### ${Object.hasOwn(labels, type) ? labels[type] : type} · ${lines.length}\n\n${entries.join('\n')}` + }) + .join('\n\n') || 'No new commits.' + return `## ${titleFor(tag)} + +Source: \`dev\` · [${sha}](${url}/commit/${sha}) \n${comparison} +${ + publishing + ? ` + +After approval, select the checkbox below to publish. +Do not use the normal Squash or Rebase merge buttons. + +- [ ] ${publishLabel}` + : ` +- [ ] ${refreshLabel} + +Refreshing requires a new approval.` +} + +## Changes + +${scope} +` +} + +function writeVersionPr({ sha, body, refresh, title, branch, files }) { + // Create-only ref and expectedHeadOid prevent replacing another candidate. + api('git/refs', { ref: `refs/heads/${branch}`, sha }) + let pr + try { + const commit = api('graphql', { + query: `mutation($input: CreateCommitOnBranchInput!) { + createCommitOnBranch(input: $input) { commit { oid } } + }`, + variables: { + input: { + branch: { repositoryNameWithOwner: repository, branchName: branch }, + expectedHeadOid: sha, + message: { headline: title }, + fileChanges: { + additions: files.map((path) => ({ + path, + contents: readFileSync(path).toString('base64'), + })), + }, + }, + }, + }) + if (!commit.data?.createCommitOnBranch?.commit?.oid) + throw new Error('Signed version commit failed.') + if (refresh) { + const current = get(`pulls/${refresh.number}`) + if ( + current.state !== 'open' || + current.head.sha !== refresh.head.sha || + current.base.ref !== refresh.base.ref || + current.body !== refresh.body + ) + throw new Error( + 'The version pull request changed while preparing the update.', + ) + const oid = commit.data.createCommitOnBranch.commit.oid + updateRefs(refresh.base.repo.node_id, [ + { + name: `refs/heads/${refresh.head.ref}`, + beforeOid: refresh.head.sha, + afterOid: oid, + force: true, + }, + { + name: `refs/heads/${branch}`, + beforeOid: oid, + afterOid: '0'.repeat(40), + }, + ]) + for (const review of list( + `pulls/${refresh.number}/reviews?per_page=100`, + )) { + if (review.state === 'APPROVED') + api( + `pulls/${refresh.number}/reviews/${review.id}/dismissals`, + { + message: + 'The version PR was refreshed from dev; please review again.', + }, + 'PUT', + ) + } + pr = api(`pulls/${refresh.number}`, { title, body }, 'PATCH') + } else { + pr = api('pulls', { + head: branch, + base: 'dev', + title, + body, + }) + } + } catch (error) { + if (refresh) { + try { + api(`git/refs/heads/${branch}`, {}, 'DELETE') + } catch {} + } + throw error + } + api(`issues/${pr.number}/labels`, { labels: ['release'] }) + summary(`Version pull request: ${pr.html_url}`) +} + +function updateRefs(repositoryId, refUpdates) { + api('graphql', { + query: `mutation($input: UpdateRefsInput!) { updateRefs(input: $input) { clientMutationId } }`, + variables: { input: { repositoryId, refUpdates } }, + }) +} + +function requireBotPr(pr, base, prefix) { + const tag = branchTag(pr.head.ref, prefix) + if ( + !tag || + pr.base.ref !== base || + pr.base.repo.full_name !== repository || + pr.head.repo?.full_name !== repository || + pr.user.login !== 'github-actions[bot]' + ) + throw new Error( + 'Expected a release PR created by this repository workflow.', + ) + return tag +} + +function publishPr() { + const event = json(env.GITHUB_EVENT_PATH) + const pr = get(`pulls/${event.pull_request.number}`) + const tag = requireBotPr(pr, 'dev', 'release/') + if (!pr.merged || pr.merge_commit_sha !== event.pull_request.merge_commit_sha) + throw new Error('Expected the final merged version commit on dev.') + const sha = pr.merge_commit_sha + fetchBranches() + git('fetch', '--no-tags', 'origin', sha) + git('merge-base', '--is-ancestor', sha, 'origin/dev') + validateVersionCommit(tag, sha) + const branch = `publish/${tag}` + const existing = list( + `pulls?state=open&base=main&head=${repository.split('/')[0]}:${branch}&per_page=100`, + ) + if (existing.length) { + const publication = existing[0] + requireBotPr(publication, 'main', 'publish/') + if (publication.head.sha !== sha) + throw new Error( + 'This tag is already reserved by a different publication snapshot.', + ) + summary(`Existing publication PR: ${publication.html_url}`) + return + } + const refs = remoteRefs() + if (refs.some(([, ref]) => ref === `refs/tags/${tag}`)) + throw new Error(`Tag ${tag} already exists.`) + git('merge-base', '--is-ancestor', 'origin/main', sha) + const head = refs.find(([, ref]) => ref === `refs/heads/${branch}`)?.[0] + if (head && head !== sha) + throw new Error('Publication branch already points to another commit.') + if (!head) api('git/refs', { ref: `refs/heads/${branch}`, sha }) + const body = releaseBody( + tag, + sha, + refs + .filter(([, ref]) => ref.startsWith('refs/tags/')) + .map(([, ref]) => ref.slice(10)), + true, + ) + const publication = api('pulls', { + head: branch, + base: 'main', + title: titleFor(tag), + body, + }) + api(`issues/${publication.number}/labels`, { labels: ['release'] }) + summary(`Publication PR: ${publication.html_url}\n\n${body}`) +} + +function requireApproval(pr, sha) { + const reviews = new Map() + for (const review of list(`pulls/${pr.number}/reviews?per_page=100`)) { + if (['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)) + reviews.set(review.user.login, review) + } + let approved = false + for (const review of reviews.values()) { + if (!canWrite(review.user.login)) continue + if (review.state === 'CHANGES_REQUESTED') + throw new Error('A reviewer with write access has requested changes.') + if (review.state === 'APPROVED' && review.commit_id === sha) approved = true + } + if (!approved) + throw new Error( + 'The current PR head needs approval from a reviewer with write access.', + ) +} + +function releaseOutput(tag, sha) { + output({ + release_tag: tag, + source_sha: sha, + channel: isRc(tag) ? 'rc' : 'stable', + }) +} + +function publish() { + const event = json(env.GITHUB_EVENT_PATH) + const tag = requireBotPr(event.pull_request, 'main', 'publish/') + if ( + checked(event.changes?.body?.from, publishLabel) || + !checked(event.pull_request.body, publishLabel) + ) + throw new Error('Publishing requires a new checkbox selection.') + if (!canWrite(event.sender.login)) + throw new Error('Publishing requires write access.') + const sha = event.pull_request.head.sha + const pr = get(`pulls/${event.pull_request.number}`) + requireBotPr(pr, 'main', 'publish/') + if ( + pr.head.sha !== sha || + !checked(pr.body, publishLabel) || + !pr.body.includes(``) + ) + throw new Error( + 'The publication snapshot changed after the checkbox selection.', + ) + fetchBranches() + git('fetch', '--no-tags', 'origin', sha) + git('merge-base', '--is-ancestor', sha, 'origin/dev') + validateVersionCommit(tag, sha) + const marker = `Release workflow run: ${env.GITHUB_RUN_ID}\nSource: ${sha}\nPR: ${pr.number}` + const existing = remoteRefs().find(([, ref]) => ref === `refs/tags/${tag}`) + if (existing) { + const ref = get(`git/ref/tags/${tag}`) + const object = + ref.object.type === 'tag' && get(`git/tags/${ref.object.sha}`) + if ( + !object || + object.object.type !== 'commit' || + object.object.sha !== sha || + object.message.trim() !== marker || + Number(env.GITHUB_RUN_ATTEMPT) <= 1 + ) + throw new Error( + 'An existing tag can only be recovered by its original release run.', + ) + git('merge-base', '--is-ancestor', sha, 'origin/main') + summary(`Resuming ${tag} at ${sha} from this release run.`) + } else { + if (pr.state !== 'open' || pr.draft) + throw new Error('Expected an open, ready publication PR.') + requireApproval(pr, sha) + const main = git('rev-parse', 'origin/main') + git('merge-base', '--is-ancestor', main, sha) + git( + '-c', + 'user.name=github-actions[bot]', + '-c', + 'user.email=41898282+github-actions[bot]@users.noreply.github.com', + 'tag', + '--annotate', + '--no-sign', + '--message', + marker, + tag, + sha, + ) + // GraphQL updateRefs rejects annotated tag objects; Git preserves atomicity. + git( + 'push', + '--atomic', + `--force-with-lease=refs/heads/main:${main}`, + `--force-with-lease=refs/heads/${pr.head.ref}:${sha}`, + `--force-with-lease=refs/tags/${tag}:`, + 'origin', + `${sha}:refs/heads/main`, + `${sha}:refs/heads/${pr.head.ref}`, + `refs/tags/${tag}:refs/tags/${tag}`, + ) + summary(`Fast-forwarded main and tagged ${tag} at approved commit ${sha}.`) + } + releaseOutput(tag, sha) +} + +function validateTag() { + const tag = env.GITHUB_REF_NAME + if (!isRelease(tag)) throw new Error('Expected a stable or RC release tag.') + fetchBranches() + const sha = git('rev-parse', `${env.GITHUB_SHA}^{commit}`) + const ref = get(`git/ref/tags/${tag}`) + const object = ref.object.type === 'tag' && get(`git/tags/${ref.object.sha}`) + if (object && object.message.includes('Release workflow run:')) + throw new Error( + 'Resume the original publication run for workflow-owned tags.', + ) + git('fetch', '--no-tags', 'origin', `refs/tags/${tag}`) + if (git('rev-parse', 'FETCH_HEAD^{commit}') !== sha) + throw new Error('The tag moved after this run started.') + for (const branch of ['main', 'dev']) + git('merge-base', '--is-ancestor', sha, `origin/${branch}`) + validateVersions(tag, sha) + releaseOutput(tag, sha) +} + +function prepareDraft() { + const tag = env.RELEASE_TAG + verifyTarget() + const result = run('gh', [ + 'api', + '--paginate', + endpoint('releases?per_page=100'), + '--jq', + `.[] | select(.tag_name == ${JSON.stringify(tag)}) | {id, draft}`, + ]) + const release = result ? JSON.parse(result) : null + if (release && !release.draft) + throw new Error('This release is already public.') + const draft = + release || + api('releases', { + tag_name: tag, + target_commitish: env.SOURCE_SHA, + name: `Clash Verge Rev ${tag}`, + body: 'Draft release, will be updated later.', + draft: true, + prerelease: isRc(tag), + }) + output({ id: draft.id }) +} + +function verifyTarget(tag = env.RELEASE_TAG, sha = env.SOURCE_SHA) { + const ref = get(`git/ref/tags/${tag}`) + const target = + ref.object.type === 'tag' + ? get(`git/tags/${ref.object.sha}`).object + : ref.object + if (target.type !== 'commit' || target.sha !== sha) + throw new Error('The release tag no longer points to the validated source.') +} + +function nextDev() { + let refresh + let tag = env.RELEASE_TAG + let source = env.SOURCE_SHA + let minor = false + if (env.GITHUB_EVENT_NAME === 'pull_request_target') { + const event = json(env.GITHUB_EVENT_PATH) + const oldBody = event.changes?.body?.from + const body = event.pull_request.body + if ( + checked(oldBody, minorLabel) === checked(body, minorLabel) && + (checked(oldBody) || !checked(body)) + ) + return + if (!canWrite(event.sender.login)) + throw new Error('Changing the next dev version requires write access.') + refresh = get(`pulls/${event.pull_request.number}`) + if (refresh.state !== 'open') return + tag = requireBotPr(refresh, 'dev', 'next-dev/') + if ( + refresh.head.sha !== event.pull_request.head.sha || + refresh.body !== body + ) + throw new Error( + 'The version update PR changed after the checkbox selection.', + ) + minor = checked(body, minorLabel) + source = body.match(//)?.[1] + } + if (!stablePattern.test(tag) || !/^[a-f0-9]{40}$/.test(source)) + throw new Error('The next dev version requires a stable release snapshot.') + verifyTarget(tag, source) + const release = get(`releases/tags/${tag}`) + if (release.draft || release.prerelease) + throw new Error('The stable release must be public before preparing dev.') + const branch = `next-dev/${tag}` + if (!refresh) { + const existing = list( + `pulls?state=all&base=dev&head=${repository.split('/')[0]}:${branch}&per_page=100`, + )[0] + if (existing) { + requireBotPr(existing, 'dev', 'next-dev/') + summary(`Existing dev version PR: ${existing.html_url}`) + return + } + } + fetchBranches() + git('fetch', '--no-tags', 'origin', source) + git('merge-base', '--is-ancestor', source, 'origin/main') + git('merge-base', '--is-ancestor', source, 'origin/dev') + git('checkout', '--detach', 'origin/dev') + const sha = git('rev-parse', 'HEAD') + validateVersions(tag, source) + validateVersions(tag, sha) + if (read('Changelog.md') !== gitFile(source, 'Changelog.md')) + throw new Error( + 'Changelog.md changed after this release. Preserve the unreleased entries and prepare the dev update manually.', + ) + if ( + !read('Changelog.md').split('\n').includes(`## ${tag}`) || + read('docs/Changelog.history.md').split('\n').includes(`## ${tag}`) + ) + throw new Error('The released changelog is missing or already archived.') + const [major, minorVersion, patch] = tag.slice(1).split('.').map(BigInt) + const patchTag = `v${major}.${minorVersion}.${patch + 1n}` + const minorTag = `v${major}.${minorVersion + 1n}.0` + const nextTag = minor ? minorTag : patchTag + run('pnpm', ['release-version', nextTag]) + run('bash', ['scripts-workflow/bump_changelog.sh']) + const changelog = read('Changelog.md') + if (!changelog.includes('## v(Version Goes Here)')) + throw new Error('The changelog template has no version placeholder.') + writeFileSync( + 'Changelog.md', + changelog.replace('## v(Version Goes Here)', `## ${nextTag}`), + ) + validateVersions(nextTag) + const title = `Dev: ${nextTag}` + const body = `## ${title} + +After [${tag}](${env.GITHUB_SERVER_URL}/${repository}/releases/tag/${tag}). + + +Patch: \`${patchTag}\` · Minor: \`${minorTag}\` + +- [${minor ? 'x' : ' '}] ${minorLabel} +- [ ] ${refreshLabel} + +Changing the version or refreshing requires a new approval. +` + writeVersionPr({ + sha, + body, + refresh, + title, + branch: refresh + ? `next-dev-refresh/${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}` + : branch, + files: [...versionFiles, 'Changelog.md', 'docs/Changelog.history.md'], + }) +} + +const commands = { + prepare, + 'next-dev': nextDev, + 'publish-pr': publishPr, + publish, + 'validate-tag': validateTag, + 'prepare-draft': prepareDraft, + 'verify-target': verifyTarget, +} +const command = commands[process.argv[2]] +if (!command) throw new Error('Unknown release command.') +command() diff --git a/scripts/prebuild.mjs b/scripts/prebuild.mjs index 22dd96032..0230668ab 100644 --- a/scripts/prebuild.mjs +++ b/scripts/prebuild.mjs @@ -266,13 +266,13 @@ async function getLatestReleaseVersion() { } if (!META_MAP[`${platform}-${arch}`]) { - throw new Error(`clash meta unsupported platform "${platform}-${arch}"`) + throw new Error(`mihomo unsupported platform "${platform}-${arch}"`) } if (!META_ALPHA_MAP[`${platform}-${arch}`]) { - throw new Error(`clash meta alpha unsupported platform "${platform}-${arch}"`) + throw new Error(`mihomo alpha unsupported platform "${platform}-${arch}"`) } -function clashMetaAlpha() { +function mihomoAlpha() { const name = META_ALPHA_MAP[`${platform}-${arch}`] const isWin = platform === 'win32' const urlExt = isWin ? 'zip' : 'gz' @@ -285,7 +285,7 @@ function clashMetaAlpha() { } } -function clashMeta() { +function mihomo() { const name = META_MAP[`${platform}-${arch}`] const isWin = platform === 'win32' const urlExt = isWin ? 'zip' : 'gz' @@ -733,13 +733,12 @@ const tasks = [ { name: 'verge-mihomo-alpha', func: () => - getLatestAlphaVersion().then(() => resolveSidecar(clashMetaAlpha())), + getLatestAlphaVersion().then(() => resolveSidecar(mihomoAlpha())), retry: 5, }, { name: 'verge-mihomo', - func: () => - getLatestReleaseVersion().then(() => resolveSidecar(clashMeta())), + func: () => getLatestReleaseVersion().then(() => resolveSidecar(mihomo())), retry: 5, }, // After both sidecar tasks: it hashes what they downloaded. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 824c83107..f6822677c 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -79,7 +79,7 @@ tauri-plugin-fs = "2.7.1" tauri-plugin-process = "2.4.0" tauri-plugin-deep-link = "2.6.2" tauri-plugin-window-state = "2.5.0" -zip = "9.0.4" +zip = "9.1.3" flate2 = "1.1.10" reqwest_dav = "0.3.3" aes-gcm = { version = "0.11.1" } @@ -154,7 +154,7 @@ windows-sys.workspace = true [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-autostart = "2.7.0" tauri-plugin-global-shortcut = "2.4.1" -tauri-plugin-updater = "2.14.0" +tauri-plugin-updater = "2.14.1" [dev-dependencies] criterion = { workspace = true } diff --git a/src-tauri/src/config/clash.rs b/src-tauri/src/config/clash.rs index 106c55d9f..59687278c 100644 --- a/src-tauri/src/config/clash.rs +++ b/src-tauri/src/config/clash.rs @@ -184,84 +184,41 @@ impl IClashTemp { _ => None, }) } - #[cfg(not(target_os = "windows"))] - fn guard_redir_port(config: &Mapping) -> u16 { + fn guard_port_value(config: &Mapping, key: &str, default: u16) -> u16 { let mut port = config - .get("redir-port") + .get(key) .and_then(|value| match value { Value::String(val_str) => val_str.parse().ok(), Value::Number(val_num) => val_num.as_u64().map(|u| u as u16), _ => None, }) - .unwrap_or(7895); + .unwrap_or(default); if port == 0 { - port = 7895; + port = default; } port } + #[cfg(not(target_os = "windows"))] + fn guard_redir_port(config: &Mapping) -> u16 { + Self::guard_port_value(config, "redir-port", network::ports::DEFAULT_REDIR) + } + #[cfg(target_os = "linux")] fn guard_tproxy_port(config: &Mapping) -> u16 { - let mut port = config - .get("tproxy-port") - .and_then(|value| match value { - Value::String(val_str) => val_str.parse().ok(), - Value::Number(val_num) => val_num.as_u64().map(|u| u as u16), - _ => None, - }) - .unwrap_or(network::ports::DEFAULT_TPROXY); - if port == 0 { - port = network::ports::DEFAULT_TPROXY; - } - port + Self::guard_port_value(config, "tproxy-port", network::ports::DEFAULT_TPROXY) } fn guard_mixed_port(config: &Mapping) -> u16 { - let raw_value = config.get("mixed-port"); - - let mut port = raw_value - .and_then(|value| match value { - Value::String(val_str) => val_str.parse().ok(), - Value::Number(val_num) => val_num.as_u64().map(|u| u as u16), - _ => None, - }) - .unwrap_or(network::ports::DEFAULT_MIXED); - - if port == 0 { - port = network::ports::DEFAULT_MIXED; - } - - port + Self::guard_port_value(config, "mixed-port", network::ports::DEFAULT_MIXED) } fn guard_socks_port(config: &Mapping) -> u16 { - let mut port = config - .get("socks-port") - .and_then(|value| match value { - Value::String(val_str) => val_str.parse().ok(), - Value::Number(val_num) => val_num.as_u64().map(|u| u as u16), - _ => None, - }) - .unwrap_or(7898); - if port == 0 { - port = 7898; - } - port + Self::guard_port_value(config, "socks-port", network::ports::DEFAULT_SOCKS) } fn guard_port(config: &Mapping) -> u16 { - let mut port = config - .get("port") - .and_then(|value| match value { - Value::String(val_str) => val_str.parse().ok(), - Value::Number(val_num) => val_num.as_u64().map(|u| u as u16), - _ => None, - }) - .unwrap_or(7899); - if port == 0 { - port = 7899; - } - port + Self::guard_port_value(config, "port", network::ports::DEFAULT_HTTP) } pub(super) fn guard_server_ctrl(config: &Mapping) -> String { diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index add685f62..1be472f88 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -14,6 +14,15 @@ mod verge; pub(crate) use self::config::Config; pub use self::{clash::*, encrypt::*, mixed_port::*, prfitem::*, profiles::*, verge::*}; +macro_rules! patch_opt_field { + ($lv:expr, $rv:expr, $key:tt) => { + if ($rv.$key).is_some() { + $lv.$key = $rv.$key.clone(); + } + }; +} +pub(crate) use patch_opt_field; + 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/src-tauri/src/config/prfitem.rs b/src-tauri/src/config/prfitem.rs index da8728d21..eafb29cf9 100644 --- a/src-tauri/src/config/prfitem.rs +++ b/src-tauri/src/config/prfitem.rs @@ -1,3 +1,5 @@ +use super::patch_opt_field; + use crate::{ config::profiles, utils::{ @@ -131,19 +133,18 @@ impl PrfOption { match (one, other) { (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(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); + patch_opt_field!(result, b_ref, user_agent); + patch_opt_field!(result, b_ref, with_proxy); + patch_opt_field!(result, b_ref, self_proxy); + patch_opt_field!(result, b_ref, danger_accept_invalid_certs); + patch_opt_field!(result, b_ref, allow_auto_update); + patch_opt_field!(result, b_ref, update_interval); + patch_opt_field!(result, b_ref, merge); + patch_opt_field!(result, b_ref, script); + patch_opt_field!(result, b_ref, rules); + patch_opt_field!(result, b_ref, proxies); + patch_opt_field!(result, b_ref, groups); + patch_opt_field!(result, b_ref, timeout_seconds); Some(result) } (Some(a_ref), None) => Some(a_ref.clone()), @@ -181,6 +182,38 @@ impl PrfItem { } } + async fn append_companion_item(item: &mut Self) -> Result { + profiles::profiles_append_item_safe(item).await?; + item.uid + .clone() + .ok_or_else(|| anyhow::anyhow!("appended companion item has no uid")) + } + + async fn ensure_companion_items( + merge: &mut Option, + script: &mut Option, + rules: &mut Option, + proxies: &mut Option, + groups: &mut Option, + ) -> Result<()> { + if merge.is_none() { + *merge = Some(Self::append_companion_item(&mut Self::from_merge(None)).await?); + } + if script.is_none() { + *script = Some(Self::append_companion_item(&mut Self::from_script(None)).await?); + } + if rules.is_none() { + *rules = Some(Self::append_companion_item(&mut Self::from_rules()).await?); + } + if proxies.is_none() { + *proxies = Some(Self::append_companion_item(&mut Self::from_proxies()).await?); + } + if groups.is_none() { + *groups = Some(Self::append_companion_item(&mut Self::from_groups()).await?); + } + Ok(()) + } + async fn from_local( name: String, desc: String, @@ -197,31 +230,7 @@ impl PrfItem { let mut proxies = opt_ref.and_then(|o| o.proxies.clone()); let mut groups = opt_ref.and_then(|o| o.groups.clone()); - if merge.is_none() { - 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 = &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 = &mut Self::from_rules(); - profiles::profiles_append_item_safe(rules_item).await?; - rules = rules_item.uid.clone(); - } - if proxies.is_none() { - 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 = &mut Self::from_groups(); - profiles::profiles_append_item_safe(groups_item).await?; - groups = groups_item.uid.clone(); - } + Self::ensure_companion_items(&mut merge, &mut script, &mut rules, &mut proxies, &mut groups).await?; Ok(Self { uid: Some(uid), itype: Some("local".into()), @@ -375,31 +384,7 @@ impl PrfItem { bail!("profile does not contain `proxies` or `proxy-providers`"); } - if merge.is_none() { - 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 = &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 = &mut Self::from_rules(); - profiles::profiles_append_item_safe(rules_item).await?; - rules = rules_item.uid.clone(); - } - if proxies.is_none() { - 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 = &mut Self::from_groups(); - profiles::profiles_append_item_safe(groups_item).await?; - groups = groups_item.uid.clone(); - } + Self::ensure_companion_items(&mut merge, &mut script, &mut rules, &mut proxies, &mut groups).await?; Ok(Self { uid: Some(uid), @@ -461,46 +446,30 @@ impl PrfItem { } } - fn from_rules() -> Self { - let uid = help::get_uid("r").into(); + fn new_seq_item(prefix: &str, itype: &str, template: &str) -> Self { + let uid = help::get_uid(prefix).into(); let file = format!("{uid}.yaml").into(); // yaml ext Self { uid: Some(uid), - itype: Some("rules".into()), + itype: Some(itype.into()), file: Some(file), updated: Some(chrono::Local::now().timestamp() as usize), - file_data: Some(tmpl::ITEM_RULES.into()), + file_data: Some(template.into()), ..Default::default() } } + fn from_rules() -> Self { + Self::new_seq_item("r", "rules", tmpl::ITEM_RULES) + } + fn from_proxies() -> Self { - let uid = help::get_uid("p").into(); - let file = format!("{uid}.yaml").into(); // yaml ext - - Self { - uid: Some(uid), - itype: Some("proxies".into()), - file: Some(file), - updated: Some(chrono::Local::now().timestamp() as usize), - file_data: Some(tmpl::ITEM_PROXIES.into()), - ..Default::default() - } + Self::new_seq_item("p", "proxies", tmpl::ITEM_PROXIES) } fn from_groups() -> Self { - let uid = help::get_uid("g").into(); - let file = format!("{uid}.yaml").into(); // yaml ext - - Self { - uid: Some(uid), - itype: Some("groups".into()), - file: Some(file), - updated: Some(chrono::Local::now().timestamp() as usize), - file_data: Some(tmpl::ITEM_GROUPS.into()), - ..Default::default() - } + Self::new_seq_item("g", "groups", tmpl::ITEM_GROUPS) } pub(crate) async fn read_file(&self) -> Result { @@ -516,26 +485,20 @@ impl PrfItem { } } +macro_rules! current_field { + ($name:ident, $field:ident) => { + pub(crate) fn $name(&self) -> Option<&String> { + self.option.as_ref().and_then(|o| o.$field.as_ref()) + } + }; +} + impl PrfItem { - pub(crate) fn current_merge(&self) -> Option<&String> { - self.option.as_ref().and_then(|o| o.merge.as_ref()) - } - - pub(crate) fn current_script(&self) -> Option<&String> { - self.option.as_ref().and_then(|o| o.script.as_ref()) - } - - pub(crate) fn current_rules(&self) -> Option<&String> { - self.option.as_ref().and_then(|o| o.rules.as_ref()) - } - - pub(crate) fn current_proxies(&self) -> Option<&String> { - self.option.as_ref().and_then(|o| o.proxies.as_ref()) - } - - pub(crate) fn current_groups(&self) -> Option<&String> { - self.option.as_ref().and_then(|o| o.groups.as_ref()) - } + current_field!(current_merge, merge); + current_field!(current_script, script); + current_field!(current_rules, rules); + current_field!(current_proxies, proxies); + current_field!(current_groups, groups); } fn allow_auto_update_enabled(option: Option<&PrfOption>) -> bool { diff --git a/src-tauri/src/config/profiles.rs b/src-tauri/src/config/profiles.rs index 4b149b716..23826cdc1 100644 --- a/src-tauri/src/config/profiles.rs +++ b/src-tauri/src/config/profiles.rs @@ -1,5 +1,5 @@ use super::{ - PrfOption, + PrfOption, patch_opt_field, prfitem::{PrfItem, PrfSelected, normalize_profile_home_url}, }; use crate::core::notify::{Refresh, announce}; @@ -86,14 +86,6 @@ impl ProfileDeletePlan { } } -macro_rules! patch { - ($lv: expr, $rv: expr, $key: tt) => { - if ($rv.$key).is_some() { - $lv.$key = $rv.$key.to_owned(); - } - }; -} - impl IProfiles { fn take_item_file_by_uid(items: &mut Vec, target_uid: Option<&str>) -> Option { let index = items.iter().position(|item| item.uid.as_deref() == target_uid)?; @@ -244,15 +236,15 @@ impl IProfiles { for each in items.iter_mut() { if each.uid.as_deref() == Some(uid) { - patch!(each, item, itype); - patch!(each, item, name); - patch!(each, item, desc); - patch!(each, item, file); - patch!(each, item, url); - patch!(each, item, selected); - patch!(each, item, extra); - patch!(each, item, updated); - patch!(each, item, option); + patch_opt_field!(each, item, itype); + patch_opt_field!(each, item, name); + patch_opt_field!(each, item, desc); + patch_opt_field!(each, item, file); + patch_opt_field!(each, item, url); + patch_opt_field!(each, item, selected); + patch_opt_field!(each, item, extra); + patch_opt_field!(each, item, updated); + patch_opt_field!(each, item, option); self.items = Some(items); return self.save_file().await; @@ -420,6 +412,21 @@ pub(crate) async fn profiles_append_item_with_filedata_safe(item: &PrfItem, file profiles_append_item_safe(item).await } +/// Applies `edit` to the committed profiles; commits only when the edit succeeds. +async fn with_profiles_edit(edit: F) -> Result<()> +where + F: FnOnce(IProfiles) -> Fut + Send, + Fut: std::future::Future> + Send, +{ + Config::profiles() + .await + .with_data_modify(|profiles| async move { + let profiles = edit(profiles).await?; + Ok((profiles, ())) + }) + .await +} + pub(crate) async fn profiles_append_item_safe(item: &mut PrfItem) -> Result<()> { let profiles = Config::profiles().await; profiles_append_item_to_safe(&profiles, item).await @@ -435,43 +442,19 @@ pub(super) async fn profiles_append_item_to_safe(profiles: &Draft, it } pub(crate) async fn profiles_patch_item_safe(index: &str, item: &PrfItem) -> Result<()> { - Config::profiles() - .await - .with_data_modify(|mut profiles| async move { - profiles.patch_item(index, item).await?; - Ok((profiles, ())) - }) - .await + with_profiles_edit(|mut profiles| async move { profiles.patch_item(index, item).await.map(|_| profiles) }).await } pub(crate) async fn profiles_reorder_safe(active_id: &str, over_id: &str) -> Result<()> { - Config::profiles() - .await - .with_data_modify(|mut profiles| async move { - profiles.reorder(active_id, over_id).await?; - Ok((profiles, ())) - }) - .await + with_profiles_edit(|mut profiles| async move { profiles.reorder(active_id, over_id).await.map(|_| profiles) }).await } pub(crate) async fn profiles_save_file_safe() -> Result<()> { - Config::profiles() - .await - .with_data_modify(|profiles| async move { - profiles.save_file().await?; - Ok((profiles, ())) - }) - .await + with_profiles_edit(|profiles| async move { profiles.save_file().await.map(|_| profiles) }).await } pub(crate) async fn profiles_update_item_safe(index: &str, item: &mut PrfItem) -> Result<()> { - Config::profiles() - .await - .with_data_modify(|mut profiles| async move { - profiles.update_item(index, item).await?; - Ok((profiles, ())) - }) - .await + with_profiles_edit(|mut profiles| async move { profiles.update_item(index, item).await.map(|_| profiles) }).await } #[derive(Debug, PartialEq, Eq)] @@ -713,19 +696,21 @@ where Ok(()) } +fn current_item_mut(profiles: &mut IProfiles) -> Option<&mut PrfItem> { + let current = profiles.current.clone()?; + profiles + .items + .as_mut()? + .iter_mut() + .find(|item| item.uid.as_ref() == Some(¤t)) +} + /// Records a backend-made selection so the next core start restores it. pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> { let group_name = String::from(group_name); let node = String::from(node); persist_selected_update(true, move |mut profiles| async move { - let Some(current) = profiles.current.clone() else { - return Ok((profiles, false)); - }; - let Some(item) = profiles - .items - .as_mut() - .and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t))) - else { + let Some(item) = current_item_mut(&mut profiles) else { return Ok((profiles, false)); }; @@ -760,14 +745,7 @@ fn remove_selected_node(selected: &mut Vec, group_name: &str) -> bo pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> { persist_selected_update(true, move |mut profiles| async move { - let Some(current) = profiles.current.clone() else { - return Ok((profiles, false)); - }; - let Some(item) = profiles - .items - .as_mut() - .and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t))) - else { + let Some(item) = current_item_mut(&mut profiles) else { return Ok((profiles, false)); }; diff --git a/src-tauri/src/config/verge.rs b/src-tauri/src/config/verge.rs index 45b0e18ec..d3b58dcf0 100644 --- a/src-tauri/src/config/verge.rs +++ b/src-tauri/src/config/verge.rs @@ -1,6 +1,6 @@ use crate::config::Config; use crate::{ - config::{DEFAULT_PAC, deserialize_encrypted, serialize_encrypted}, + config::{DEFAULT_PAC, deserialize_encrypted, patch_opt_field, serialize_encrypted}, constants::network, utils::{dirs, help}, }; @@ -39,7 +39,7 @@ pub struct IVerge { pub traffic_graph: Option, - /// show memory info (only for Clash Meta) + /// show memory info (only for Mihomo) pub enable_memory_usage: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -432,98 +432,90 @@ impl 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.clone(); - } - }; - } + patch_opt_field!(self, patch, app_log_level); + patch_opt_field!(self, patch, app_log_max_size); + patch_opt_field!(self, patch, app_log_max_count); - patch!(app_log_level); - patch!(app_log_max_size); - patch!(app_log_max_count); - - patch!(language); - patch!(theme_mode); - patch!(tray_event); - patch!(env_type); - patch!(start_page); - patch!(startup_script); - patch!(traffic_graph); - patch!(enable_memory_usage); - patch!(enable_group_icon); - patch!(pause_render_traffic_stats_on_blur); + patch_opt_field!(self, patch, language); + patch_opt_field!(self, patch, theme_mode); + patch_opt_field!(self, patch, tray_event); + patch_opt_field!(self, patch, env_type); + patch_opt_field!(self, patch, start_page); + patch_opt_field!(self, patch, startup_script); + patch_opt_field!(self, patch, traffic_graph); + patch_opt_field!(self, patch, enable_memory_usage); + patch_opt_field!(self, patch, enable_group_icon); + patch_opt_field!(self, patch, pause_render_traffic_stats_on_blur); #[cfg(target_os = "macos")] - patch!(tray_icon); - patch!(menu_icon); - patch!(menu_order); - patch!(proxy_group_tools_position); - patch!(notice_position); - patch!(collapse_navbar); - patch!(common_tray_icon); - patch!(sysproxy_tray_icon); - patch!(tun_tray_icon); + patch_opt_field!(self, patch, tray_icon); + patch_opt_field!(self, patch, menu_icon); + patch_opt_field!(self, patch, menu_order); + patch_opt_field!(self, patch, proxy_group_tools_position); + patch_opt_field!(self, patch, notice_position); + patch_opt_field!(self, patch, collapse_navbar); + patch_opt_field!(self, patch, common_tray_icon); + patch_opt_field!(self, patch, sysproxy_tray_icon); + patch_opt_field!(self, patch, tun_tray_icon); - patch!(enable_tun_mode); - patch!(enable_auto_launch); - patch!(enable_silent_start); - patch!(enable_hover_jump_navigator); - patch!(hover_jump_navigator_delay); + patch_opt_field!(self, patch, enable_tun_mode); + patch_opt_field!(self, patch, enable_auto_launch); + patch_opt_field!(self, patch, enable_silent_start); + patch_opt_field!(self, patch, enable_hover_jump_navigator); + patch_opt_field!(self, patch, hover_jump_navigator_delay); #[cfg(not(target_os = "windows"))] - patch!(verge_redir_port); + patch_opt_field!(self, patch, verge_redir_port); #[cfg(not(target_os = "windows"))] - patch!(verge_redir_enabled); + patch_opt_field!(self, patch, verge_redir_enabled); #[cfg(target_os = "linux")] - patch!(verge_tproxy_port); + patch_opt_field!(self, patch, verge_tproxy_port); #[cfg(target_os = "linux")] - patch!(verge_tproxy_enabled); - patch!(verge_mixed_port); - patch!(verge_socks_port); - patch!(verge_socks_enabled); - patch!(verge_port); - patch!(verge_http_enabled); - patch!(enable_system_proxy); - patch!(enable_proxy_guard); - patch!(enable_bypass_check); - patch!(use_default_bypass); - patch!(system_proxy_bypass); - patch!(proxy_guard_duration); - patch!(proxy_auto_config); - patch!(pac_file_content); - patch!(proxy_host); - patch!(theme_setting); - patch!(web_ui_list); - patch!(clash_core); - patch!(hotkeys); - patch!(enable_global_hotkey); + patch_opt_field!(self, patch, verge_tproxy_enabled); + patch_opt_field!(self, patch, verge_mixed_port); + patch_opt_field!(self, patch, verge_socks_port); + patch_opt_field!(self, patch, verge_socks_enabled); + patch_opt_field!(self, patch, verge_port); + patch_opt_field!(self, patch, verge_http_enabled); + patch_opt_field!(self, patch, enable_system_proxy); + patch_opt_field!(self, patch, enable_proxy_guard); + patch_opt_field!(self, patch, enable_bypass_check); + patch_opt_field!(self, patch, use_default_bypass); + patch_opt_field!(self, patch, system_proxy_bypass); + patch_opt_field!(self, patch, proxy_guard_duration); + patch_opt_field!(self, patch, proxy_auto_config); + patch_opt_field!(self, patch, pac_file_content); + patch_opt_field!(self, patch, proxy_host); + patch_opt_field!(self, patch, theme_setting); + patch_opt_field!(self, patch, web_ui_list); + patch_opt_field!(self, patch, clash_core); + patch_opt_field!(self, patch, hotkeys); + patch_opt_field!(self, patch, enable_global_hotkey); - patch!(auto_close_connection); - patch!(auto_check_update); - patch!(default_latency_test); - patch!(default_latency_timeout); - patch!(enable_auto_delay_detection); - patch!(auto_delay_detection_interval_minutes); - patch!(enable_builtin_enhanced); - patch!(proxy_layout_column); - patch!(test_list); - patch!(auto_log_clean); - patch!(enable_auto_backup_schedule); - patch!(auto_backup_interval_hours); - patch!(auto_backup_on_change); + patch_opt_field!(self, patch, auto_close_connection); + patch_opt_field!(self, patch, auto_check_update); + patch_opt_field!(self, patch, default_latency_test); + patch_opt_field!(self, patch, default_latency_timeout); + patch_opt_field!(self, patch, enable_auto_delay_detection); + patch_opt_field!(self, patch, auto_delay_detection_interval_minutes); + patch_opt_field!(self, patch, enable_builtin_enhanced); + patch_opt_field!(self, patch, proxy_layout_column); + patch_opt_field!(self, patch, test_list); + patch_opt_field!(self, patch, auto_log_clean); + patch_opt_field!(self, patch, enable_auto_backup_schedule); + patch_opt_field!(self, patch, auto_backup_interval_hours); + patch_opt_field!(self, patch, auto_backup_on_change); - patch!(webdav_url); - patch!(webdav_username); - patch!(webdav_password); + patch_opt_field!(self, patch, webdav_url); + patch_opt_field!(self, patch, webdav_username); + patch_opt_field!(self, patch, webdav_password); #[cfg(target_os = "macos")] - patch!(enable_tray_speed); - patch!(tray_proxy_groups_display_mode); - patch!(tray_inline_outbound_modes); - patch!(enable_auto_light_weight_mode); - patch!(auto_light_weight_minutes); - patch!(enable_dns_settings); - patch!(home_cards); - patch!(enable_external_controller); + patch_opt_field!(self, patch, enable_tray_speed); + patch_opt_field!(self, patch, tray_proxy_groups_display_mode); + patch_opt_field!(self, patch, tray_inline_outbound_modes); + patch_opt_field!(self, patch, enable_auto_light_weight_mode); + patch_opt_field!(self, patch, auto_light_weight_minutes); + patch_opt_field!(self, patch, enable_dns_settings); + patch_opt_field!(self, patch, home_cards); + patch_opt_field!(self, patch, enable_external_controller); } pub fn get_log_level(&self) -> LevelFilter { diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs index da67ce13e..3f85ced31 100644 --- a/src-tauri/src/constants.rs +++ b/src-tauri/src/constants.rs @@ -14,6 +14,13 @@ pub mod network { } } +pub mod sysproxy { + /// Single source of the macOS default bypass list; both proxy backends compose from it. + #[cfg(target_os = "macos")] + pub const MACOS_DEFAULT_BYPASS: &str = + "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,"; +} + pub mod timing { use super::Duration; diff --git a/src-tauri/src/core/auto_backup.rs b/src-tauri/src/core/auto_backup.rs index c475cb711..dad3f62c1 100644 --- a/src-tauri/src/core/auto_backup.rs +++ b/src-tauri/src/core/auto_backup.rs @@ -7,11 +7,10 @@ use crate::{ use anyhow::Result; use chrono::Local; use clash_verge_logging::{Type, logging}; -use parking_lot::RwLock; use std::{ path::PathBuf, sync::{ - Arc, OnceLock, + OnceLock, atomic::{AtomicBool, AtomicI64, Ordering}, }, time::{Duration, UNIX_EPOCH}, @@ -82,8 +81,9 @@ impl Default for AutoBackupSettings { } pub struct AutoBackupManager { - settings: Arc>, settings_tx: watch::Sender, + // The watch channel is the single settings store; no duplicate copies. + settings_rx: watch::Receiver, runner_started: AtomicBool, exec_lock: Mutex<()>, last_backup: AtomicI64, @@ -93,10 +93,10 @@ impl AutoBackupManager { pub fn global() -> &'static Self { static INSTANCE: OnceLock = OnceLock::new(); INSTANCE.get_or_init(|| { - let (tx, _rx) = watch::channel(AutoBackupSettings::default()); + let (settings_tx, settings_rx) = watch::channel(AutoBackupSettings::default()); Self { - settings: Arc::new(RwLock::new(AutoBackupSettings::default())), - settings_tx: tx, + settings_tx, + settings_rx, runner_started: AtomicBool::new(false), exec_lock: Mutex::new(()), last_backup: AtomicI64::new(0), @@ -106,20 +106,14 @@ impl AutoBackupManager { pub async fn init(&self) -> Result<()> { let settings = Self::load_settings().await; - { - *self.settings.write() = settings; - } - let _ = self.settings_tx.send(settings); + let _ = self.settings_tx.send_replace(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); + let _ = self.settings_tx.send_replace(settings); self.maybe_start_runner(settings); Ok(()) } @@ -193,7 +187,7 @@ impl AutoBackupManager { } async fn execute_trigger(&self, trigger: AutoBackupTrigger) -> Result<()> { - let snapshot = *self.settings.read(); + let snapshot = *self.settings_rx.borrow(); if trigger.is_schedule() && !snapshot.schedule_enabled { return Ok(()); diff --git a/src-tauri/src/core/hotkey.rs b/src-tauri/src/core/hotkey.rs index 75027d176..d23ae2a57 100755 --- a/src-tauri/src/core/hotkey.rs +++ b/src-tauri/src/core/hotkey.rs @@ -466,12 +466,3 @@ impl Hotkey { (del_list, add_list) } } - -impl Drop for Hotkey { - fn drop(&mut self) { - let app_handle = handle::Handle::app_handle(); - if let Err(e) = app_handle.global_shortcut().unregister_all() { - logging!(error, Type::Hotkey, "Error unregistering all hotkeys: {:?}", e); - } - } -} diff --git a/src-tauri/src/core/lightweight.rs b/src-tauri/src/core/lightweight.rs index 9b4cb0041..fd1e4d363 100644 --- a/src-tauri/src/core/lightweight.rs +++ b/src-tauri/src/core/lightweight.rs @@ -40,8 +40,47 @@ impl LightweightState { static LIGHTWEIGHT_STATE: AtomicU8 = AtomicU8::new(LightweightState::Normal as u8); -static WINDOW_CLOSE_HANDLER_ID: AtomicU32 = AtomicU32::new(0); -static WEBVIEW_FOCUS_HANDLER_ID: AtomicU32 = AtomicU32::new(0); +struct WindowListener { + handler_id: AtomicU32, + event: &'static str, + label: &'static str, +} + +impl WindowListener { + fn setup(&self, on_event: impl Fn() + Send + 'static) { + if let Some(window) = WindowManager::get_main_window() { + let previous_handler_id = self.handler_id.swap(0, Ordering::AcqRel); + if previous_handler_id != 0 { + window.unlisten(previous_handler_id); + logging!(debug, Type::Lightweight, "覆盖旧的{}监听", self.label); + } + let handler_id = window.listen(self.event, move |_event| on_event()); + self.handler_id.store(handler_id, Ordering::Release); + } + } + + fn cancel(&self) { + let id = self.handler_id.swap(0, Ordering::AcqRel); + if id != 0 { + if let Some(window) = WindowManager::get_main_window() { + window.unlisten(id); + } + logging!(debug, Type::Lightweight, "取消了{}监听", self.label); + } + } +} + +static WINDOW_CLOSE_LISTENER: WindowListener = WindowListener { + handler_id: AtomicU32::new(0), + event: "tauri://close-requested", + label: "窗口关闭", +}; + +static WEBVIEW_FOCUS_LISTENER: WindowListener = WindowListener { + handler_id: AtomicU32::new(0), + event: "tauri://focus", + label: "窗口焦点", +}; static CANCEL_TX: Mutex>> = Mutex::new(None); @@ -154,55 +193,27 @@ pub async fn add_light_weight_timer() { } fn setup_window_close_listener() { - if let Some(window) = WindowManager::get_main_window() { - let previous_handler_id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel); - if previous_handler_id != 0 { - window.unlisten(previous_handler_id); - logging!(debug, Type::Lightweight, "覆盖旧的窗口关闭监听"); - } - let handler_id = window.listen("tauri://close-requested", move |_event| { - std::mem::drop(AsyncHandler::spawn(|| async { - setup_light_weight_timer().await; - })); - logging!(info, Type::Lightweight, "监听到关闭请求,开始轻量模式计时"); - }); - WINDOW_CLOSE_HANDLER_ID.store(handler_id, Ordering::Release); - } + WINDOW_CLOSE_LISTENER.setup(|| { + std::mem::drop(AsyncHandler::spawn(|| async { + setup_light_weight_timer().await; + })); + logging!(info, Type::Lightweight, "监听到关闭请求,开始轻量模式计时"); + }); } fn cancel_window_close_listener() { - let id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel); - if id != 0 { - if let Some(window) = WindowManager::get_main_window() { - window.unlisten(id); - } - logging!(debug, Type::Lightweight, "取消了窗口关闭监听"); - } + WINDOW_CLOSE_LISTENER.cancel(); } fn setup_webview_focus_listener() { - if let Some(window) = WindowManager::get_main_window() { - let previous_handler_id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel); - if previous_handler_id != 0 { - window.unlisten(previous_handler_id); - logging!(debug, Type::Lightweight, "覆盖旧的窗口焦点监听"); - } - let handler_id = window.listen("tauri://focus", move |_event| { - cancel_light_weight_timer(); - logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时"); - }); - WEBVIEW_FOCUS_HANDLER_ID.store(handler_id, Ordering::Release); - } + WEBVIEW_FOCUS_LISTENER.setup(|| { + cancel_light_weight_timer(); + logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时"); + }); } fn cancel_webview_focus_listener() { - let id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel); - if id != 0 { - if let Some(window) = WindowManager::get_main_window() { - window.unlisten(id); - } - logging!(debug, Type::Lightweight, "取消了窗口焦点监听"); - } + WEBVIEW_FOCUS_LISTENER.cancel(); } async fn setup_light_weight_timer() { diff --git a/src-tauri/src/core/proxy_control.rs b/src-tauri/src/core/proxy_control.rs index e9f482247..19bc09456 100644 --- a/src-tauri/src/core/proxy_control.rs +++ b/src-tauri/src/core/proxy_control.rs @@ -6,7 +6,7 @@ use crate::{ notification::{self, FailedOperation}, runstate::RUN_STATE, service, - sysopt::Sysopt, + sysopt::{Sysopt, format_bypass}, }, process::AsyncHandler, utils::{ @@ -204,8 +204,6 @@ const fn refusal_classification(service_usable: Option) -> SysproxyFailure } const LOOPBACK_HOST: &str = "127.0.0.1"; -const MACOS_DEFAULT_BYPASS: &str = - "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,"; const MAX_SERVICE_BYPASS_LEN: usize = 8192; static SERVICE_PROXY_OPERATIONS: ServiceProxyOperations = ServiceProxyOperations::new(); @@ -327,13 +325,7 @@ fn service_bypass(verge: &IVerge) -> Result { let custom = verge.system_proxy_bypass.as_deref().unwrap_or(""); ensure!(!custom.contains('\0'), "system proxy bypass contains NUL"); - let mut bypass = if custom.is_empty() { - MACOS_DEFAULT_BYPASS.to_owned() - } else if verge.use_default_bypass.unwrap_or(true) { - format!("{MACOS_DEFAULT_BYPASS},{custom}") - } else { - custom.to_owned() - }; + let mut bypass: String = format_bypass(verge.use_default_bypass.unwrap_or(true), custom).into(); truncate_utf8(&mut bypass, MAX_SERVICE_BYPASS_LEN); Ok(bypass) } diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index 7d9242087..5733a24aa 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -593,50 +593,71 @@ fn packaged_service_tool_path(file_name: &str, packaged_path: impl FnOnce() -> R } #[cfg(target_os = "windows")] -fn uninstall_service() -> Result<()> { - logging!(info, Type::Service, "uninstall service"); - +pub(super) fn run_maybe_elevated(program: &Path, hide_window: bool) -> Result { use deelevate::{PrivilegeLevel, Token}; use runas::Command as RunasCommand; use std::os::windows::process::CommandExt as _; - let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall.exe", || { - Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall.exe")) - })?; - - if !uninstall_path.exists() { - bail!(format!("uninstaller not found: {uninstall_path:?}")); - } - let token = Token::with_current_process()?; let level = token.privilege_level()?; let status = match level { - PrivilegeLevel::NotPrivileged => RunasCommand::new(uninstall_path).show(false).status()?, - _ => StdCommand::new(uninstall_path).creation_flags(0x08000000).status()?, + PrivilegeLevel::NotPrivileged => { + let mut command = RunasCommand::new(program); + if hide_window { + command.show(false); + } + command.status()? + } + _ => { + let mut command = StdCommand::new(program); + if hide_window { + // 0x08000000 is CREATE_NO_WINDOW, the direct counterpart of runas's show(false). + command.creation_flags(0x08000000); + } + command.status()? + } }; + Ok(status) +} +fn uninstaller_tool_path(file_name: &str, packaged_path: impl FnOnce() -> Result) -> Result { + let uninstall_path = packaged_service_tool_path(file_name, packaged_path)?; + if !uninstall_path.exists() { + bail!(format!("uninstaller not found: {uninstall_path:?}")); + } + Ok(uninstall_path) +} + +fn ensure_uninstall_success(status: &std::process::ExitStatus) -> Result<()> { if !status.success() { bail!( "failed to uninstall service with status {}", status.code().unwrap_or(-1) ); } - Ok(()) } +#[cfg(target_os = "windows")] +fn uninstall_service() -> Result<()> { + logging!(info, Type::Service, "uninstall service"); + + let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall.exe", || { + Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall.exe")) + })?; + + let status = run_maybe_elevated(&uninstall_path, true)?; + ensure_uninstall_success(&status) +} + #[cfg(target_os = "linux")] fn uninstall_service() -> Result<()> { logging!(info, Type::Service, "uninstall service"); - let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall", || { + let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall", || { Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-uninstall")) })?; - if !uninstall_path.exists() { - bail!(format!("uninstaller not found: {uninstall_path:?}")); - } - let elevator = crate::utils::help::linux_elevator(); let status = if linux_running_as_root() { StdCommand::new(&uninstall_path).status()? @@ -666,14 +687,7 @@ fn uninstall_service() -> Result<()> { status.code().unwrap_or(-1) ); - if !status.success() { - bail!( - "failed to uninstall service with status {}", - status.code().unwrap_or(-1) - ); - } - - Ok(()) + ensure_uninstall_success(&status) } #[cfg(target_os = "linux")] @@ -688,14 +702,10 @@ fn linux_running_as_root() -> bool { fn uninstall_service() -> Result<()> { logging!(info, Type::Service, "uninstall service"); - let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall", || { + let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall", || { Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall")) })?; - if !uninstall_path.exists() { - bail!(format!("uninstaller not found: {uninstall_path:?}")); - } - let uninstall_path = macos_service_tool_path(&uninstall_path)?; let uninstall_shell: String = uninstall_path.to_string_lossy().into_owned(); @@ -711,14 +721,7 @@ fn uninstall_service() -> Result<()> { let status = StdCommand::new("osascript").args(vec!["-e", &command]).status()?; - if !status.success() { - bail!( - "failed to uninstall service with status {}", - status.code().unwrap_or(-1) - ); - } - - Ok(()) + ensure_uninstall_success(&status) } fn install_service() -> Result<()> { diff --git a/src-tauri/src/core/sysopt.rs b/src-tauri/src/core/sysopt.rs index 9b3d158dc..c32b4ac15 100644 --- a/src-tauri/src/core/sysopt.rs +++ b/src-tauri/src/core/sysopt.rs @@ -241,20 +241,19 @@ async fn current_os_proxy_state(sys: Sysproxy, auto: Autoproxy) -> OsProxyState } pub(crate) struct Sysopt { - update_lock: TokioMutex<()>, + // Owns the proxy target pair; every reader and writer goes through this lock. + proxy_state_lock: TokioMutex<(Sysproxy, Autoproxy)>, guard_operation_lock: TokioMutex<()>, reset_sysproxy: AtomicBool, - inner_proxy: Arc>, guard: Arc>, } impl Default for Sysopt { fn default() -> Self { Self { - update_lock: TokioMutex::new(()), + proxy_state_lock: TokioMutex::new((Sysproxy::default(), Autoproxy::default())), guard_operation_lock: TokioMutex::new(()), reset_sysproxy: AtomicBool::new(false), - inner_proxy: Arc::new(RwLock::new((Sysproxy::default(), Autoproxy::default()))), guard: Arc::new(RwLock::new(GuardMonitor::new(GuardType::None, Duration::from_secs(30)))), } } @@ -269,10 +268,9 @@ static DEFAULT_BYPASS: &str = "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172 #[cfg(any(target_os = "linux", target_os = "macos"))] static BYPASS_SEPARATOR: &str = ","; #[cfg(target_os = "macos")] -static DEFAULT_BYPASS: &str = - "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,"; +static DEFAULT_BYPASS: &str = crate::constants::sysproxy::MACOS_DEFAULT_BYPASS; -fn format_bypass(use_default: bool, custom_bypass: &str) -> String { +pub(super) fn format_bypass(use_default: bool, custom_bypass: &str) -> String { if custom_bypass.is_empty() { DEFAULT_BYPASS.into() } else if use_default { @@ -361,7 +359,7 @@ impl Sysopt { } } while self.access_guard().read().get_state().is_pendding() { - tokio::task::yield_now().await; + tokio::time::sleep(Duration::from_millis(10)).await; } // `start()` false may also mean a guard was already running. !self.access_guard().read().get_state().is_stopped() @@ -409,18 +407,21 @@ impl Sysopt { /// configuration instead of a partially-applied one (e.g. SOCKS already /// disabled but HTTP still enabled mid-transition). pub(crate) async fn wait_idle(&self) { - let _ = self.update_lock.lock().await; + let _ = self.proxy_state_lock.lock().await; } /// init the sysproxy + #[allow( + clippy::significant_drop_tightening, + reason = "the proxy state lock must stay held until the OS write finishes; wait_idle relies on it" + )] pub(super) async fn update_sysproxy(&self) -> Result<()> { - let _lock = self.update_lock.lock().await; + let mut proxy_state = self.proxy_state_lock.lock().await; let verge = Config::verge().await.latest_arc(); // Configured, not live: this runs while the Core is being started or restarted, and // asking a Core that is not up yet would only fall back here anyway. let port = MixedPort::desired().await; let pac_port = server::embedded_server_port()?; - // 先 await, 避免持有锁导致的 Send 问题 let bypass = get_bypass().await; let (sys_enable, pac_enable, proxy_host, proxy_guard) = ( @@ -431,7 +432,7 @@ impl Sysopt { ); let (sys, auto, guard_type) = { - let (sys, auto) = &mut *self.inner_proxy.write(); + let (sys, auto) = &mut *proxy_state; sys.host = proxy_host.into(); sys.port = port; sys.bypass = bypass.into(); @@ -528,6 +529,10 @@ impl Sysopt { } /// reset the sysproxy + #[allow( + clippy::significant_drop_tightening, + reason = "the proxy state lock must stay held until the disable finishes; wait_idle relies on it" + )] pub(super) async fn reset_sysproxy(&self) -> Result<()> { if self .reset_sysproxy @@ -539,13 +544,13 @@ impl Sysopt { defer! { self.reset_sysproxy.store(false, Ordering::SeqCst); } - let _lock = self.update_lock.lock().await; + let mut proxy_state = self.proxy_state_lock.lock().await; let _guard_operation = self.guard_operation_lock.lock().await; let drained = self.stop_proxy_guard_locked().await; // 直接关闭所有代理 let (sys, auto) = { - let (sys, auto) = &mut *self.inner_proxy.write(); + let (sys, auto) = &mut *proxy_state; sys.enable = false; auto.enable = false; (sys.clone(), auto.clone()) diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index ae8c63c2c..f7fa4a6d6 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -636,7 +636,8 @@ async fn create_tray_menu( .tray_proxy_groups_display_mode .as_deref() .unwrap_or("default"); - let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable"; + let include_proxy_groups = + options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable"); let proxy_group_order_map = runtime_proxy_groups_order; diff --git a/src-tauri/src/core/win_uwp.rs b/src-tauri/src/core/win_uwp.rs index 3db9a1a44..47a186a0a 100644 --- a/src-tauri/src/core/win_uwp.rs +++ b/src-tauri/src/core/win_uwp.rs @@ -1,10 +1,7 @@ #![cfg(target_os = "windows")] -use crate::utils::dirs; +use crate::{core::service, utils::dirs}; use anyhow::{Result, bail}; -use deelevate::{PrivilegeLevel, Token}; -use runas::Command as RunasCommand; -use std::process::Command as StdCommand; pub fn invoke_uwptools() -> Result<()> { let resource_dir = dirs::app_resources_dir()?; @@ -14,13 +11,7 @@ pub fn invoke_uwptools() -> Result<()> { bail!("enableLoopback exe not found"); } - let token = Token::with_current_process()?; - let level = token.privilege_level()?; - - match level { - PrivilegeLevel::NotPrivileged => RunasCommand::new(tool_path).status()?, - _ => StdCommand::new(tool_path).status()?, - }; + service::run_maybe_elevated(&tool_path, false)?; Ok(()) } diff --git a/src-tauri/src/enhance/builtin/meta_guard.js b/src-tauri/src/enhance/builtin/meta_guard.js deleted file mode 100644 index d427974c1..000000000 --- a/src-tauri/src/enhance/builtin/meta_guard.js +++ /dev/null @@ -1,8 +0,0 @@ -// This function is exported for use by the Clash core -// eslint-disable-next-line unused-imports/no-unused-vars -function main(config, _name) { - if (config.mode === 'script') { - config.mode = 'rule' - } - return config -} diff --git a/src-tauri/src/enhance/builtin/meta_hy_alpn.js b/src-tauri/src/enhance/builtin/meta_hy_alpn.js deleted file mode 100644 index d7e57dd2a..000000000 --- a/src-tauri/src/enhance/builtin/meta_hy_alpn.js +++ /dev/null @@ -1,12 +0,0 @@ -// This function is exported for use by the Clash core -// eslint-disable-next-line unused-imports/no-unused-vars -function main(config, _name) { - if (Array.isArray(config.proxies)) { - config.proxies.forEach((p, i) => { - if (p.type === 'hysteria' && typeof p.alpn === 'string') { - config.proxies[i].alpn = [p.alpn] - } - }) - } - return config -} diff --git a/src-tauri/src/enhance/chain.rs b/src-tauri/src/enhance/chain.rs index 797b02cd7..df25c712f 100644 --- a/src-tauri/src/enhance/chain.rs +++ b/src-tauri/src/enhance/chain.rs @@ -22,12 +22,6 @@ pub enum ChainType { Groups(SeqMap), } -#[derive(Debug, Clone)] -pub enum ChainSupport { - ClashMeta, - ClashMetaAlpha, -} - // Helper trait to allow async conversion pub trait AsyncChainItemFrom { async fn from_async(item: &PrfItem) -> Option; @@ -53,70 +47,16 @@ impl AsyncChainItemFrom for Option { uid, data: ChainType::Merge(help::read_mapping(&path).await.ok()?), }), - "rules" => { + "rules" | "proxies" | "groups" => { let seq_map = help::read_yaml::(&path).await.ok()?; - Some(ChainItem { - uid, - data: ChainType::Rules(seq_map), - }) - } - "proxies" => { - let seq_map = help::read_yaml::(&path).await.ok()?; - Some(ChainItem { - uid, - data: ChainType::Proxies(seq_map), - }) - } - "groups" => { - let seq_map = help::read_yaml::(&path).await.ok()?; - Some(ChainItem { - uid, - data: ChainType::Groups(seq_map), - }) + let data = match itype { + "rules" => ChainType::Rules(seq_map), + "proxies" => ChainType::Proxies(seq_map), + _ => ChainType::Groups(seq_map), + }; + Some(ChainItem { uid, data }) } _ => None, } } } -impl ChainItem { - /// 内建支持一些脚本 - pub fn builtin() -> Vec<(ChainSupport, Self)> { - // meta 的一些处理 - let meta_guard = Self::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js")); - - // meta 1.13.2 alpn string 转 数组 - let hy_alpn = Self::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js")); - - // meta 的一些处理 - let meta_guard_alpha = Self::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js")); - - // meta 1.13.2 alpn string 转 数组 - let hy_alpn_alpha = Self::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js")); - - vec![ - (ChainSupport::ClashMeta, hy_alpn), - (ChainSupport::ClashMeta, meta_guard), - (ChainSupport::ClashMetaAlpha, hy_alpn_alpha), - (ChainSupport::ClashMetaAlpha, meta_guard_alpha), - ] - } - - pub fn to_script, D: Into>(uid: U, data: D) -> Self { - Self { - uid: uid.into(), - data: ChainType::Script(data.into()), - } - } -} - -impl ChainSupport { - pub fn is_support(&self, core: Option<&String>) -> bool { - match core { - Some(core) => matches!( - (self, core.as_str()), - (Self::ClashMeta, "verge-mihomo") | (Self::ClashMetaAlpha, "verge-mihomo-alpha") - ), - None => true, - } - } -} diff --git a/src-tauri/src/enhance/field.rs b/src-tauri/src/enhance/field.rs index 04e73a786..038980f55 100644 --- a/src-tauri/src/enhance/field.rs +++ b/src-tauri/src/enhance/field.rs @@ -27,7 +27,7 @@ fn lowercase_key(key: &str) -> Value { pub fn use_lowercase(config: &Mapping) -> Mapping { let mut lowercased = Mapping::new(); - for (key, value) in config.into_iter() { + for (key, value) in config.iter() { if let Some(key_str) = key.as_str() { lowercased.insert(lowercase_key(key_str), value.clone()); } @@ -35,17 +35,6 @@ pub fn use_lowercase(config: &Mapping) -> Mapping { lowercased } -pub fn use_lowercase_owned(config: Mapping) -> Mapping { - let mut lowercased = Mapping::new(); - - for (key, value) in config { - if let Some(key_str) = key.as_str() { - lowercased.insert(lowercase_key(key_str), value); - } - } - lowercased -} - pub fn use_sort(mut config: Mapping) -> Mapping { let mut sorted = Mapping::new(); HANDLE_FIELDS.into_iter().for_each(|key| { diff --git a/src-tauri/src/enhance/mod.rs b/src-tauri/src/enhance/mod.rs index ba0ba5708..6fab9c2be 100644 --- a/src-tauri/src/enhance/mod.rs +++ b/src-tauri/src/enhance/mod.rs @@ -8,7 +8,7 @@ mod tun; use self::{ chain::{AsyncChainItemFrom as _, ChainItem, ChainType}, - field::{use_keys, use_lowercase_owned, use_sort}, + field::{use_keys, use_lowercase, use_sort}, merge::use_merge, script::use_script, seq::{SeqMap, use_seq}, @@ -34,7 +34,6 @@ type ResultLog = Vec<(String, String)>; #[derive(Debug)] struct ConfigValues { clash_config: Mapping, - clash_core: Option, enable_tun: bool, enable_builtin: bool, socks_enabled: bool, @@ -128,8 +127,7 @@ async fn get_config_values(profile_uid: &str) -> ConfigValues { let dns_settings = verge_arc.dns_settings_for(profile_uid); let dns_override_confirmation = dns_settings.confirmation; - let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = ( - Some(verge_arc.get_valid_clash_core()), + let (enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = ( enable_tun_mode.unwrap_or(false), enable_builtin_enhanced.unwrap_or(true), verge_socks_enabled.unwrap_or(false), @@ -148,7 +146,6 @@ async fn get_config_values(profile_uid: &str) -> ConfigValues { ConfigValues { clash_config, - clash_core, enable_tun, enable_builtin, socks_enabled, @@ -199,35 +196,25 @@ async fn collect_profile_items(profiles: &IProfiles) -> Result { let name = current_item.name.clone().unwrap_or_default(); + let ProfileItems { + merge_item: default_merge, + script_item: default_script, + rules_item: default_rules, + proxies_item: default_proxies, + groups_item: default_groups, + global_merge: default_global_merge, + global_script: default_global_script, + .. + } = ProfileItems::default(); + let (merge_item, script_item, rules_item, proxies_item, groups_item, global_merge, global_script) = tokio::join!( - chain_item_or_default(profiles.get_item(&merge_uid).ok(), || ChainItem { - uid: "".into(), - data: ChainType::Merge(Mapping::new()), - },), - chain_item_or_default(profiles.get_item(&script_uid).ok(), || ChainItem { - uid: "".into(), - data: ChainType::Script(tmpl::ITEM_SCRIPT.into()), - },), - chain_item_or_default(profiles.get_item(&rules_uid).ok(), || ChainItem { - uid: "".into(), - data: ChainType::Rules(SeqMap::default()), - },), - chain_item_or_default(profiles.get_item(&proxies_uid).ok(), || ChainItem { - uid: "".into(), - data: ChainType::Proxies(SeqMap::default()), - },), - chain_item_or_default(profiles.get_item(&groups_uid).ok(), || ChainItem { - uid: "".into(), - data: ChainType::Groups(SeqMap::default()), - },), - chain_item_or_default(profiles.get_item("Merge").ok(), || ChainItem { - uid: "Merge".into(), - data: ChainType::Merge(Mapping::new()), - },), - chain_item_or_default(profiles.get_item("Script").ok(), || ChainItem { - uid: "Script".into(), - data: ChainType::Script(tmpl::ITEM_SCRIPT.into()), - },), + chain_item_or_default(profiles.get_item(&merge_uid).ok(), || default_merge,), + chain_item_or_default(profiles.get_item(&script_uid).ok(), || default_script,), + chain_item_or_default(profiles.get_item(&rules_uid).ok(), || default_rules,), + chain_item_or_default(profiles.get_item(&proxies_uid).ok(), || default_proxies,), + chain_item_or_default(profiles.get_item(&groups_uid).ok(), || default_groups,), + chain_item_or_default(profiles.get_item("Merge").ok(), || default_global_merge,), + chain_item_or_default(profiles.get_item("Script").ok(), || default_global_script,), ); Ok(ProfileItems { @@ -243,16 +230,16 @@ async fn collect_profile_items(profiles: &IProfiles) -> Result { }) } -async fn process_global_items( +async fn process_override_items( mut config: Mapping, mut exists_keys: Vec, mut result_map: HashMap, - global_merge: ChainItem, - global_script: ChainItem, + merge_item: ChainItem, + script_item: ChainItem, profile_name: &String, authoritative: &AuthoritativeFields, ) -> (Mapping, Vec, HashMap) { - if let ChainType::Merge(merge) = global_merge.data { + if let ChainType::Merge(merge) = merge_item.data { exists_keys.extend(use_keys(&merge)); let before = authoritative.current(&config); config = use_merge(&merge, config); @@ -262,11 +249,11 @@ async fn process_global_items( .map(discarded_note) .collect(); if !notes.is_empty() { - result_map.entry(global_merge.uid).or_default().extend(notes); + result_map.entry(merge_item.uid).or_default().extend(notes); } } - if let ChainType::Script(script) = global_script.data { + if let ChainType::Script(script) = script_item.data { let before = authoritative.current(&config); let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await; exists_keys.extend(changed_keys); @@ -277,7 +264,7 @@ async fn process_global_items( .into_iter() .map(discarded_note), ); - result_map.insert(global_script.uid, logs); + result_map.insert(script_item.uid, logs); } (config, exists_keys, result_map) @@ -544,46 +531,6 @@ fn ensure_lan_bind_address(mut config: Mapping) -> Mapping { config } -async fn process_profile_items( - mut config: Mapping, - mut exists_keys: Vec, - mut result_map: HashMap, - merge_item: ChainItem, - script_item: ChainItem, - profile_name: &String, - authoritative: &AuthoritativeFields, -) -> (Mapping, Vec, HashMap) { - if let ChainType::Merge(merge) = merge_item.data { - exists_keys.extend(use_keys(&merge)); - let before = authoritative.current(&config); - config = use_merge(&merge, config); - let notes: ResultLog = authoritative - .overridden(&before, &authoritative.current(&config)) - .into_iter() - .map(discarded_note) - .collect(); - if !notes.is_empty() { - result_map.entry(merge_item.uid).or_default().extend(notes); - } - } - - if let ChainType::Script(script) = script_item.data { - let before = authoritative.current(&config); - let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await; - exists_keys.extend(changed_keys); - config = res_config; - logs.extend( - authoritative - .overridden(&before, &authoritative.current(&config)) - .into_iter() - .map(discarded_note), - ); - result_map.insert(script_item.uid, logs); - } - - (config, exists_keys, result_map) -} - /// Merges the app's clash config over the profile; switches are parameters to keep it pure. fn merge_default_config( mut config: Mapping, @@ -655,25 +602,12 @@ fn merge_default_config( config } -fn apply_builtin_scripts(mut config: Mapping, clash_core: Option, enable_builtin: bool) -> Mapping { +fn apply_builtin_scripts(mut config: Mapping, enable_builtin: bool) -> Mapping { if enable_builtin { - let items: Vec<_> = ChainItem::builtin() - .into_iter() - .filter(|(s, _)| s.is_support(clash_core.as_ref())) - .map(|(_, c)| c) - .collect(); - if !items.is_empty() { - // The JS path saw a lowercased view; lowercase once, only when a builtin runs. - config = use_lowercase_owned(config); - for item in items { - logging!(debug, Type::Core, "run builtin script {}", item.uid); - config = match item.uid.as_str() { - "verge_hy_alpn" => builtin_hy_alpn(config), - "verge_meta_guard" => builtin_meta_guard(config), - _ => config, - }; - } - } + // The JS path saw a lowercased view; lowercase once, only when the builtin runs. + config = use_lowercase(&config); + logging!(debug, Type::Core, "run builtin script verge_hy_alpn"); + config = builtin_hy_alpn(config); } config @@ -693,14 +627,6 @@ fn builtin_hy_alpn(mut config: Mapping) -> Mapping { config } -fn builtin_meta_guard(mut config: Mapping) -> Mapping { - if config.get("mode").and_then(Value::as_str) == Some("script") { - config.insert(Value::from("mode"), Value::from("rule")); - } - - config -} - fn cleanup_proxy_groups(mut config: Mapping) -> Mapping { // built-in proxies docs: https://wiki.metacubex.one/config/proxies/built-in const BUILTIN_POLICIES: &[&str] = &["DIRECT", "REJECT", "REJECT-DROP", "PASS", "PASS-RULE"]; @@ -879,7 +805,6 @@ pub async fn enhance( let cfg_vals = get_config_values(profile_uid).await; let ConfigValues { clash_config, - clash_core, enable_tun, enable_builtin, socks_enabled, @@ -929,13 +854,13 @@ pub async fn enhance( tproxy_enabled, ); - let config = apply_builtin_scripts(config, clash_core, enable_builtin); + let config = apply_builtin_scripts(config, enable_builtin); let config = use_tun(config, enable_tun); let (config, dns_settings) = apply_dns_settings(config, enable_dns_settings).await; let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, dns_settings); - let (config, exists_keys, result_map) = process_global_items( + let (config, exists_keys, result_map) = process_override_items( config, exists_keys, result_map, @@ -946,7 +871,7 @@ pub async fn enhance( ) .await; - let (config, exists_keys, result_map) = process_profile_items( + let (config, exists_keys, result_map) = process_override_items( config, exists_keys, result_map, @@ -1428,7 +1353,7 @@ mod authoritative_field_tests { mod tests { use super::{ AuthoritativeFields, ChainItem, ChainType, Mapping, cleanup_proxy_groups, ensure_lan_bind_address, - process_global_items, process_profile_items, use_keys, + process_override_items, use_keys, }; use std::collections::HashMap; @@ -1476,22 +1401,28 @@ mod tests { data: ChainType::Merge(merge.clone()), }; let profile_name = "test-profile".into(); - let (config, keys, logs) = process_global_items( + let (config, keys, logs) = process_override_items( config, vec![], HashMap::new(), merge_item("global-merge"), - ChainItem::to_script("global-script", script), + ChainItem { + uid: "global-script".into(), + data: ChainType::Script(script.into()), + }, &profile_name, &authoritative, ) .await; - let (config, _, logs) = process_profile_items( + let (config, _, logs) = process_override_items( config, keys, logs, merge_item("profile-merge"), - ChainItem::to_script("profile-script", script), + ChainItem { + uid: "profile-script".into(), + data: ChainType::Script(script.into()), + }, &profile_name, &authoritative, ) @@ -1536,16 +1467,19 @@ mod tests { nested: {winner: global-merge}, dns: {enable: false}, tun: {enable: false}}", )), }; - let global_script = ChainItem::to_script( - "Script", - r#"function main(config) { + let global_script = ChainItem { + uid: "Script".into(), + data: ChainType::Script( + r#"function main(config) { config["global-script-wins"] = "global-script"; config["profile-merge-wins"] = "global-script"; config["profile-script-wins"] = "global-script"; config.nested.winner = "global-script"; return config; - }"#, - ); + }"# + .into(), + ), + }; let profile_merge = ChainItem { uid: "profile-merge".into(), data: ChainType::Merge(mapping( @@ -1553,18 +1487,21 @@ mod tests { nested: {winner: profile-merge}}", )), }; - let profile_script = ChainItem::to_script( - "profile-script", - r#"function main(config) { + let profile_script = ChainItem { + uid: "profile-script".into(), + data: ChainType::Script( + r#"function main(config) { config["profile-script-wins"] = "profile-script"; config.nested.winner = "profile-script"; return config; - }"#, - ); + }"# + .into(), + ), + }; let profile_name = "test-profile".into(); let authoritative = AuthoritativeFields::capture(&config, &[], Mapping::new()); - let (config, exists_keys, result_map) = process_global_items( + let (config, exists_keys, result_map) = process_override_items( config, exists_keys, HashMap::new(), @@ -1574,7 +1511,7 @@ mod tests { &authoritative, ) .await; - let (config, exists_keys, _) = process_profile_items( + let (config, exists_keys, _) = process_override_items( config, exists_keys, result_map, diff --git a/src-tauri/src/enhance/script.rs b/src-tauri/src/enhance/script.rs index 2aaee1121..b69ee7494 100644 --- a/src-tauri/src/enhance/script.rs +++ b/src-tauri/src/enhance/script.rs @@ -1,6 +1,6 @@ use crate::{process::AsyncHandler, utils::tmpl}; -use super::field::{use_lowercase, use_lowercase_owned}; +use super::field::use_lowercase; use anyhow::{Error, Result}; use boa_engine::{Context, JsString, JsValue, Source, js_string, native_function::NativeFunction, property::Attribute}; use clash_verge_logging::{Type, logging}; @@ -22,7 +22,7 @@ type EvalOutcome = (Result, Vec<(String, String)>) pub(super) async fn use_script(script: String, config: Mapping, name: String) -> ScriptResult { // The template is identity-plus-lowercasing; skip the Boa round-trip. if script == tmpl::ITEM_SCRIPT { - return (use_lowercase_owned(config), vec![], vec![]); + return (use_lowercase(&config), vec![], vec![]); } let json = match serde_json::to_string(&use_lowercase(&config)) { @@ -173,7 +173,7 @@ fn eval_script(script: &str, config_json: &str, name: &String) -> EvalOutcome { } match parse_json_safely(&result) { - Ok(config) => (Ok(use_lowercase_owned(config)), outputs.lock().to_vec()), + Ok(config) => (Ok(use_lowercase(&config)), outputs.lock().to_vec()), Err(err) => { logging!( error, diff --git a/src-tauri/src/enhance/tun.rs b/src-tauri/src/enhance/tun.rs index f5d1af7ab..e56810135 100644 --- a/src-tauri/src/enhance/tun.rs +++ b/src-tauri/src/enhance/tun.rs @@ -28,7 +28,7 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping { .and_then(|v| v.as_str()) .unwrap_or("fake-ip"); - if current_mode == "fake-ip" || !dns_val.contains_key(Value::from("enhanced-mode")) { + if current_mode == "fake-ip" { revise!(dns_val, "enable", true); revise!(dns_val, "ipv6", ipv6_val); diff --git a/src-tauri/src/feat/backup.rs b/src-tauri/src/feat/backup.rs index 7c246d36a..5f51e74d9 100644 --- a/src-tauri/src/feat/backup.rs +++ b/src-tauri/src/feat/backup.rs @@ -33,6 +33,9 @@ async fn finalize_restored_verge_config( webdav_username: Option, webdav_password: Option, ) -> Result<()> { + // The lock must cover every restored layer; an unlocked apply races a concurrently staged draft. + let config_write = Config::lock_config_write().await; + // A broken restored `verge.yaml` is a restore failure, not a reason to load defaults. let mut restored = help::read_yaml::(&verge_path()?).await?; restored.webdav_url = webdav_url; @@ -43,43 +46,42 @@ async fn finalize_restored_verge_config( let restored_clash = IClashTemp::new().await; let clash_draft = Config::clash().await; clash_draft.edit_draft(|d| { - *d = restored_clash.clone(); + *d = restored_clash; }); clash_draft.apply(); let restored_profiles = IProfiles::new().await; let profiles_draft = Config::profiles().await; profiles_draft.edit_draft(|d| { - *d = restored_profiles.clone(); + *d = restored_profiles; }); profiles_draft.apply(); let verge_draft = Config::verge().await; - { - // Hold the write lock so a concurrent patch cannot stage a draft the proxy write reads. - let _config_write = Config::lock_config_write().await; - verge_draft.edit_draft(|d| { - *d = restored.clone(); - }); - verge_draft.apply(); + verge_draft.edit_draft(|d| { + *d = restored.clone(); + }); + verge_draft.apply(); - // Turn it off here; a failing core side effect below would otherwise skip this step. - if !restored.enable_system_proxy.unwrap_or_default() { - let result = async { - let _lifecycle = CoreManager::global().lifecycle_lock.lock().await; - proxy_control::apply().await?; - proxy_control::refresh_guard().await - } - .await; - if let Err(err) = result { - logging!(error, Type::Backup, "Failed to turn the system proxy off: {err:#}"); - if SystemProxyStateUnknown::is_in(&err) { - return Err(err); - } + // Turn it off here; a failing core side effect below would otherwise skip this step. + if !restored.enable_system_proxy.unwrap_or_default() { + let result = async { + let _lifecycle = CoreManager::global().lifecycle_lock.lock().await; + proxy_control::apply().await?; + proxy_control::refresh_guard().await + } + .await; + if let Err(err) = result { + logging!(error, Type::Backup, "Failed to turn the system proxy off: {err:#}"); + if SystemProxyStateUnknown::is_in(&err) { + return Err(err); } } } + // `patch_verge` re-acquires the configuration write lock. + drop(config_write); + // Run configuration side effects without rewriting the already-restored file. if let Err(err) = super::patch_verge(&restored, true).await { logging!(error, Type::Backup, "Failed to apply restored verge config: {err:#}"); @@ -134,14 +136,35 @@ pub async fn delete_webdav_backup(filename: String) -> Result<()> { }) } +/// Backup creation strips the WebDAV credentials, so the live ones are kept. +async fn restore_backup_archive(path: PathBuf) -> Result<()> { + 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 home_dir = app_home_dir()?; + AsyncHandler::spawn_blocking(move || -> Result<()> { + let file = std::fs::File::open(&path)?; + let mut zip = zip::ZipArchive::new(file)?; + zip.extract(&home_dir)?; + Ok(()) + }) + .await + .map_err(|join| anyhow!("backup extraction task failed: {join}"))??; + + // Held for the config-data transition only; extraction is plain filesystem work. + let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await; + finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await +} + #[tracing::instrument(skip_all, level = "info", fields(filename = %filename))] pub async fn restore_webdav_backup(filename: String) -> Result<()> { - let verge = Config::verge().await; - 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.as_str()); @@ -158,12 +181,7 @@ pub async fn restore_webdav_backup(filename: String) -> Result<()> { err })?; - let value = backup_storage_path.clone(); - let file = AsyncHandler::spawn_blocking(move || std::fs::File::open(&value)).await??; - let mut zip = zip::ZipArchive::new(file)?; - let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await; - zip.extract(app_home_dir()?)?; - let res = finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await; + let res = restore_backup_archive(backup_storage_path.clone()).await; let _ = backup_storage_path.remove_if_exists().await; res } @@ -332,22 +350,7 @@ pub async fn restore_local_backup(filename: String) -> Result<()> { 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)?; - let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await; - zip.extract(app_home_dir()?)?; - finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await?; - Ok(()) + restore_backup_archive(target_path).await } #[tracing::instrument(skip_all, level = "info", fields(filename = %filename, destination = %destination))] diff --git a/src-tauri/src/feat/clash.rs b/src-tauri/src/feat/clash.rs index 0f90dffc4..853b35481 100644 --- a/src-tauri/src/feat/clash.rs +++ b/src-tauri/src/feat/clash.rs @@ -3,7 +3,7 @@ use crate::core::notify::{Refresh, announce}; use crate::{ config::{Config, MixedPort}, core::{CoreManager, handle, tray}, - feat::clean_async, + feat::run_exit_cleanup_flow, process::AsyncHandler, utils, }; @@ -14,15 +14,8 @@ use smartstring::alias::String; use std::sync::{Arc, LazyLock}; #[allow(clippy::expect_used)] -static TLS_CONFIG: LazyLock> = LazyLock::new(|| { - let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) - .with_safe_default_protocol_versions() - .expect("Failed to set TLS versions") - .with_root_certificates(root_store) - .with_no_client_auth(); - Arc::new(config) -}); +static TLS_CONFIG: LazyLock> = + LazyLock::new(|| Arc::new(utils::network::build_webpki_rooted_tls_config().expect("Failed to set TLS versions"))); pub async fn restart_clash_core() { match CoreManager::global().restart_core().await { @@ -38,38 +31,9 @@ pub async fn restart_clash_core() { pub async fn restart_app() { logging!(debug, Type::System, "启动重启应用流程"); - handle::Handle::global().set_is_exiting(); - - Config::apply_all_and_save_file().await; - - let cleanup_result = clean_async().await; - - logging!( - info, - Type::System, - "资源清理完成,退出代码: {}", - if cleanup_result.all_success { 0 } else { 1 } - ); + let cleanup_result = run_exit_cleanup_flow(NoticeStatus::AppRestartCoreStopFailed).await; if !cleanup_result.core_stopped { - handle::Handle::global().clear_is_exiting(); - // A failed stop may still have marked the core stopped, and exit cleanup skipped the DNS restore. - // A core that kept running keeps its DNS, whatever an unapplied config draft says. - #[cfg(target_os = "macos")] - { - let manager = CoreManager::global(); - let _lifecycle = manager.lifecycle_lock.lock().await; - if matches!( - *manager.get_running_mode(), - crate::core::manager::RunningMode::NotRunning - ) { - crate::utils::resolve::dns::sync_public_dns().await; - } - } - handle::Handle::notice( - NoticeStatus::AppRestartCoreStopFailed, - cleanup_result.stop_error.unwrap_or_default(), - ); return; } @@ -103,11 +67,14 @@ pub async fn change_clash_mode(mode: String) -> Result<(), String> { } let clash = Config::clash().await; - clash.edit_draft(|d| d.patch_config(&mapping)); - clash.apply(); - - let clash_data = clash.data_arc(); - if clash_data.save_config().await.is_ok() { + // Serialize with patch transactions: an unlocked commit can flush a staged draft mid-rollback. + let save_result = { + let _config_write = Config::lock_config_write().await; + clash.edit_draft(|d| d.patch_config(&mapping)); + clash.apply(); + clash.data_arc().save_config().await + }; + if save_result.is_ok() { announce(Refresh::Clash); tray::Tray::global().update_menu_and_icon().await; } diff --git a/src-tauri/src/feat/config.rs b/src-tauri/src/feat/config.rs index 2a1fdbe6c..da19781bd 100644 --- a/src-tauri/src/feat/config.rs +++ b/src-tauri/src/feat/config.rs @@ -27,7 +27,7 @@ pub async fn patch_verge(patch: &IVerge, not_save_file: bool) -> Result<()> { /// Apply a patch without post-update reconciliation. pub(super) async fn apply_verge_patch(patch: &IVerge, not_save_file: bool) -> Result<()> { - let config_write = Config::try_lock_config_write()?; + let config_write = Config::lock_config_write().await; apply_verge_patch_locked(&config_write, patch, not_save_file).await } diff --git a/src-tauri/src/feat/profile.rs b/src-tauri/src/feat/profile.rs index 56faeb01d..a74e7dd39 100644 --- a/src-tauri/src/feat/profile.rs +++ b/src-tauri/src/feat/profile.rs @@ -47,26 +47,11 @@ pub async fn switch_proxy_node(group_name: &str, proxy_name: &str) { record_switched_node(group_name, proxy_name).await; announce(Refresh::Proxies); let _ = tray::Tray::global().update_menu().await; - return; } Err(err) => { logging!(error, Type::Tray, "切换代理失败: {err:?}"); } } - - match handle::Handle::mihomo() - .select_node_for_group(group_name, proxy_name) - .await - { - Ok(_) => { - logging!(info, Type::Tray, "代理切换回退成功: {} -> {}", group_name, proxy_name); - record_switched_node(group_name, proxy_name).await; - let _ = tray::Tray::global().update_menu().await; - } - Err(err) => { - logging!(error, Type::Tray, "代理切换最终失败: {err:?}"); - } - } } async fn should_update_profile(uid: &String, ignore_auto_update: bool) -> Result)>> { diff --git a/src-tauri/src/feat/window.rs b/src-tauri/src/feat/window.rs index e618701ed..092d7267e 100644 --- a/src-tauri/src/feat/window.rs +++ b/src-tauri/src/feat/window.rs @@ -130,9 +130,7 @@ pub async fn open_or_close_dashboard() { logging!(info, Type::Window, "Window toggle result: {result:?}"); } -pub async fn quit() { - logging!(debug, Type::System, "启动退出流程"); - // 设置退出标志 +pub(crate) async fn run_exit_cleanup_flow(stop_failed_notice: NoticeStatus) -> CleanupResult { handle::Handle::global().set_is_exiting(); Config::apply_all_and_save_file().await; @@ -162,11 +160,18 @@ pub async fn quit() { } } handle::Handle::notice( - NoticeStatus::AppQuitCoreStopFailed, - cleanup_result.stop_error.unwrap_or_default(), + stop_failed_notice, + cleanup_result.stop_error.clone().unwrap_or_default(), ); } + cleanup_result +} + +pub async fn quit() { + logging!(debug, Type::System, "启动退出流程"); + let cleanup_result = run_exit_cleanup_flow(NoticeStatus::AppQuitCoreStopFailed).await; + utils::server::shutdown_embedded_server(); let app_handle = handle::Handle::app_handle(); app_handle.exit(if cleanup_result.all_success { 0 } else { 1 }); @@ -316,7 +321,6 @@ pub async fn hide() { { let _ = window.hide(); } - handle::Handle::global().set_activation_policy_accessory(); } #[cfg(test)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7e11e5c02..24d3f5203 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -289,9 +289,6 @@ pub fn run() -> std::process::ExitCode { } pub fn handle_window_close(api: &tauri::WindowEvent) { - #[cfg(target_os = "macos")] - handle::Handle::global().set_activation_policy_accessory(); - if core::handle::Handle::global().is_exiting() { return; } diff --git a/src-tauri/src/utils/init.rs b/src-tauri/src/utils/init.rs index be7f016a1..927fc797f 100644 --- a/src-tauri/src/utils/init.rs +++ b/src-tauri/src/utils/init.rs @@ -354,7 +354,6 @@ pub(super) async fn init_dns_config() -> Result<()> { Value::String("*.arpa".into()), Value::String("time.*.com".into()), Value::String("ntp.*.com".into()), - Value::String("time.*.com".into()), Value::String("+.market.xiaomi.com".into()), Value::String("localhost.ptlogin2.qq.com".into()), Value::String("*.msftncsi.com".into()), @@ -509,11 +508,9 @@ pub async fn init_resources() -> Result<()> { let app_dir = dirs::app_home_dir()?; let res_dir = dirs::app_resources_dir()?; - if !app_dir.exists() { - std::mem::drop(fs::create_dir_all(&app_dir).await); - } + // app_home is created by `ensure_directories` in `init_config`, which always runs first. if !res_dir.exists() { - std::mem::drop(fs::create_dir_all(&res_dir).await); + fs::create_dir_all(&res_dir).await?; } let file_list = ["Country.mmdb", "ASN.mmdb", "geoip.dat", "geosite.dat"]; diff --git a/src-tauri/src/utils/network.rs b/src-tauri/src/utils/network.rs index d325fecbd..89d2ddc6f 100644 --- a/src-tauri/src/utils/network.rs +++ b/src-tauri/src/utils/network.rs @@ -48,6 +48,17 @@ enum TlsRootMode { StaticWebpkiRoots, } +/// ALPN stays unset: raw TLS connectors offer none, while reqwest needs the h2 offer. +pub(crate) fn build_webpki_rooted_tls_config() -> Result { + let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions()? + .with_root_certificates(root_store) + .with_no_client_auth(); + + Ok(config) +} + pub struct NetworkManager; impl NetworkManager { @@ -102,13 +113,7 @@ impl NetworkManager { } fn build_static_webpki_tls_config() -> Result { - let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - let mut config = - rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) - .with_safe_default_protocol_versions()? - .with_root_certificates(root_store) - .with_no_client_auth(); - + let mut config = build_webpki_rooted_tls_config()?; config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; Ok(config) @@ -155,6 +160,23 @@ impl NetworkManager { detail.contains("protocolversion") || detail.contains("protocol version") } + /// `allow_fallback` stays false when the caller already disabled certificate verification. + async fn request_with_static_webpki_fallback(allow_fallback: bool, mut request: F) -> Result + where + F: FnMut(TlsRootMode) -> Fut, + Fut: std::future::Future>, + { + match request(TlsRootMode::PlatformVerifier).await { + Ok(value) => Ok(value), + Err(err) if allow_fallback && Self::should_retry_with_static_webpki_roots(&err) => { + request(TlsRootMode::StaticWebpkiRoots).await.map_err(|fallback_err| { + fallback_err.context("static webpki roots fallback failed after platform TLS verifier failed") + }) + } + Err(err) => Err(err), + } + } + async fn get_with_tls_mode( &self, url: &str, @@ -264,34 +286,21 @@ impl NetworkManager { user_agent: Option, accept_invalid_certs: bool, ) -> Result { - let platform_result = self - .get_with_tls_mode( - url, - proxy_type, - timeout_secs, - user_agent.clone(), - accept_invalid_certs, - TlsRootMode::PlatformVerifier, - ) - .await; - - match platform_result { - Ok(response) => Ok(response), - Err(err) if !accept_invalid_certs && Self::should_retry_with_static_webpki_roots(&err) => self - .get_with_tls_mode( + Self::request_with_static_webpki_fallback(!accept_invalid_certs, |tls_root_mode| { + let user_agent = user_agent.clone(); + async move { + self.get_with_tls_mode( url, proxy_type, timeout_secs, user_agent, accept_invalid_certs, - TlsRootMode::StaticWebpkiRoots, + tls_root_mode, ) .await - .map_err(|fallback_err| { - fallback_err.context("static webpki roots fallback failed after platform TLS verifier failed") - }), - Err(err) => Err(err), - } + } + }) + .await } async fn get_bytes_with_tls_mode( @@ -340,19 +349,10 @@ impl NetworkManager { timeout_secs: Option, max_bytes: usize, ) -> Result> { - let platform_result = self - .get_bytes_with_tls_mode(url, proxy_type, timeout_secs, max_bytes, TlsRootMode::PlatformVerifier) - .await; - - match platform_result { - Ok(body) => Ok(body), - Err(err) if Self::should_retry_with_static_webpki_roots(&err) => self - .get_bytes_with_tls_mode(url, proxy_type, timeout_secs, max_bytes, TlsRootMode::StaticWebpkiRoots) + Self::request_with_static_webpki_fallback(true, |tls_root_mode| async move { + self.get_bytes_with_tls_mode(url, proxy_type, timeout_secs, max_bytes, tls_root_mode) .await - .map_err(|fallback_err| { - fallback_err.context("static webpki roots fallback failed after platform TLS verifier failed") - }), - Err(err) => Err(err), - } + }) + .await } } diff --git a/src-tauri/src/utils/resolve/mod.rs b/src-tauri/src/utils/resolve/mod.rs index 7ba959ae3..fa408f2af 100644 --- a/src-tauri/src/utils/resolve/mod.rs +++ b/src-tauri/src/utils/resolve/mod.rs @@ -1,8 +1,6 @@ use crate::core::notify::NoticeStatus; use std::sync::atomic::{AtomicBool, Ordering}; -use anyhow::Result; - use crate::{ config::Config, core::{ @@ -97,18 +95,6 @@ async fn resolve_setup() { resolve_done(); } -pub async fn resolve_reset_async() -> Result<(), anyhow::Error> { - CoreManager::global().stop_core().await?; - - #[cfg(target_os = "macos")] - { - use dns::restore_public_dns; - restore_public_dns().await?; - } - - Ok(()) -} - fn init_scheme() { logging_error!(Type::Setup, init::init_scheme()); } diff --git a/src/components/setting/mods/backup-config-viewer.tsx b/src/components/setting/mods/backup-config-viewer.tsx index 9e6c028e5..a51705733 100644 --- a/src/components/setting/mods/backup-config-viewer.tsx +++ b/src/components/setting/mods/backup-config-viewer.tsx @@ -40,9 +40,9 @@ export const BackupConfigViewer = memo( const { register, handleSubmit, watch } = useForm({ defaultValues: { - url: webdav_url!, - username: webdav_username!, - password: webdav_password!, + url: webdav_url ?? '', + username: webdav_username ?? '', + password: webdav_password ?? '', }, }) const url = watch('url') @@ -50,9 +50,9 @@ export const BackupConfigViewer = memo( const password = watch('password') const webdavChanged = - webdav_url !== url || - webdav_username !== username || - webdav_password !== password + (webdav_url ?? '') !== url || + (webdav_username ?? '') !== username || + (webdav_password ?? '') !== password const webdavSignature = buildWebdavSignature(verge) const webdavStatus = getWebdavStatus(webdavSignature) @@ -69,9 +69,9 @@ export const BackupConfigViewer = memo( }, [webdav_url, webdav_username, webdav_password, onInit, shouldAutoInit]) const checkForm = () => { - const username = usernameRef.current?.value + const username = usernameRef.current?.value.trim() const password = passwordRef.current?.value - const url = urlRef.current?.value + const url = urlRef.current?.value.trim() if (!url) { urlRef.current?.focus() @@ -95,14 +95,15 @@ export const BackupConfigViewer = memo( } const save = useLockFn(async (data: IWebDavConfig) => { - checkForm() + const trimmedUrl = data.url.trim() + const trimmedUsername = data.username.trim() + const isClearing = !trimmedUrl && !trimmedUsername && !data.password + if (!isClearing) checkForm() const signature = buildWebdavSignature({ webdav_url: data.url, webdav_username: data.username, webdav_password: data.password, }) - const trimmedUrl = data.url.trim() - const trimmedUsername = data.username.trim() try { setLoading(true) @@ -125,7 +126,7 @@ export const BackupConfigViewer = memo( ) setWebdavStatus(signature, 'unknown') showNotice.success('settings.modals.backup.messages.webdavConfigSaved') - await onSaveSuccess(signature) + if (!isClearing) await onSaveSuccess(signature) } catch (error) { showNotice.error( 'settings.modals.backup.messages.webdavConfigSaveFailed', @@ -221,9 +222,9 @@ export const BackupConfigViewer = memo( }} > {webdavChanged || - webdav_url === undefined || - webdav_username === undefined || - webdav_password === undefined ? ( + !webdav_url || + !webdav_username || + !webdav_password ? (