From 275ce852c38a69221b7c460e8021357a71f839e5 Mon Sep 17 00:00:00 2001 From: ila Date: Wed, 17 Jun 2026 17:29:56 +0800 Subject: [PATCH] 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\ + 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 --- docs/10-lan-update.md | 13 ++- scripts/update.ps1 | 195 ++++++++++++++++++++++++++++++++++++++++++ tasks.md | 21 +++++ 3 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 scripts/update.ps1 diff --git a/docs/10-lan-update.md b/docs/10-lan-update.md index f7734b9..d43affd 100644 --- a/docs/10-lan-update.md +++ b/docs/10-lan-update.md @@ -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\\` 版本并排布局的安装步骤)。 +4. **强制更新与保留策略**:补全 `mandatory` / `min_supported` 与版本清理(保留最近 N 个)。⛔ 未做。 + +启动器(`scripts/update.ps1`)要点: + +- 入口参数 `-InstallRoot`(默认脚本所在目录)、`-NoLaunch`(测试用,只更新不启动)。 +- 从 `\data\config\app_config.json` 读 `update_source`,与 stage ② 同源。 +- 下载到 `staging\.tmp` → 校验 `manifest.marker` 存在 → 重命名进 `versions\` → 原子写 `current.txt`(ascii 无 BOM)。 +- 启动 `versions\\CMBot.exe` 并设 `CMBOT_DATA_DIR=\data`(接 stage ① 数据目录分离)。 +- 任何失败(源不可达 / robocopy 失败 / marker 缺失 / 坏 manifest)都降级启动本地现版本,绝不进入「无可启动版本」。 +- 旧版本目录保留,回滚 = 手改 `current.txt` 回旧版本号。 ## 17. 暂不做 diff --git a/scripts/update.ps1 b/scripts/update.ps1 new file mode 100644 index 0000000..11591a2 --- /dev/null +++ b/scripts/update.ps1 @@ -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\ and flips current.txt; + 4. launches versions\\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\, 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 diff --git a/tasks.md b/tasks.md index 2c634c9..9ed4a72 100644 --- a/tasks.md +++ b/tasks.md @@ -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\.tmp` → 校验 `marker` → 重命名进 `versions\` → 原子写 `current.txt`(ascii 无 BOM) +- [x] 启动 `versions\\CMBot.exe` 并设 `CMBOT_DATA_DIR` +- [x] 降级:源不可达 / robocopy 失败 / marker 缺失 / 坏 manifest 一律启动本地现版本 +- [x] 假版本目录验证 6 个用例(更新、幂等、源不可达、未配置源、下载损坏、坏 manifest)全过 +- [ ] 接入真实安装结构:`build.ps1` 或新增安装步骤产出 `%LOCALAPPDATA%\CMBot\versions\\` 版本并排布局 +- [ ] 真实内网双机实测 + ## 18. 后续暂缓任务 以下任务第一阶段暂不做,后续需要时再新增设计文档: