chore: retire PowerShell update.ps1 and install_local.ps1

Superseded by src/launcher.py (Launcher.exe) + the portable layout from
build.ps1. The PS prototype validated the flow; removed to avoid confusion
with the old %LOCALAPPDATA% installer. docs/10 §16 and tasks 17.19 updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 11:05:06 +08:00
co-authored by Claude Opus 4.8
parent 3327276c9a
commit 295dc803c6
4 changed files with 3 additions and 490 deletions
+2 -2
View File
@@ -253,8 +253,8 @@ HTTP 在线更新比内网共享面临更高风险,按以下层次防护:
1. **地基**:数据目录分离(第 5 节)。任何更新方案的前提。✅ 已实现。
2. **只读通知**:启动时读取 `manifest.json` 比对版本,有新版仅提示。✅ 已实现——`services/update_service.py`(`check_for_update` / 版本比较,纯逻辑可测,支持 **`http(s)://` 源 + Basic Auth** 及本地路径;manifest 用 `utf-8-sig` 解码以容忍 BOM)+ 主窗口顶部通知横幅,后台线程检查(不可达不阻塞启动),更新源 / 凭据由 `update_source` / `update_user` / `update_pass` 配置。
3. **自动安装(PowerShell 原型)**:启动器完整流程(下载 → 校验 → `app` 目录切换 → 启动 → `app.old` 回滚)。✅ 以 `scripts/update.ps1` 实现并端到端验证(HTTP 下载 zip + SHA-256 + 解压 + `app/app.old` 切换);`scripts/build.ps1` 产出 `version.txt` + zip + 写无 BOM `manifest.json`。**此为流程验证原型。**
4. **改用 `Launcher.exe` + `~/.cmbot`(当前方向)**:🚧 将启动器改为 **Python + PyInstaller onefile** 编译的 `Launcher.exe`(复用 `services/update_service.py`),采用**便携布局**(解压任意可写目录即用,安装根 = `Launcher.exe` 所在目录),用户数据移到 **`~/.cmbot`**(`get_data_dir()` 三级回退,见第 5 节)。退休 `scripts/update.ps1` 与 `scripts/install_local.ps1`(`%LOCALAPPDATA%` 安装器)。待做:`src/launcher.py`、`build.ps1` 增产 `Launcher.exe`、安装根可写性检测、首次播种默认模板、真实环境端到端实测。
3. **自动安装(PowerShell 原型,已退休)**:曾以 `scripts/update.ps1` + `scripts/install_local.ps1` 跑通整条流程(HTTP 下载 zip + SHA-256 + 解压 + `app/app.old` 切换),验证了设计可行。该原型已被阶段④取代并删除(git 历史可查)。
4. **`Launcher.exe` + `~/.cmbot`(当前方向)**:✅ 已实现——`src/launcher.py`(Python + PyInstaller onefile,复用 `services/update_service.py`:下载 zip + SHA-256 + 解压 + `app/app.old` 切换 + 安装根可写性检测 + 首次播种默认模板到 `~/.cmbot`);`scripts/build.ps1` 产出 `Launcher.exe` + 便携布局 + 两个 zip(自更新载荷 / 便携安装包)+ 无 BOM `manifest.json`;`get_data_dir()` 三级回退(见第 5 节)。已用本地 HTTP server 真实端到端验证(下载→校验→切换→播种)。**待做**:在 Windows 上真实 PyInstaller 构建 + 解压到 D 盘真机端到端实测。
5. **强制更新与保留策略**:补全 `mandatory` / `min_supported` 与 `app.old` 回滚策略。⛔ 未做。
启动器(`Launcher.exe`,目标)要点:
-166
View File
@@ -1,166 +0,0 @@
<#
.SYNOPSIS
Install a built CMBot release into the local launcher layout.
.DESCRIPTION
Creates or updates this structure:
%LOCALAPPDATA%\CMBot\
update.ps1
app\
app.old\
data\
config\
logs\
output\
staging\
The script copies a built release directory (release\CMBot-x.y.z by default)
into app\, copies scripts\update.ps1 to the install root, and migrates
config files into data\config without overwriting existing user config.
.PARAMETER ReleaseDir
Built release directory to install. Defaults to release\CMBot-APP_VERSION.
.PARAMETER InstallRoot
Local install root. Defaults to %LOCALAPPDATA%\CMBot.
.PARAMETER LegacyDataDir
Optional old flat install/data root. When provided, config files are copied
from LegacyDataDir\config before falling back to app.old\config or release
defaults.
.PARAMETER Force
Allows replacing an existing app.old directory.
#>
[CmdletBinding()]
param(
[string]$ReleaseDir = "",
[string]$InstallRoot = (Join-Path $env:LOCALAPPDATA "CMBot"),
[string]$LegacyDataDir = "",
[switch]$Force
)
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RootDir = Split-Path -Parent $ScriptDir
$VersionFile = Join-Path $RootDir "src\version.py"
$ReleaseRoot = Join-Path $RootDir "release"
$LauncherScript = Join-Path $ScriptDir "update.ps1"
$AppExeName = "CMBot"
$AppExe = "$AppExeName.exe"
function Read-VersionValue {
param(
[string]$Path,
[string]$Name
)
$pattern = "^\s*$Name\s*=\s*`"([^`"]+)`""
foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8) {
if ($line -match $pattern) {
return $Matches[1]
}
}
throw "Cannot find $Name in $Path"
}
function Resolve-ExistingPath {
param(
[string]$Path,
[string]$Name
)
if (-not (Test-Path -LiteralPath $Path)) {
throw "$Name not found: $Path"
}
return (Resolve-Path -LiteralPath $Path).Path
}
function Assert-Release {
param([string]$Path)
$exe = Join-Path $Path $AppExe
$version = Join-Path $Path "version.txt"
if (-not (Test-Path -LiteralPath $exe)) {
throw "Release missing executable: $exe"
}
if (-not (Test-Path -LiteralPath $version)) {
throw "Release missing version.txt: $version"
}
}
function Copy-ConfigIfMissing {
param(
[string]$SourceConfigDir,
[string]$TargetConfigDir,
[string]$FileName
)
if (-not $SourceConfigDir) {
return
}
$source = Join-Path $SourceConfigDir $FileName
$target = Join-Path $TargetConfigDir $FileName
if ((Test-Path -LiteralPath $source) -and -not (Test-Path -LiteralPath $target)) {
Copy-Item -LiteralPath $source -Destination $target
Write-Host "Migrated config: $FileName"
}
}
if (-not $ReleaseDir) {
$appVersion = Read-VersionValue -Path $VersionFile -Name "APP_VERSION"
$ReleaseDir = Join-Path $ReleaseRoot "$AppExeName-$appVersion"
}
$ReleaseDir = Resolve-ExistingPath -Path $ReleaseDir -Name "Release directory"
$LauncherScript = Resolve-ExistingPath -Path $LauncherScript -Name "Launcher script"
Assert-Release -Path $ReleaseDir
$InstallRoot = [System.IO.Path]::GetFullPath($InstallRoot)
$AppDir = Join-Path $InstallRoot "app"
$OldAppDir = Join-Path $InstallRoot "app.old"
$DataDir = Join-Path $InstallRoot "data"
$ConfigDir = Join-Path $DataDir "config"
$LogsDir = Join-Path $DataDir "logs"
$OutputDir = Join-Path $DataDir "output"
$StagingDir = Join-Path $InstallRoot "staging"
New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null
New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null
New-Item -ItemType Directory -Force -Path $LogsDir | Out-Null
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
New-Item -ItemType Directory -Force -Path $StagingDir | Out-Null
if (Test-Path -LiteralPath $AppDir) {
if (Test-Path -LiteralPath $OldAppDir) {
if (-not $Force) {
throw "app.old already exists: $OldAppDir. Re-run with -Force to replace it."
}
Remove-Item -LiteralPath $OldAppDir -Recurse -Force
}
Move-Item -LiteralPath $AppDir -Destination $OldAppDir
}
Copy-Item -LiteralPath $ReleaseDir -Destination $AppDir -Recurse
Copy-Item -LiteralPath $LauncherScript -Destination (Join-Path $InstallRoot "update.ps1") -Force
$configSources = @()
if ($LegacyDataDir) {
$configSources += Join-Path ([System.IO.Path]::GetFullPath($LegacyDataDir)) "config"
}
if (Test-Path -LiteralPath $OldAppDir) {
$configSources += Join-Path $OldAppDir "config"
}
$configSources += Join-Path $AppDir "config"
foreach ($sourceConfig in $configSources) {
Copy-ConfigIfMissing -SourceConfigDir $sourceConfig -TargetConfigDir $ConfigDir -FileName "app_config.json"
Copy-ConfigIfMissing -SourceConfigDir $sourceConfig -TargetConfigDir $ConfigDir -FileName "templates.json"
}
Write-Host "Installed CMBot launcher layout:"
Write-Host " Root: $InstallRoot"
Write-Host " App: $AppDir"
Write-Host " Data: $DataDir"
Write-Host ""
Write-Host "Start with:"
Write-Host " powershell -ExecutionPolicy Bypass -File `"$InstallRoot\update.ps1`""
-321
View File
@@ -1,321 +0,0 @@
<#
.SYNOPSIS
HTTP 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 app\version.txt;
2. reads update_source/update_user/update_pass from data\config\app_config.json;
3. downloads manifest.json and, when newer, downloads the release zip;
4. verifies SHA-256, extracts to staging\app.new, and switches app/app.old;
5. launches app\CMBot.exe with CMBOT_DATA_DIR pointed at data\.
Degrades safely for normal updates: network errors, malformed manifests,
failed downloads, bad hashes, or bad zips leave the existing app\ version
in place and launch it.
.PARAMETER InstallRoot
Install directory holding Launcher.exe, app\, app.old\, 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"
$VersionFileName = "version.txt"
$AppDir = Join-Path $InstallRoot "app"
$OldAppDir = Join-Path $InstallRoot "app.old"
$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 {
$versionFile = Join-Path $AppDir $VersionFileName
if (Test-Path -LiteralPath $versionFile) {
return (Get-Content -LiteralPath $versionFile -Raw).TrimStart([char]0xFEFF).Trim()
}
return ""
}
function Get-UpdateConfig {
$result = [ordered]@{
Source = ""
User = ""
Pass = ""
}
if (-not (Test-Path -LiteralPath $ConfigFile)) {
return $result
}
try {
$cfg = Get-Content -LiteralPath $ConfigFile -Raw | ConvertFrom-Json
$result.Source = [string]$cfg.update_source
$result.User = [string]$cfg.update_user
$result.Pass = [string]$cfg.update_pass
} catch {
Write-Log "Cannot read update config: $_"
}
return $result
}
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 Get-AuthHeaders {
param(
[string]$User,
[string]$Pass
)
$headers = @{}
if ($User -or $Pass) {
$bytes = [System.Text.Encoding]::UTF8.GetBytes(("{0}:{1}" -f $User, $Pass))
$headers["Authorization"] = "Basic " + [Convert]::ToBase64String($bytes)
}
return $headers
}
function Resolve-ManifestUrl {
param([string]$Source)
$uri = [System.Uri]$Source
if ([System.IO.Path]::GetFileName($uri.AbsolutePath).ToLowerInvariant() -eq $ManifestName) {
return $uri.AbsoluteUri
}
if (-not $Source.EndsWith("/")) {
$Source = "$Source/"
$uri = [System.Uri]$Source
}
return (New-Object -TypeName System.Uri -ArgumentList $uri, $ManifestName).AbsoluteUri
}
function Resolve-ReleaseUrl {
param(
[string]$ManifestUrl,
[string]$ReleaseUrl
)
$uri = $null
if ([System.Uri]::TryCreate($ReleaseUrl, [System.UriKind]::Absolute, [ref]$uri)) {
return $uri.AbsoluteUri
}
return (New-Object -TypeName System.Uri -ArgumentList ([System.Uri]$ManifestUrl), $ReleaseUrl).AbsoluteUri
}
function Invoke-JsonGet {
param(
[string]$Url,
[hashtable]$Headers
)
$response = Invoke-WebRequest -Uri $Url -Headers $Headers -UseBasicParsing -TimeoutSec 15
return $response.Content | ConvertFrom-Json
}
function Save-HttpFile {
param(
[string]$Url,
[string]$Destination,
[hashtable]$Headers
)
Invoke-WebRequest -Uri $Url -Headers $Headers -UseBasicParsing -TimeoutSec 120 -OutFile $Destination
}
function Remove-PathIfExists {
param([string]$Path)
if (Test-Path -LiteralPath $Path) {
Remove-Item -LiteralPath $Path -Recurse -Force
}
}
function Get-PackageRoot {
param([string]$ExtractDir)
if (Test-Path -LiteralPath (Join-Path $ExtractDir $AppExe)) {
return $ExtractDir
}
$children = @(Get-ChildItem -LiteralPath $ExtractDir -Directory)
if ($children.Count -eq 1) {
$candidate = $children[0].FullName
if (Test-Path -LiteralPath (Join-Path $candidate $AppExe)) {
return $candidate
}
}
return ""
}
function Assert-PackageValid {
param(
[string]$PackageRoot,
[string]$ExpectedVersion
)
if (-not $PackageRoot -or -not (Test-Path -LiteralPath (Join-Path $PackageRoot $AppExe))) {
throw "downloaded package does not contain $AppExe"
}
$versionFile = Join-Path $PackageRoot $VersionFileName
if (-not (Test-Path -LiteralPath $versionFile)) {
throw "downloaded package does not contain $VersionFileName"
}
$actualVersion = (Get-Content -LiteralPath $versionFile -Raw).TrimStart([char]0xFEFF).Trim()
if ($actualVersion -ne $ExpectedVersion) {
throw "package version '$actualVersion' does not match manifest '$ExpectedVersion'"
}
}
function Test-AppRunning {
$name = [System.IO.Path]::GetFileNameWithoutExtension($AppExe)
$exePath = Join-Path $AppDir $AppExe
$running = Get-Process -Name $name -ErrorAction SilentlyContinue | Where-Object {
try { $_.Path -eq $exePath } catch { $false }
}
return [bool]$running
}
function Switch-AppDirectory {
param([string]$PackageRoot)
if (Test-AppRunning) {
throw "$AppExe is already running"
}
if (Test-Path -LiteralPath $OldAppDir) {
Remove-Item -LiteralPath $OldAppDir -Recurse -Force
}
if (Test-Path -LiteralPath $AppDir) {
Move-Item -LiteralPath $AppDir -Destination $OldAppDir
}
try {
Move-Item -LiteralPath $PackageRoot -Destination $AppDir
} catch {
if ((-not (Test-Path -LiteralPath $AppDir)) -and (Test-Path -LiteralPath $OldAppDir)) {
Move-Item -LiteralPath $OldAppDir -Destination $AppDir
}
throw
}
}
function Invoke-Update {
param(
[string]$Source,
[string]$User,
[string]$Pass,
[string]$LocalVersion
)
$manifestUrl = Resolve-ManifestUrl $Source
$headers = Get-AuthHeaders -User $User -Pass $Pass
Write-Log "Checking update manifest: $manifestUrl"
$m = Invoke-JsonGet -Url $manifestUrl -Headers $headers
$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
}
$releaseField = [string]$m.url
$expectedHash = ([string]$m.sha256).Trim().ToLowerInvariant()
if (-not $releaseField -or -not $expectedHash) {
throw "manifest must contain url and sha256"
}
$releaseUrl = Resolve-ReleaseUrl -ManifestUrl $manifestUrl -ReleaseUrl $releaseField
$zipPath = Join-Path $StagingDir ("{0}.zip" -f $remoteVer)
$extractDir = Join-Path $StagingDir "app.new"
Remove-PathIfExists -Path $zipPath
Remove-PathIfExists -Path $extractDir
Write-Log "Downloading v$remoteVer from $releaseUrl ..."
Save-HttpFile -Url $releaseUrl -Destination $zipPath -Headers $headers
if ($m.size) {
$actualSize = (Get-Item -LiteralPath $zipPath).Length
if ($actualSize -ne [int64]$m.size) {
throw "download size mismatch: got $actualSize, expected $($m.size)"
}
}
$actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $expectedHash) {
throw "sha256 mismatch"
}
Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDir -Force
$packageRoot = Get-PackageRoot -ExtractDir $extractDir
Assert-PackageValid -PackageRoot $packageRoot -ExpectedVersion $remoteVer
Switch-AppDirectory -PackageRoot $packageRoot
Remove-PathIfExists -Path $zipPath
Remove-PathIfExists -Path $extractDir
Write-Log "Installed and switched to v$remoteVer."
}
# -- main --------------------------------------------------------------------
New-Item -ItemType Directory -Force -Path $StagingDir | Out-Null
New-Item -ItemType Directory -Force -Path $DataDir | Out-Null
$local = Get-LocalVersion
$cfg = Get-UpdateConfig
if ($cfg.Source) {
try {
Invoke-Update -Source $cfg.Source -User $cfg.User -Pass $cfg.Pass -LocalVersion $local
} catch {
Write-Log "Update skipped (degraded to local version): $_"
}
} else {
Write-Log "No update_source configured - skip update check."
}
$current = Get-LocalVersion
$exe = Join-Path $AppDir $AppExe
if (-not (Test-Path -LiteralPath $exe)) {
Write-Log "ERROR: executable not found: $exe"
exit 1
}
if ($NoLaunch) {
Write-Log "NoLaunch: would start $exe v$current (CMBOT_DATA_DIR=$DataDir)"
exit 0
}
Write-Log "Launching v$current ..."
$env:CMBOT_DATA_DIR = $DataDir
Start-Process -FilePath $exe
+1 -1
View File
@@ -935,7 +935,7 @@
- [x] `get_data_dir()` 三级回退:`CMBOT_DATA_DIR` → 打包态 `~/.cmbot` → 开发态项目根;`tests/test_file_service.py` 5 个单测
- [x] `src/launcher.py`:复用 `update_service`,下载 zip→SHA-256→解压→`app/app.old` 切换→启动;安装根可写性检测;首次把 `app\config\` 默认模板播种到 `~/.cmbot`;`update_service` 扩展(`UpdateInfo.sha256/size/min_supported`、绝对 url 解析、`make_auth_header`/`download`);`tests/test_launcher.py` 11 个单测 + 本地 HTTP server 真实端到端验证
- [x] `build.ps1` 增产 `Launcher.exe`(PyInstaller onefile,console);发布目录改为便携布局 `Launcher.exe` + `app\`;产出两个 zip——自更新载荷 `CMBot-<ver>.zip`(app\ 内容,manifest.url 指向它)与便携安装包 `CMBot-<ver>-portable.zip`;`launcher.py` 日志初始化健壮化(--windowed/只读根不崩)。语法校验通过;实际 PyInstaller 构建需在 Windows 跑
- [ ] 退休 `scripts/update.ps1` 与 `scripts/install_local.ps1`
- [x] 退休 `scripts/update.ps1` 与 `scripts/install_local.ps1`(已删除,git 历史可查)
- [ ] 端到端实测(解压到 D 盘运行、自更新、回滚)
### 17.20 设置对话框(更新配置入口)