From 39d2201005c0cd5927f25f97b26eb8126e933a4b Mon Sep 17 00:00:00 2001
From: maguowei
Date: Sun, 30 Aug 2026 23:28:06 +0800
Subject: [PATCH] =?UTF-8?q?feat(updater):=20=E6=96=B0=E5=A2=9E=E6=AF=8F?=
=?UTF-8?q?=E5=A4=9C=E6=9E=84=E5=BB=BA=E6=BB=9A=E5=8A=A8=E9=80=9A=E9=81=93?=
=?UTF-8?q?=E5=B9=B6=E5=9C=A8=20Nightly=20=E7=89=88=E6=9C=AC=E7=A6=81?=
=?UTF-8?q?=E7=94=A8=E5=BA=94=E7=94=A8=E5=86=85=E6=9B=B4=E6=96=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5 (1M context)
---
.github/workflows/ci.yml | 2 +
.github/workflows/nightly.yml | 438 ++++++++++++++++++
CONTEXT.md | 17 +
README.md | 6 +
README.zh-CN.md | 6 +
docs/adr/0006-nightly-rolling-prerelease.md | 23 +
scripts/nightly-version.mjs | 111 +++++
scripts/nightly-version.test.mjs | 85 ++++
src/components/SettingsDrawer.tsx | 46 +-
src/components/UpdaterProvider.tsx | 17 +-
.../__tests__/SettingsDrawer.test.tsx | 36 +-
.../__tests__/UpdateBanner.test.tsx | 2 +
.../__tests__/UpdaterProvider.test.tsx | 16 +
src/hooks/__tests__/useAppUpdater.test.tsx | 59 ++-
src/hooks/useAppUpdater.ts | 62 ++-
src/i18n.ts | 7 +
16 files changed, 886 insertions(+), 47 deletions(-)
create mode 100644 .github/workflows/nightly.yml
create mode 100644 docs/adr/0006-nightly-rolling-prerelease.md
create mode 100644 scripts/nightly-version.mjs
create mode 100644 scripts/nightly-version.test.mjs
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 ? (
{installLabel}
- ) : (
+ ) : null}
+ {availability === "enabled" && !showInstallButton ? (
{isChecking ? t("update.checking") : t("update.checkNow")}
- )}
- {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",