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:
+11
-2
@@ -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. 暂不做
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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. 后续暂缓任务
|
||||
|
||||
以下任务第一阶段暂不做,后续需要时再新增设计文档:
|
||||
|
||||
Reference in New Issue
Block a user