feat: LAN auto-update launcher script (stage 3)

scripts/update.ps1: check manifest, stage+verify the new version, swap it in
alongside the running one (versions\<ver> + current.txt pointer), then launch
with CMBOT_DATA_DIR. Degrades to the local version on any failure; never
overwrites the running version. Rollback = edit current.txt.

Verified against a fake versioned layout: update, idempotent re-run, unreachable
source, no source, corrupt download (marker missing), malformed manifest.

Not yet wired into a real install layout (build.ps1 still ships flat onedir).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 17:29:56 +08:00
co-authored by Claude Opus 4.8
parent 09a563aeec
commit 275ce852c3
3 changed files with 227 additions and 2 deletions
+11 -2
View File
@@ -229,8 +229,17 @@
1. **地基**:数据目录分离(第 5 节)。任何更新方案的前提。✅ 已实现。
2. **只读通知**:启动时读取 `manifest.json` 比对版本,有新版仅提示并打开更新源目录(不自动安装)。✅ 已实现——`services/update_service.py`(`check_for_update` / 版本比较,纯逻辑可测)+ 主窗口顶部通知横幅,检查在后台线程进行(更新源不可达不阻塞启动),更新源路径由 `update_source` 配置。
3. **自动安装**:实现启动器完整流程(下载 → 校验 → 原子切换 → 启动 → 回滚)。⛔ 未做。
4. **强制更新与保留策略**:补全 `mandatory` / `min_supported` 与版本清理。⛔ 未做。
3. **自动安装**:实现启动器完整流程(下载 → 校验 → 原子切换 → 启动 → 回滚)。🚧 启动器脚本 `scripts/update.ps1` 已实现并以假版本目录验证全流程(含源不可达、下载损坏、坏 manifest 等降级路径);尚未接入真实安装结构(`build.ps1` 仍产出扁平 onedir,无生成 `%LOCALAPPDATA%\CMBot\versions\<ver>\` 版本并排布局的安装步骤)。
4. **强制更新与保留策略**:补全 `mandatory` / `min_supported` 与版本清理(保留最近 N 个)。⛔ 未做。
启动器(`scripts/update.ps1`)要点:
- 入口参数 `-InstallRoot`(默认脚本所在目录)、`-NoLaunch`(测试用,只更新不启动)。
- 从 `<InstallRoot>\data\config\app_config.json` 读 `update_source`,与 stage ② 同源。
- 下载到 `staging\<ver>.tmp` → 校验 `manifest.marker` 存在 → 重命名进 `versions\<ver>` → 原子写 `current.txt`(ascii 无 BOM)。
- 启动 `versions\<current>\CMBot.exe` 并设 `CMBOT_DATA_DIR=<InstallRoot>\data`(接 stage ① 数据目录分离)。
- 任何失败(源不可达 / robocopy 失败 / marker 缺失 / 坏 manifest)都降级启动本地现版本,绝不进入「无可启动版本」。
- 旧版本目录保留,回滚 = 手改 `current.txt` 回旧版本号。
## 17. 暂不做
+195
View File
@@ -0,0 +1,195 @@
<#
.SYNOPSIS
LAN auto-update launcher for CMBot (docs/10-lan-update.md, stage 3).
.DESCRIPTION
Run instead of launching CMBot.exe directly. On each start it:
1. reads the local version from current.txt;
2. reads update_source from data\config\app_config.json and the remote
manifest.json there;
3. if a newer version is advertised, copies it into staging, verifies it,
then atomically swaps it into versions\<ver> and flips current.txt;
4. launches versions\<current>\CMBot.exe with CMBOT_DATA_DIR pointed at the
shared data\ folder.
Degrades safely: an unreachable source, a malformed manifest, or a failed
copy never blocks startup — the existing local version is launched instead.
Never overwrites the running version (new version installs alongside).
.PARAMETER InstallRoot
Install directory holding current.txt, versions\, data\, staging\.
Defaults to the launcher's own folder.
.PARAMETER NoLaunch
Do everything except start the app. For testing the update flow.
#>
[CmdletBinding()]
param(
[string]$InstallRoot = $PSScriptRoot,
[switch]$NoLaunch
)
$ErrorActionPreference = "Stop"
$AppExe = "CMBot.exe"
$ManifestName = "manifest.json"
$CurrentTxt = Join-Path $InstallRoot "current.txt"
$VersionsDir = Join-Path $InstallRoot "versions"
$DataDir = Join-Path $InstallRoot "data"
$StagingDir = Join-Path $InstallRoot "staging"
$ConfigFile = Join-Path $DataDir "config\app_config.json"
$LogFile = Join-Path $InstallRoot "launcher.log"
function Write-Log {
param([string]$Message)
$line = "{0} {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Message
Write-Host $line
try { Add-Content -LiteralPath $LogFile -Value $line -Encoding UTF8 } catch { }
}
function Get-LocalVersion {
if (Test-Path -LiteralPath $CurrentTxt) {
return (Get-Content -LiteralPath $CurrentTxt -Raw).TrimStart([char]0xFEFF).Trim()
}
return ""
}
function Get-UpdateSource {
if (-not (Test-Path -LiteralPath $ConfigFile)) { return "" }
try {
$cfg = Get-Content -LiteralPath $ConfigFile -Raw | ConvertFrom-Json
return [string]$cfg.update_source
} catch {
Write-Log "Cannot read update_source: $_"
return ""
}
}
function ConvertTo-VersionParts {
param([string]$Text)
$parts = @(0, 0, 0)
if ($Text) {
$chunks = $Text.Trim().Split(".")
for ($i = 0; $i -lt 3 -and $i -lt $chunks.Count; $i++) {
$digits = ($chunks[$i] -replace '^(\d*).*$', '$1')
if ($digits) { $parts[$i] = [int]$digits }
}
}
return ,$parts
}
function Test-IsNewer {
param([string]$Remote, [string]$Local)
$r = ConvertTo-VersionParts $Remote
$l = ConvertTo-VersionParts $Local
for ($i = 0; $i -lt 3; $i++) {
if ($r[$i] -gt $l[$i]) { return $true }
if ($r[$i] -lt $l[$i]) { return $false }
}
return $false
}
function Invoke-Update {
param([string]$Source, [string]$LocalVersion)
$manifestPath = Join-Path $Source $ManifestName
if (-not (Test-Path -LiteralPath $manifestPath)) {
Write-Log "No manifest at $manifestPath - skip update."
return
}
try {
$m = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
} catch {
Write-Log "Manifest unreadable - skip update: $_"
return
}
$remoteVer = [string]$m.version
if (-not $remoteVer) {
Write-Log "Manifest has no version - skip update."
return
}
if (-not (Test-IsNewer $remoteVer $LocalVersion)) {
Write-Log "Up to date (local '$LocalVersion', remote '$remoteVer')."
return
}
$srcFiles = [string]$m.source
if (-not $srcFiles) { $srcFiles = $Source }
if (-not (Test-Path -LiteralPath $srcFiles)) {
Write-Log "Version folder not found: $srcFiles - skip update."
return
}
$marker = if ($m.marker) { [string]$m.marker } else { $AppExe }
# 1. copy into a temp staging folder (never into versions\ directly)
$stageTmp = Join-Path $StagingDir ("{0}.tmp" -f $remoteVer)
if (Test-Path -LiteralPath $stageTmp) { Remove-Item -LiteralPath $stageTmp -Recurse -Force }
New-Item -ItemType Directory -Force -Path $stageTmp | Out-Null
Write-Log "Downloading v$remoteVer from $srcFiles ..."
& robocopy $srcFiles $stageTmp /E /NFL /NDL /NJH /NJS /NP /R:1 /W:1 | Out-Null
if ($LASTEXITCODE -ge 8) {
Remove-Item -LiteralPath $stageTmp -Recurse -Force -ErrorAction SilentlyContinue
throw "robocopy failed (exit $LASTEXITCODE)"
}
# 2. verify completeness via the marker file
if (-not (Test-Path -LiteralPath (Join-Path $stageTmp $marker))) {
Remove-Item -LiteralPath $stageTmp -Recurse -Force -ErrorAction SilentlyContinue
throw "marker '$marker' missing after copy - corrupt download"
}
# 3. atomic-ish swap: rename staging -> versions\<ver>, then flip pointer
$verDir = Join-Path $VersionsDir $remoteVer
if (Test-Path -LiteralPath $verDir) { Remove-Item -LiteralPath $verDir -Recurse -Force }
Move-Item -LiteralPath $stageTmp -Destination $verDir
$ptrTmp = "$CurrentTxt.tmp"
# ASCII keeps the pointer file BOM-free (version strings are ASCII).
Set-Content -LiteralPath $ptrTmp -Value $remoteVer -Encoding ascii -NoNewline
Move-Item -LiteralPath $ptrTmp -Destination $CurrentTxt -Force
Write-Log "Installed and switched to v$remoteVer."
}
# ── main ──────────────────────────────────────────────────────────────────────
New-Item -ItemType Directory -Force -Path $VersionsDir | Out-Null
New-Item -ItemType Directory -Force -Path $StagingDir | Out-Null
$local = Get-LocalVersion
$source = Get-UpdateSource
if ($source) {
try {
Invoke-Update -Source $source -LocalVersion $local
} catch {
Write-Log "Update skipped (degraded to local version): $_"
}
} else {
Write-Log "No update_source configured - skip update check."
}
$current = Get-LocalVersion
if (-not $current) {
Write-Log "ERROR: no installed version to launch (current.txt missing)."
exit 1
}
$exe = Join-Path (Join-Path $VersionsDir $current) $AppExe
if (-not (Test-Path -LiteralPath $exe)) {
Write-Log "ERROR: executable not found: $exe"
exit 1
}
if ($NoLaunch) {
Write-Log "NoLaunch: would start $exe (CMBOT_DATA_DIR=$DataDir)"
exit 0
}
Write-Log "Launching v$current ..."
$env:CMBOT_DATA_DIR = $DataDir
Start-Process -FilePath $exe
+21
View File
@@ -872,6 +872,27 @@
- [x] GUI 实测:配置可达更新源 + 高版本 manifest → 启动后显示横幅,点击打开目录
- [x] GUI 实测:更新源不可达 → 正常启动、无横幅、无卡顿
### 17.17 局域网更新 · 阶段③:自动安装启动器(脚本)
前置阅读:
- `docs/10-lan-update.md`(§3 架构、§8 流程、§16 阶段③)
说明:
- 实现 PowerShell 启动器 `scripts/update.ps1`,跑通「检查 → staging → 校验 → 原子切换 → 启动 → 降级」整条链路。先以脚本验证流程,后续再决定是否编译为 `Launcher.exe`。
任务:
- [x] `scripts/update.ps1`:读 `current.txt` 与 `data\config\app_config.json` 的 `update_source`
- [x] 读远端 `manifest.json`、语义化版本比较,仅当远端更高才更新
- [x] robocopy 拷到 `staging\<ver>.tmp` → 校验 `marker` → 重命名进 `versions\<ver>` → 原子写 `current.txt`(ascii 无 BOM)
- [x] 启动 `versions\<current>\CMBot.exe` 并设 `CMBOT_DATA_DIR`
- [x] 降级:源不可达 / robocopy 失败 / marker 缺失 / 坏 manifest 一律启动本地现版本
- [x] 假版本目录验证 6 个用例(更新、幂等、源不可达、未配置源、下载损坏、坏 manifest)全过
- [ ] 接入真实安装结构:`build.ps1` 或新增安装步骤产出 `%LOCALAPPDATA%\CMBot\versions\<ver>\` 版本并排布局
- [ ] 真实内网双机实测
## 18. 后续暂缓任务
以下任务第一阶段暂不做,后续需要时再新增设计文档: