Files
cmbot/scripts/update.ps1
T

322 lines
9.8 KiB
PowerShell
Raw Normal View History

<#
.SYNOPSIS
2026-06-18 09:42:45 +08:00
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:
2026-06-18 09:42:45 +08:00
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\.
2026-06-18 09:42:45 +08:00
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
2026-06-18 09:42:45 +08:00
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"
2026-06-18 09:42:45 +08:00
$VersionFileName = "version.txt"
2026-06-18 09:42:45 +08:00
$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 {
2026-06-18 09:42:45 +08:00
$versionFile = Join-Path $AppDir $VersionFileName
if (Test-Path -LiteralPath $versionFile) {
return (Get-Content -LiteralPath $versionFile -Raw).TrimStart([char]0xFEFF).Trim()
}
return ""
}
2026-06-18 09:42:45 +08:00
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
2026-06-18 09:42:45 +08:00
$result.Source = [string]$cfg.update_source
$result.User = [string]$cfg.update_user
$result.Pass = [string]$cfg.update_pass
} catch {
2026-06-18 09:42:45 +08:00
Write-Log "Cannot read update config: $_"
}
2026-06-18 09:42:45 +08:00
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
}
2026-06-18 09:42:45 +08:00
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)
}
2026-06-18 09:42:45 +08:00
return $headers
}
2026-06-18 09:42:45 +08:00
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 {
2026-06-18 09:42:45 +08:00
Move-Item -LiteralPath $PackageRoot -Destination $AppDir
} catch {
2026-06-18 09:42:45 +08:00
if ((-not (Test-Path -LiteralPath $AppDir)) -and (Test-Path -LiteralPath $OldAppDir)) {
Move-Item -LiteralPath $OldAppDir -Destination $AppDir
}
throw
}
2026-06-18 09:42:45 +08:00
}
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
}
2026-06-18 09:42:45 +08:00
$releaseField = [string]$m.url
$expectedHash = ([string]$m.sha256).Trim().ToLowerInvariant()
if (-not $releaseField -or -not $expectedHash) {
throw "manifest must contain url and sha256"
}
2026-06-18 09:42:45 +08:00
$releaseUrl = Resolve-ReleaseUrl -ManifestUrl $manifestUrl -ReleaseUrl $releaseField
2026-06-18 09:42:45 +08:00
$zipPath = Join-Path $StagingDir ("{0}.zip" -f $remoteVer)
$extractDir = Join-Path $StagingDir "app.new"
Remove-PathIfExists -Path $zipPath
Remove-PathIfExists -Path $extractDir
2026-06-18 09:42:45 +08:00
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)"
}
}
2026-06-18 09:42:45 +08:00
$actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $expectedHash) {
throw "sha256 mismatch"
}
2026-06-18 09:42:45 +08:00
Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDir -Force
$packageRoot = Get-PackageRoot -ExtractDir $extractDir
Assert-PackageValid -PackageRoot $packageRoot -ExpectedVersion $remoteVer
2026-06-18 09:42:45 +08:00
Switch-AppDirectory -PackageRoot $packageRoot
Remove-PathIfExists -Path $zipPath
Remove-PathIfExists -Path $extractDir
Write-Log "Installed and switched to v$remoteVer."
}
2026-06-18 09:42:45 +08:00
# -- main --------------------------------------------------------------------
New-Item -ItemType Directory -Force -Path $StagingDir | Out-Null
2026-06-18 09:42:45 +08:00
New-Item -ItemType Directory -Force -Path $DataDir | Out-Null
$local = Get-LocalVersion
2026-06-18 09:42:45 +08:00
$cfg = Get-UpdateConfig
2026-06-18 09:42:45 +08:00
if ($cfg.Source) {
try {
2026-06-18 09:42:45 +08:00
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
2026-06-18 09:42:45 +08:00
$exe = Join-Path $AppDir $AppExe
if (-not (Test-Path -LiteralPath $exe)) {
Write-Log "ERROR: executable not found: $exe"
exit 1
}
if ($NoLaunch) {
2026-06-18 09:42:45 +08:00
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