diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2b4e1d..14ef965 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,11 @@ on: push: branches: - main + - dev pull_request: branches: - main + - dev types: - opened - synchronize diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..04eea67 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,438 @@ +name: Nightly + +# 滚动每夜构建:main 合并后自动出各平台安装包,供提前测试。 +# 设计决策见 docs/adr/0006-nightly-rolling-prerelease.md。 +on: + # main 合并:等 CI 成功后再构建(不重复跑质量门禁),仅当触发 CI 的是 push 到 main 才继续 + workflow_run: + workflows: ["CI"] + types: [completed] + branches: [main] + # 手动触发:不走 CI 依赖链,只做轻量前置(actionlint + gitleaks) + workflow_dispatch: + +# 顶层默认无权限;各 job 按需声明最小权限 +permissions: {} + +# 滚动 tag 是单一共享目标;串行执行避免发布阶段被新一轮运行取消。 +concurrency: + group: nightly + cancel-in-progress: false + +jobs: + prepare: + # workflow_run 事件时,仅当触发 CI 的是 push 到 main 且 CI 为 success 才继续; + # 手动触发时无 CI 上下文,直接继续。 + if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push') }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + checks: write + outputs: + commit_sha: ${{ steps.commit.outputs.sha }} + nightly_version: ${{ steps.version.outputs.version }} + steps: + - name: 提取本次构建对应的 commit + id: commit + shell: bash + env: + CI_SHA: ${{ github.event.workflow_run.head_sha }} + DISPATCH_SHA: ${{ github.sha }} + run: | + if [ "${{ github.event_name }}" = "workflow_run" ]; then + echo "sha=$CI_SHA" >> "$GITHUB_OUTPUT" + else + echo "sha=$DISPATCH_SHA" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.commit.outputs.sha }} + fetch-depth: 0 + + # 手动触发才走轻量前置;CI 路径下 push/PR 已在 ci.yml 里执行相同检查。 + - name: actionlint workflow 校验(仅手动触发) + if: ${{ github.event_name == 'workflow_dispatch' }} + uses: reviewdog/action-actionlint@d63ba7532e0942965320cd8d73cbae4c7b3c5283 # v1.73.1 + with: + reporter: github-check + fail_level: error + + - name: Gitleaks 密钥扫描(仅手动触发) + if: ${{ github.event_name == 'workflow_dispatch' }} + uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: 计算每夜构建版本 + id: version + shell: bash + env: + GITHUB_SHA: ${{ steps.commit.outputs.sha }} + run: | + set -euo pipefail + version=$(node scripts/nightly-version.mjs --print) + echo "version=$version" >> "$GITHUB_OUTPUT" + + build-universal: + needs: prepare + runs-on: macos-26 + timeout-minutes: 60 + permissions: + contents: read + env: + NIGHTLY_VERSION: ${{ needs.prepare.outputs.nightly_version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.prepare.outputs.commit_sha }} + fetch-depth: 0 + + - name: 安装 pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + + - name: 安装 Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: pnpm + + - name: 安装 Rust stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: stable + targets: aarch64-apple-darwin,x86_64-apple-darwin + + - name: Rust 缓存 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: ./src-tauri -> target + + - name: 安装前端依赖 + run: pnpm install --frozen-lockfile + + - name: 注入每夜构建版本 + env: + GITHUB_SHA: ${{ needs.prepare.outputs.commit_sha }} + run: node scripts/nightly-version.mjs --write-config src-tauri/tauri.nightly.generated.json + + - name: 构建 macOS DMG + run: pnpm tauri build --config src-tauri/tauri.nightly.generated.json --target universal-apple-darwin --bundles dmg + + - name: 产物断言 - DMG + id: artifact + shell: bash + run: | + set -euo pipefail + dmg=$(find src-tauri/target -type f -name "code-manager_${NIGHTLY_VERSION}_universal.dmg" 2>/dev/null | head -1) + test -n "$dmg" + test -s "$dmg" + echo "path=$dmg" >> "$GITHUB_OUTPUT" + echo "DMG 存在且非空: $dmg" + + - name: 无头启动冒烟 - macOS + shell: bash + run: | + set -euo pipefail + app=$(find src-tauri/target -type d -name "code-manager.app" 2>/dev/null | head -1) + test -n "$app" + bin=$(find "$app/Contents/MacOS" -maxdepth 1 -type f -name 'code-manager*' 2>/dev/null | head -1) + test -n "$bin" + test -x "$bin" + # GitHub macOS runner 是真实 macOS 会话,可直接启动二进制;启动后断言静置若干秒仍存活。 + "$bin" >/tmp/nightly-macos.log 2>&1 & pid=$! + sleep 12 + if ! kill -0 "$pid" 2>/dev/null; then + echo "应用未存活 12 秒,冒烟失败:" + cat /tmp/nightly-macos.log 2>/dev/null || true + exit 1 + fi + kill "$pid" 2>/dev/null || true + echo "应用无头启动冒烟通过" + + - name: 暂存 macOS 产物 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: nightly-macos + path: ${{ steps.artifact.outputs.path }} + if-no-files-found: error + retention-days: 3 + + build-linux: + needs: prepare + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: + contents: read + env: + NIGHTLY_VERSION: ${{ needs.prepare.outputs.nightly_version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.prepare.outputs.commit_sha }} + fetch-depth: 0 + + - name: 安装 pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + + - name: 安装 Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: pnpm + + - name: 安装 Rust stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: stable + + - name: Rust 缓存 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: ./src-tauri -> target + + - name: 安装 Linux 系统依赖 + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xvfb + + - name: 安装前端依赖 + run: pnpm install --frozen-lockfile + + - name: 注入每夜构建版本 + env: + GITHUB_SHA: ${{ needs.prepare.outputs.commit_sha }} + run: node scripts/nightly-version.mjs --write-config src-tauri/tauri.nightly.generated.json + + - name: 构建 Linux 包 + run: pnpm tauri build --config src-tauri/tauri.nightly.generated.json --bundles deb,appimage + + - name: 产物断言 - deb/appimage + id: artifact + shell: bash + run: | + set -euo pipefail + deb=$(find src-tauri/target -type f -name "code-manager_${NIGHTLY_VERSION}_*.deb" 2>/dev/null | head -1) + appimg=$(find src-tauri/target -type f -name "code-manager_${NIGHTLY_VERSION}_*.AppImage" 2>/dev/null | head -1) + test -n "$deb" + test -n "$appimg" + test -s "$deb" + test -s "$appimg" + echo "deb=$deb" >> "$GITHUB_OUTPUT" + echo "appimage=$appimg" >> "$GITHUB_OUTPUT" + echo "存在且非空: $deb" + echo "存在且非空: $appimg" + + - name: 无头启动冒烟 - Linux + shell: bash + run: | + set -euo pipefail + appimage=$(find src-tauri/target -type f -name "code-manager_${NIGHTLY_VERSION}_*.AppImage" 2>/dev/null | head -1) + test -f "$appimage" + # AppImage 在部分 runner 上缺 FUSE,用解包运行代替挂载 + export APPIMAGE_EXTRACT_AND_RUN=1 + xvfb-run -a "$appimage" >/tmp/nightly-linux.log 2>&1 & pid=$! + sleep 12 + if ! kill -0 "$pid" 2>/dev/null; then + echo "AppImage 未存活 12 秒,冒烟失败:" + cat /tmp/nightly-linux.log 2>/dev/null || true + exit 1 + fi + kill "$pid" 2>/dev/null || true + echo "AppImage 无头启动冒烟通过" + + - name: 暂存 Linux 产物 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: nightly-linux + path: | + ${{ steps.artifact.outputs.deb }} + ${{ steps.artifact.outputs.appimage }} + if-no-files-found: error + retention-days: 3 + + build-windows: + needs: prepare + runs-on: windows-2025 + timeout-minutes: 60 + permissions: + contents: read + env: + NIGHTLY_VERSION: ${{ needs.prepare.outputs.nightly_version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.prepare.outputs.commit_sha }} + fetch-depth: 0 + + - name: 安装 pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + + - name: 安装 Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: pnpm + + - name: 安装 Rust stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: stable + + - name: 安装前端依赖 + run: pnpm install --frozen-lockfile + + - name: 注入每夜构建版本 + env: + GITHUB_SHA: ${{ needs.prepare.outputs.commit_sha }} + run: node scripts/nightly-version.mjs --write-config src-tauri/tauri.nightly.generated.json + + - name: 构建 Windows NSIS + run: pnpm tauri build --config src-tauri/tauri.nightly.generated.json --bundles nsis + + - name: 产物断言 - NSIS + id: artifact + shell: bash + run: | + set -euo pipefail + setup=$(find src-tauri/target -type f -name "code-manager_${NIGHTLY_VERSION}_x64-setup.exe" 2>/dev/null | head -1) + if [ -z "$setup" ]; then + echo "未找到 NSIS 安装包: ${NIGHTLY_VERSION}" + exit 1 + fi + test -s "$setup" + echo "path=$setup" >> "$GITHUB_OUTPUT" + echo "存在且非空: $setup" + + - name: 无头启动冒烟 - Windows(静默安装) + shell: pwsh + run: | + $setup = Get-ChildItem -Path "src-tauri/target" -Recurse -Filter "code-manager_${env:NIGHTLY_VERSION}_x64-setup.exe" | Select-Object -First 1 + if (-not $setup) { + Write-Error "未找到 NSIS 安装包: ${env:NIGHTLY_VERSION}" + exit 1 + } + Write-Host "静默安装: $($setup.Name)" + # NSIS 静默安装;安装路径按 productName 约定 + Start-Process -FilePath $setup.FullName -ArgumentList '/S' -Wait -NoNewWindow + $candidates = @( + "$env:LOCALAPPDATA\Programs\code-manager\code-manager.exe", + "$env:LOCALAPPDATA\code-manager\code-manager.exe" + ) + $exe = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $exe) { + Write-Error "未找到安装后的可执行文件" + exit 1 + } + Write-Host "已安装: $exe" + $process = $null + try { + $process = Start-Process -FilePath $exe -PassThru + Start-Sleep -Seconds 12 + if ($process.HasExited) { + Write-Error "应用启动后提前退出,exit code: $($process.ExitCode)" + exit 1 + } + Write-Host "应用启动并存活 12 秒,冒烟通过" + } + finally { + if ($process -and -not $process.HasExited) { + Stop-Process -Id $process.Id -Force + $process.WaitForExit() + } + } + + - name: 暂存 Windows 产物 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: nightly-windows + path: ${{ steps.artifact.outputs.path }} + if-no-files-found: error + retention-days: 3 + + publish: + needs: [prepare, build-universal, build-linux, build-windows] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + env: + COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} + NIGHTLY_VERSION: ${{ needs.prepare.outputs.nightly_version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.prepare.outputs.commit_sha }} + fetch-depth: 0 + + - name: 跳过已过期的 main 构建 + id: freshness + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + publish=true + if [ "$EVENT_NAME" = "workflow_run" ]; then + latest_main=$(git ls-remote origin refs/heads/main | cut -f1) + if [ "$latest_main" != "$COMMIT_SHA" ]; then + echo "当前构建 $COMMIT_SHA 已落后于 main $latest_main,跳过发布" + publish=false + fi + fi + echo "publish=$publish" >> "$GITHUB_OUTPUT" + + - name: 下载全部平台产物 + if: ${{ steps.freshness.outputs.publish == 'true' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: nightly-* + merge-multiple: true + path: dist + + - name: 发布前核验完整产物集 + if: ${{ steps.freshness.outputs.publish == 'true' }} + shell: bash + run: | + set -euo pipefail + assert_one() { + local pattern=$1 + local count + count=$(find dist -maxdepth 1 -type f -name "$pattern" | wc -l | tr -d ' ') + if [ "$count" -ne 1 ]; then + echo "产物数量错误: $pattern, expected=1, actual=$count" + exit 1 + fi + } + + total=$(find dist -maxdepth 1 -type f | wc -l | tr -d ' ') + if [ "$total" -ne 4 ]; then + echo "产物总数错误: expected=4, actual=$total" + find dist -maxdepth 1 -type f -print + exit 1 + fi + + assert_one "code-manager_${NIGHTLY_VERSION}_universal.dmg" + assert_one "code-manager_${NIGHTLY_VERSION}_*.deb" + assert_one "code-manager_${NIGHTLY_VERSION}_*.AppImage" + assert_one "code-manager_${NIGHTLY_VERSION}_x64-setup.exe" + + - name: 发布滚动 Nightly Release + if: ${{ steps.freshness.outputs.publish == 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # 所有平台构建与冒烟成功后才替换旧 release;新 release 在产物齐全前保持 draft。 + gh release delete nightly --yes --cleanup-tag 2>/dev/null || true + git push origin --delete nightly 2>/dev/null || true + gh release create nightly dist/* \ + --draft \ + --prerelease \ + --latest=false \ + --target "$COMMIT_SHA" \ + --title "Code Manager 每夜构建 $NIGHTLY_VERSION" \ + --notes "滚动构建,每一次 main 合并会覆盖本 Release。版本号含 commit 短 sha,报 bug 请附上(如 $NIGHTLY_VERSION)。" + gh release edit nightly --draft=false --prerelease --latest=false diff --git a/CONTEXT.md b/CONTEXT.md index 23fcccc..e5728f2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -64,6 +64,23 @@ _Avoid_: 运行中会话(running session,只是其中一个具体状态) 把承载 Claude Code 会话的终端视图带到前台的动作:宿主终端窗口/tab 激活,以及多路复用器(如 herdr)内部的 pane 选中。聚焦的载体随会话所在环境不同而不同(终端 tab、herdr pane、Ghostty term),但语义不变:用户眼睛看到承载该会话的视图并可直接交互。 _Avoid_: 聚焦终端 tab(herdr 场景没有 tab 概念)、激活窗口(只覆盖一半语义) +### 发布通道(Release Channel) + +#### 每夜构建(Nightly) + +持续提供主线最新可测试版本的**滚动发布通道**,用于正式发布前体验新功能和反馈问题。新构建替代旧构建,不承诺稳定性或历史版本保留。 +_Avoid_: 预发布、nightly build(裸指构建时)、canary(语义不同,当前无此通道) + +#### 预发布(Prerelease) + +表示“比 stable 早”的发布标记,不定义具体通道的更新、保留或质量策略。它可用于标记每夜构建,但两者不是同义词。 +_Avoid_: 把它们当同义词(预发布是标记,每夜构建是通道) + +#### 候选版本(Release Candidate) + +正式发版前的验收版本,与[每夜构建](#每夜构建nightly)同为"发布前通道",但由发布流程人工/半自动产生、目标指向即将发布的版本。当前**无此通道**;术语先行定义以便后续引入时口径一致。 +_Avoid_: 预发布、beta、RC(裸缩写,除非重申语义) + ### 目录总览(Directory Overview) **目录总览(Directory Overview)**: diff --git a/README.md b/README.md index 3a11caa..7ed3cf8 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,12 @@ The current macOS release packages are not notarized by Apple. Homebrew installs xattr -rd com.apple.quarantine /Applications/code-manager.app ``` +### Nightly Builds + +Want to try the latest features before they are released? Nightly installers for macOS (`dmg`), Linux (`deb` / `AppImage`), and Windows (`setup.exe`) are built automatically on every `main` merge and published as a rolling prerelease at [releases/download/nightly](https://github.com/maguowei/code-manager/releases/download/nightly). The version includes the commit short SHA (e.g. `1.6.0-nightly.ga1b2c3d`) so you can tell exactly which build you have. + +Nightly builds are **not signed or notarized**: macOS will block the first launch (right-click → Open, or `xattr -rd com.apple.quarantine /Applications/code-manager.app`), and Windows may show a SmartScreen warning. Nightly builds roll forward on each merge (older ones are overwritten) and do **not** self-update — install a stable release to get automatic updates. + ### Automatic Updates The app has built-in automatic updates: it silently checks for new versions on startup, and once one is found you can download and install it with one click in "Settings - App Update", after which it restarts automatically. Users who installed via Homebrew can also keep upgrading with `brew upgrade`; both paths work, and after an in-app update the version Homebrew records will automatically align on the next `brew upgrade`. diff --git a/README.zh-CN.md b/README.zh-CN.md index 8341b3e..f02e102 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -63,6 +63,12 @@ macOS 当前发布包未经过 Apple 公证。Homebrew 安装会自动移除隔 xattr -rd com.apple.quarantine /Applications/code-manager.app ``` +### 每夜构建(Nightly) + +想提前体验尚未发布的新功能?每次 `main` 合并后会自动构建 macOS(`dmg`)、Linux(`deb`/`AppImage`)、Windows(`setup.exe`) 的每夜构建安装包,以滚动预发布形式发布在 [releases/download/nightly](https://github.com/maguowei/code-manager/releases/download/nightly)。版本号含 commit 短 sha(如 `1.6.0-nightly.ga1b2c3d`),可精确定位你正在使用哪次构建。 + +每夜构建**未签名/未公证**:macOS 首次打开会被拦截(右键 → 打开,或 `xattr -rd com.apple.quarantine /Applications/code-manager.app`),Windows 可能触发 SmartScreen 提示。每夜构建随每次合并向前滚动(旧构建被覆盖),并且**不参与自更新** —— 需要自动更新请安装正式版。 + ### 自动更新 应用内置自动更新:启动时会静默检查新版本,发现后在「设置 - 应用更新」中可一键下载并安装,安装完成后自动重启。通过 Homebrew 安装的用户也可继续用 `brew upgrade` 升级;两种方式都可用,应用内更新后 Homebrew 记录的版本号会在下次 `brew upgrade` 时自动对齐。 diff --git a/docs/adr/0006-nightly-rolling-prerelease.md b/docs/adr/0006-nightly-rolling-prerelease.md new file mode 100644 index 0000000..f9e4396 --- /dev/null +++ b/docs/adr/0006-nightly-rolling-prerelease.md @@ -0,0 +1,23 @@ +# 每夜构建使用滚动预发布通道、临时构建配置与独立更新策略 + +主分支 `main` 每次合并后在 CI 上自动构建各平台安装包,供用户提前测试尚未发布的功能,同时不影响已安装稳定版的用户自更新。 + +## Context + +正式版由 `release.yml` 在 `v*` tag 时发布,产物发布前已通过质量门禁,但大众只能等正式发版才拿到包含新功能的包。若直接让测试者安装 pre-release 标记的构建,需要解决三个问题:包要能精确定位到某次 `main` 合并(否则报 bug 无从回溯);预发布构建不能污染稳定版的自更新通道;各平台打包器对版本号后缀的容忍度不同(MSI/RPM 拒绝预发布后缀)。 + +## Decision + +- **滚动每夜构建**:固定 `nightly` tag,每次 `main` 合并后用完整的新产物集替换旧 release,`prerelease: true`,下载链接 `releases/download/nightly` 永久稳定,不刷新 tag 历史。 +- **CI 注入版本号**:构建前先断言 `package.json`、`src-tauri/Cargo.toml` 与 `src-tauri/tauri.conf.json` 的正式版本一致,再生成临时 Tauri overlay,把构建版本设为 `<当前semver>-nightly.g<短sha>`(如 `1.6.0-nightly.ga1b2c3d`)。构建通过 `tauri build --config` 合并 overlay,不改写任何正式版本源或 `Cargo.lock`;产物名、About 页、`package_info()` 均可据此定位 commit。 +- **打包器收窄**:因版本号含 `-`,预发布只在 macOS 出 `dmg`、Linux 出 `deb`+`appimage`、Windows 出 `nsis`;`rpm` 与 `msi` 留给正式版。正式版 `release.yml` / `bundle.targets: "all"` 保持不变。 +- **隔离稳定自更新**:稳定版继续查询 `releases/latest` 下的稳定 `latest.json`,GitHub 会排除预发布 release。Nightly overlay 则清空 updater endpoints,运行时再根据 `-nightly.` 版本标识禁用自动与手动检查;任一层失效都不会把 Nightly 引向稳定版安装包。Nightly 仍不注入 `TAURI_SIGNING_PRIVATE_KEY`,也不生成 `.sig` / `latest.json`。Homebrew cask workflow 已有 `prerelease == false` 判断,不会误更新。 +- **上传编排**:不用 `tauri-action`(其"构建+上传"一步、无法插入中间验证步骤)。各平台 job 独立执行 `tauri build` → 产物断言 → 无头启动冒烟 → 上传 workflow artifact;最终 `publish` job 只在全部成功后核验四类产物、删除旧 release、创建 draft 并一次性上传,最后公开为 prerelease。构建失败不会改变当前可下载的 Nightly。 +- **质量门禁**:`push: main` 由 `workflow_run` 等待 `ci.yml` 成功后才构建(不重复跑 verify);`workflow_dispatch` 走独立轻量前置(actionlint + gitleaks)。Nightly workflow 串行执行且不自动取消发布阶段;发布前再次核对 `main`,过期构建直接跳过。 + +## Consequences + +- `main` 合并到包可下载要等 CI 完成,约比正式发布快;无法回溯历史预发布包(滚动语义,旧包被覆盖)。 +- 预发布包不签名/不公证:macOS 触发 Gatekeeper、Windows 触发 SmartScreen,测试者需额外步骤,已写入 README 说明。 +- 每夜构建**不支持**应用内自更新;设置页会明确提示手动安装新 Nightly。后续若要给测试者提供自动更新,需要独立 endpoint、签名产物与通道切换,属另一个决策。 +- 若未来想用 `tauri-action` 统一正式与预发布,需重新评估各平台打包器的版本后缀行为;本决策刻意保留了两条独立流水线。 diff --git a/scripts/nightly-version.mjs b/scripts/nightly-version.mjs new file mode 100644 index 0000000..969b501 --- /dev/null +++ b/scripts/nightly-version.mjs @@ -0,0 +1,111 @@ +// 每夜构建版本注入:校验正式版本源后生成 Tauri 临时 overlay,不改写仓库内的正式版本文件。 +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(import.meta.dirname, ".."); +const STABLE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,40}$/i; + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function readCargoPackageVersion(path) { + const cargoToml = readFileSync(path, "utf8"); + const packageSection = cargoToml.match(/\[package\]([\s\S]*?)(?=\n\[|$)/)?.[1]; + const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1]; + if (!version) { + throw new Error(`无法从 ${path} 读取 [package].version`); + } + return version; +} + +/** 读取并校验三个正式版本源,避免 Nightly 建立在漂移的基础版本上。 */ +export function readCanonicalBaseVersion(rootDir = root) { + const versions = { + "package.json": readJson(resolve(rootDir, "package.json")).version, + "src-tauri/Cargo.toml": readCargoPackageVersion(resolve(rootDir, "src-tauri/Cargo.toml")), + "src-tauri/tauri.conf.json": readJson(resolve(rootDir, "src-tauri/tauri.conf.json")).version, + }; + const uniqueVersions = new Set(Object.values(versions)); + if (uniqueVersions.size !== 1) { + const details = Object.entries(versions) + .map(([path, version]) => `${path}=${String(version)}`) + .join(", "); + throw new Error(`正式版本源不一致: ${details}`); + } + + const [baseVersion] = uniqueVersions; + if (typeof baseVersion !== "string" || !STABLE_VERSION_PATTERN.test(baseVersion)) { + throw new Error(`正式版本必须是稳定 SemVer: ${String(baseVersion)}`); + } + return baseVersion; +} + +/** g 前缀保证纯数字且以 0 开头的短 SHA 仍是合法 SemVer prerelease 标识符。 */ +export function createNightlyVersion(baseVersion, commitSha) { + if (!STABLE_VERSION_PATTERN.test(baseVersion)) { + throw new Error(`正式版本必须是稳定 SemVer: ${baseVersion}`); + } + const normalizedSha = commitSha.trim().toLowerCase(); + if (!COMMIT_SHA_PATTERN.test(normalizedSha)) { + throw new Error("commit SHA 必须是至少 7 位、至多 40 位的十六进制字符串"); + } + return `${baseVersion}-nightly.g${normalizedSha.slice(0, 7)}`; +} + +/** overlay 同时注入版本并清空 updater endpoint,防止 Nightly 访问稳定更新通道。 */ +export function writeNightlyConfig(outputPath, version) { + const config = { + version, + plugins: { + updater: { + endpoints: [], + }, + }, + }; + writeFileSync(outputPath, `${JSON.stringify(config, null, 2)}\n`); +} + +function resolveCommitSha() { + const environmentSha = process.env.GITHUB_SHA?.trim(); + if (environmentSha) return environmentSha; + return execFileSync("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", + }).trim(); +} + +export function runCli(args = process.argv.slice(2)) { + const command = args[0] ?? "--print"; + const baseVersion = readCanonicalBaseVersion(root); + const version = createNightlyVersion(baseVersion, resolveCommitSha()); + + if (process.env.NIGHTLY_VERSION && process.env.NIGHTLY_VERSION !== version) { + throw new Error( + `Nightly 版本不一致: expected=${process.env.NIGHTLY_VERSION}, actual=${version}`, + ); + } + + if (command === "--write-config") { + const outputPath = args[1]; + if (!outputPath) throw new Error("--write-config 需要输出路径"); + writeNightlyConfig(resolve(root, outputPath), version); + } else if (command !== "--print") { + throw new Error(`未知参数: ${command}`); + } + + // stdout 是 workflow 的机器可读契约,只输出纯 SemVer。 + process.stdout.write(`${version}\n`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + runCli(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/nightly-version.test.mjs b/scripts/nightly-version.test.mjs new file mode 100644 index 0000000..ff16f3b --- /dev/null +++ b/scripts/nightly-version.test.mjs @@ -0,0 +1,85 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createNightlyVersion, + readCanonicalBaseVersion, + writeNightlyConfig, +} from "./nightly-version.mjs"; + +const temporaryDirectories = []; + +function makeTemporaryDirectory() { + const directory = mkdtempSync(resolve(tmpdir(), "code-manager-nightly-")); + temporaryDirectories.push(directory); + return directory; +} + +function writeVersionFixture(root, versions) { + mkdirSync(resolve(root, "src-tauri"), { recursive: true }); + writeFileSync( + resolve(root, "package.json"), + `${JSON.stringify({ version: versions.package }, null, 2)}\n`, + ); + writeFileSync( + resolve(root, "src-tauri/Cargo.toml"), + `[package]\nname = "code-manager"\nversion = "${versions.cargo}"\n`, + ); + writeFileSync( + resolve(root, "src-tauri/tauri.conf.json"), + `${JSON.stringify({ version: versions.tauri }, null, 2)}\n`, + ); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("nightly version", () => { + it("为数字开头的短 SHA 添加 g 前缀,保持合法 SemVer", () => { + expect(createNightlyVersion("1.6.0", "0123456789abcdef")).toBe( + "1.6.0-nightly.g0123456", + ); + }); + + it("拒绝不稳定基础版本和非法 commit SHA", () => { + expect(() => createNightlyVersion("1.6.0-beta.1", "a123456")).toThrow( + "正式版本必须是稳定 SemVer", + ); + expect(() => createNightlyVersion("1.6.0", "123456")).toThrow("commit SHA"); + expect(() => createNightlyVersion("1.6.0", "not-a-sha")).toThrow("commit SHA"); + }); + + it("要求三个正式版本源保持一致", () => { + const fixtureRoot = makeTemporaryDirectory(); + writeVersionFixture(fixtureRoot, { package: "1.6.0", cargo: "1.6.0", tauri: "1.6.1" }); + + expect(() => readCanonicalBaseVersion(fixtureRoot)).toThrow("正式版本源不一致"); + }); + + it("生成只包含 Nightly 覆盖项的临时 Tauri 配置", () => { + const fixtureRoot = makeTemporaryDirectory(); + const outputPath = resolve(fixtureRoot, "tauri.nightly.json"); + writeNightlyConfig(outputPath, "1.6.0-nightly.ga1b2c3d"); + + expect(JSON.parse(readFileSync(outputPath, "utf8"))).toEqual({ + version: "1.6.0-nightly.ga1b2c3d", + plugins: { updater: { endpoints: [] } }, + }); + }); + + it("--print 的 stdout 只包含纯版本号", () => { + const scriptPath = resolve(import.meta.dirname, "nightly-version.mjs"); + const stdout = execFileSync(process.execPath, [scriptPath, "--print"], { + cwd: resolve(import.meta.dirname, ".."), + env: { ...process.env, GITHUB_SHA: "0123456789abcdef" }, + encoding: "utf8", + }); + + expect(stdout).toBe("1.6.0-nightly.g0123456\n"); + }); +}); diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index 037ae52..9f95ac2 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -1,4 +1,3 @@ -import { getVersion } from "@tauri-apps/api/app"; import { disable as disableAutostart, enable as enableAutostart, @@ -309,22 +308,15 @@ function SettingsSectionCard({ // 应用更新设置卡片:展示当前版本,手动检查更新并下载安装(状态机见 useAppUpdater) function UpdateSettingsCard() { const { t } = useI18n(); - const { status, availableVersion, progress, checkForUpdate, downloadAndRestart } = useUpdater(); - const [currentVersion, setCurrentVersion] = useState(null); - - useEffect(() => { - let cancelled = false; - void getVersion() - .then((v) => { - if (!cancelled) setCurrentVersion(v); - }) - .catch(() => { - // 取版本失败时仅不展示版本行,不影响检查更新 - }); - return () => { - cancelled = true; - }; - }, []); + const { + availability, + currentVersion, + status, + availableVersion, + progress, + checkForUpdate, + downloadAndRestart, + } = useUpdater(); const isChecking = status === "checking"; const isBusy = status === "downloading" || status === "ready"; @@ -347,7 +339,16 @@ function UpdateSettingsCard() {

) : null}
- {showInstallButton ? ( + {availability === "loading" ? ( + {t("update.loadingVersion")} + ) : null} + {availability === "nightly" ? ( + {t("update.nightlyDisabled")} + ) : null} + {availability === "unavailable" ? ( + {t("update.unavailable")} + ) : null} + {availability === "enabled" && showInstallButton ? ( - ) : ( + ) : null} + {availability === "enabled" && !showInstallButton ? ( - )} - {status === "upToDate" ? ( + ) : null} + {availability === "enabled" && status === "upToDate" ? ( {t("update.upToDate")} ) : null} - {status === "available" && availableVersion ? ( + {availability === "enabled" && status === "available" && availableVersion ? ( {t("update.available").replace("{version}", availableVersion)} diff --git a/src/components/UpdaterProvider.tsx b/src/components/UpdaterProvider.tsx index 23d9541..5e7e13e 100644 --- a/src/components/UpdaterProvider.tsx +++ b/src/components/UpdaterProvider.tsx @@ -16,7 +16,9 @@ const UpdaterContext = createContext(null); */ export function UpdaterProvider({ children }: { children: ReactNode }) { const updater = useAppUpdater(); - // 用 ref 读取最新的 status / checkForUpdate,让自动检查回调保持稳定、不随状态变化重建 + // 用 ref 读取最新状态,让自动检查回调保持稳定、不随状态变化重建 + const availabilityRef = useRef(updater.availability); + availabilityRef.current = updater.availability; const statusRef = useRef(updater.status); statusRef.current = updater.status; const checkRef = useRef(updater.checkForUpdate); @@ -25,6 +27,7 @@ export function UpdaterProvider({ children }: { children: ReactNode }) { // 静默自动检查:仅在空闲态触发,避免打断正在进行的检查 / 下载 / 待重启流程 const autoCheck = useCallback(() => { + if (availabilityRef.current !== "enabled") return; const status = statusRef.current; if ( status === "checking" || @@ -41,28 +44,28 @@ export function UpdaterProvider({ children }: { children: ReactNode }) { // 启动时检查一次 const didInitialCheck = useRef(false); useEffect(() => { - if (didInitialCheck.current) return; + if (didInitialCheck.current || updater.availability !== "enabled") return; didInitialCheck.current = true; autoCheck(); - }, [autoCheck]); + }, [autoCheck, updater.availability]); // 定时轮询 useEffect(() => { - if (!isTauri()) return; + if (!isTauri() || updater.availability !== "enabled") return; const id = window.setInterval(autoCheck, POLL_INTERVAL_MS); return () => window.clearInterval(id); - }, [autoCheck]); + }, [autoCheck, updater.availability]); // 窗口重新聚焦时检查(带节流) useEffect(() => { - if (!isTauri()) return; + if (!isTauri() || updater.availability !== "enabled") return; const onFocus = () => { if (Date.now() - lastCheckRef.current < FOCUS_THROTTLE_MS) return; autoCheck(); }; window.addEventListener("focus", onFocus); return () => window.removeEventListener("focus", onFocus); - }, [autoCheck]); + }, [autoCheck, updater.availability]); return {children}; } diff --git a/src/components/__tests__/SettingsDrawer.test.tsx b/src/components/__tests__/SettingsDrawer.test.tsx index e845911..d053ccb 100644 --- a/src/components/__tests__/SettingsDrawer.test.tsx +++ b/src/components/__tests__/SettingsDrawer.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { I18nProvider } from "../../i18n"; import type { AppPreferences, ConfigWorkspace } from "../../types"; @@ -7,14 +8,27 @@ import { ThemeProvider } from "../theme-provider"; import { UpdaterProvider } from "../UpdaterProvider"; import { Toaster } from "../ui/sonner"; -const { invokeMock, isPermissionGrantedMock, platformMock, requestPermissionMock } = vi.hoisted( - () => ({ +const { invokeMock, isPermissionGrantedMock, platformMock, requestPermissionMock, updaterState } = + vi.hoisted(() => ({ invokeMock: vi.fn<(command: string, args?: unknown) => Promise>(async () => null), isPermissionGrantedMock: vi.fn<() => Promise>(async () => false), platformMock: vi.fn(() => "macos"), requestPermissionMock: vi.fn<() => Promise>(async () => "granted"), - }), -); + updaterState: { + availability: "unavailable", + currentVersion: null as string | null, + status: "idle", + availableVersion: null, + progress: 0, + checkForUpdate: vi.fn(), + downloadAndRestart: vi.fn(), + }, + })); + +vi.mock("../UpdaterProvider", () => ({ + UpdaterProvider: ({ children }: { children: ReactNode }) => children, + useUpdater: () => updaterState, +})); vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock, @@ -88,6 +102,8 @@ describe("SettingsDrawer", () => { platformMock.mockReturnValue("macos"); requestPermissionMock.mockReset(); requestPermissionMock.mockResolvedValue("granted"); + updaterState.availability = "unavailable"; + updaterState.currentVersion = null; invokeMock.mockReset(); invokeMock.mockImplementation(async (command) => { if (command === "get_native_open_app_options") { @@ -143,6 +159,18 @@ describe("SettingsDrawer", () => { }); }); + it("Nightly 版本说明应用内更新已停用", async () => { + updaterState.availability = "nightly"; + updaterState.currentVersion = "1.6.0-nightly.g0123456"; + + renderSettingsDrawer(); + + expect( + await screen.findByText("每夜构建不参与应用内更新,请手动安装新的每夜构建。"), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "检查更新" })).not.toBeInTheDocument(); + }); + it("opens the log viewer from the diagnostics section", async () => { renderSettingsDrawer(); diff --git a/src/components/__tests__/UpdateBanner.test.tsx b/src/components/__tests__/UpdateBanner.test.tsx index 4e0dd7e..0a48680 100644 --- a/src/components/__tests__/UpdateBanner.test.tsx +++ b/src/components/__tests__/UpdateBanner.test.tsx @@ -18,6 +18,8 @@ import { UpdateBanner } from "../UpdateBanner"; function mockUpdater(overrides: Record) { useUpdaterMock.mockReturnValue({ + availability: "enabled", + currentVersion: "1.6.0", status: "idle", availableVersion: null, progress: 0, diff --git a/src/components/__tests__/UpdaterProvider.test.tsx b/src/components/__tests__/UpdaterProvider.test.tsx index 7af8bf4..8f9224f 100644 --- a/src/components/__tests__/UpdaterProvider.test.tsx +++ b/src/components/__tests__/UpdaterProvider.test.tsx @@ -5,6 +5,8 @@ const { checkForUpdateMock, isTauriMock, updaterState } = vi.hoisted(() => ({ checkForUpdateMock: vi.fn(), isTauriMock: vi.fn(() => true), updaterState: { + availability: "enabled", + currentVersion: "1.6.0", status: "idle", availableVersion: null, progress: 0, @@ -25,6 +27,7 @@ beforeEach(() => { vi.setSystemTime(new Date("2026-07-04T00:00:00Z")); vi.clearAllMocks(); updaterState.status = "idle"; + updaterState.availability = "enabled"; isTauriMock.mockReturnValue(true); }); @@ -53,4 +56,17 @@ describe("UpdaterProvider", () => { expect(checkForUpdateMock).not.toHaveBeenCalled(); }); + + it("Nightly 通道不注册或触发自动检查", () => { + updaterState.availability = "nightly"; + render( + +
+ , + ); + + vi.advanceTimersByTime(7 * 60 * 60 * 1000); + fireEvent.focus(window); + expect(checkForUpdateMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/hooks/__tests__/useAppUpdater.test.tsx b/src/hooks/__tests__/useAppUpdater.test.tsx index 35d9670..aa4e29f 100644 --- a/src/hooks/__tests__/useAppUpdater.test.tsx +++ b/src/hooks/__tests__/useAppUpdater.test.tsx @@ -1,17 +1,26 @@ -import { act, renderHook } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; // 隔离 updater / process 插件、isTauri、Toast、logger,专注验证状态机分支 -const { checkMock, relaunchMock, isTauriMock, showToastMock, warnMock, showOperationErrorMock } = - vi.hoisted(() => ({ - checkMock: vi.fn(), - relaunchMock: vi.fn(), - isTauriMock: vi.fn(() => true), - showToastMock: vi.fn(), - warnMock: vi.fn(), - showOperationErrorMock: vi.fn(), - })); +const { + checkMock, + getVersionMock, + relaunchMock, + isTauriMock, + showToastMock, + warnMock, + showOperationErrorMock, +} = vi.hoisted(() => ({ + checkMock: vi.fn(), + getVersionMock: vi.fn<() => Promise>(), + relaunchMock: vi.fn(), + isTauriMock: vi.fn(() => true), + showToastMock: vi.fn(), + warnMock: vi.fn(), + showOperationErrorMock: vi.fn(), +})); +vi.mock("@tauri-apps/api/app", () => ({ getVersion: getVersionMock })); vi.mock("@tauri-apps/plugin-updater", () => ({ check: checkMock })); vi.mock("@tauri-apps/plugin-process", () => ({ relaunch: relaunchMock })); vi.mock("../../types", () => ({ isTauri: isTauriMock })); @@ -38,6 +47,7 @@ function makeUpdate(version = "1.0.1", contentLength: number | null = 100) { beforeEach(() => { vi.clearAllMocks(); isTauriMock.mockReturnValue(true); + getVersionMock.mockResolvedValue("1.6.0"); }); describe("useAppUpdater.checkForUpdate", () => { @@ -84,6 +94,31 @@ describe("useAppUpdater.checkForUpdate", () => { expect(checkMock).not.toHaveBeenCalled(); expect(result.current.status).toBe("idle"); }); + + it("Nightly 版本禁用更新检查且不访问稳定 endpoint", async () => { + getVersionMock.mockResolvedValue("1.6.0-nightly.g0123456"); + const { result } = renderHook(() => useAppUpdater()); + await act(async () => { + await result.current.checkForUpdate(); + }); + + expect(result.current.availability).toBe("nightly"); + expect(result.current.currentVersion).toBe("1.6.0-nightly.g0123456"); + expect(result.current.status).toBe("idle"); + expect(checkMock).not.toHaveBeenCalled(); + }); + + it("读取版本失败时 fail closed,不访问更新 endpoint", async () => { + getVersionMock.mockRejectedValue(new Error("version unavailable")); + const { result } = renderHook(() => useAppUpdater()); + await act(async () => { + await result.current.checkForUpdate(); + }); + + await waitFor(() => expect(result.current.availability).toBe("unavailable")); + expect(checkMock).not.toHaveBeenCalled(); + expect(warnMock).toHaveBeenCalled(); + }); }); describe("useAppUpdater.downloadAndRestart", () => { @@ -185,12 +220,14 @@ describe("useAppUpdater.checkForUpdate silent 模式", () => { }), ); const { result } = renderHook(() => useAppUpdater()); + await waitFor(() => expect(result.current.availability).toBe("enabled")); let silentCheck: Promise | undefined; let manualCheck: Promise | undefined; - act(() => { + await act(async () => { silentCheck = result.current.checkForUpdate({ silent: true }); manualCheck = result.current.checkForUpdate(); + await Promise.resolve(); }); expect(checkMock).toHaveBeenCalledTimes(1); diff --git a/src/hooks/useAppUpdater.ts b/src/hooks/useAppUpdater.ts index 768ec8b..65607d9 100644 --- a/src/hooks/useAppUpdater.ts +++ b/src/hooks/useAppUpdater.ts @@ -1,6 +1,7 @@ +import { getVersion } from "@tauri-apps/api/app"; import { relaunch } from "@tauri-apps/plugin-process"; import { check, type Update } from "@tauri-apps/plugin-updater"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { showOperationError } from "@/lib/user-facing-error"; import { useI18n } from "../i18n"; import { isTauri } from "../types"; @@ -26,6 +27,8 @@ export type AppUpdaterStatus = | "ready" | "error"; +export type AppUpdaterAvailability = "loading" | "enabled" | "nightly" | "unavailable"; + export interface CheckForUpdateOptions { /** * 静默检查:用于启动 / 聚焦 / 定时等自动触发。 @@ -35,6 +38,8 @@ export interface CheckForUpdateOptions { } export interface AppUpdaterState { + availability: AppUpdaterAvailability; + currentVersion: string | null; status: AppUpdaterStatus; /** 发现的新版本号,仅在 available/downloading/ready 时有意义 */ availableVersion: string | null; @@ -44,10 +49,18 @@ export interface AppUpdaterState { downloadAndRestart: () => Promise; } +export function isNightlyVersion(version: string): boolean { + return /-nightly(?:\.|$)/i.test(version); +} + /** 封装 @tauri-apps/plugin-updater 的检查 / 下载 / 安装 / 重启流程,供 UpdaterProvider 统一驱动 */ export function useAppUpdater(): AppUpdaterState { const { t } = useI18n(); const { showToast } = useToast(); + const [availability, setAvailability] = useState( + isTauri() ? "loading" : "unavailable", + ); + const [currentVersion, setCurrentVersion] = useState(null); const [status, setStatus] = useState("idle"); const [availableVersion, setAvailableVersion] = useState(null); const [progress, setProgress] = useState(0); @@ -55,10 +68,45 @@ export function useAppUpdater(): AppUpdaterState { const pendingUpdateRef = useRef(null); // 复用进行中的检查,避免自动检查与手动检查并发写入同一份更新状态 const checkRequestRef = useRef | null>(null); + // 版本读取是本地调用且全局只需一次;所有检查都等待同一结果,Nightly 默认 fail closed。 + const availabilityRequestRef = useRef< + Promise<{ availability: AppUpdaterAvailability; version: string | null }> | undefined + >(undefined); + + const resolveAvailability = useCallback(() => { + if (!availabilityRequestRef.current) { + availabilityRequestRef.current = isTauri() + ? getVersion() + .then((version) => ({ + availability: isNightlyVersion(version) ? ("nightly" as const) : ("enabled" as const), + version, + })) + .catch((error) => { + logger.warn(`updater: 读取应用版本失败,已停用更新检查 ${String(error)}`); + return { availability: "unavailable" as const, version: null }; + }) + : Promise.resolve({ availability: "unavailable" as const, version: null }); + } + return availabilityRequestRef.current; + }, []); + + useEffect(() => { + let cancelled = false; + void resolveAvailability().then((resolved) => { + if (cancelled) return; + setAvailability(resolved.availability); + setCurrentVersion(resolved.version); + }); + return () => { + cancelled = true; + }; + }, [resolveAvailability]); const checkForUpdate = useCallback( async (options?: CheckForUpdateOptions) => { if (!isTauri()) return; + const resolved = await resolveAvailability(); + if (resolved.availability !== "enabled") return; // 静默检查不进入 checking,避免顶部横幅 / 设置按钮出现无意义的加载态闪烁 if (!options?.silent) setStatus("checking"); let request = checkRequestRef.current; @@ -93,7 +141,7 @@ export function useAppUpdater(): AppUpdaterState { } } }, - [showToast, t], + [resolveAvailability, showToast, t], ); // 安装完成后重启进入新版本;重启失败不应回退为下载失败,保留 ready 让用户重试 @@ -146,5 +194,13 @@ export function useAppUpdater(): AppUpdaterState { await restartApp(); }, [status, restartApp, showToast, t]); - return { status, availableVersion, progress, checkForUpdate, downloadAndRestart }; + return { + availability, + currentVersion, + status, + availableVersion, + progress, + checkForUpdate, + downloadAndRestart, + }; } diff --git a/src/i18n.ts b/src/i18n.ts index d33d4c2..7e152ae 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1392,6 +1392,9 @@ const translations = { "update.title": "应用更新", "update.description": "检查并安装新版本。", "update.currentVersion": "当前版本 {version}", + "update.loadingVersion": "正在读取当前版本…", + "update.nightlyDisabled": "每夜构建不参与应用内更新,请手动安装新的每夜构建。", + "update.unavailable": "当前无法读取应用版本,更新检查已停用。", "update.checkNow": "检查更新", "update.checking": "正在检查…", "update.upToDate": "已是最新版本", @@ -3163,6 +3166,10 @@ const translations = { "update.title": "App Update", "update.description": "Check for and install new versions.", "update.currentVersion": "Current version {version}", + "update.loadingVersion": "Reading the current version…", + "update.nightlyDisabled": + "Nightly builds do not use in-app updates. Install a newer Nightly build manually.", + "update.unavailable": "The app version is unavailable, so update checks are disabled.", "update.checkNow": "Check for Updates", "update.checking": "Checking…", "update.upToDate": "You're on the latest version",