feat: update launcher over http

This commit is contained in:
2026-06-18 09:42:45 +08:00
parent 813e569639
commit 6aae8d1bd6
+197 -71
View File
@@ -1,23 +1,21 @@
<# <#
.SYNOPSIS .SYNOPSIS
LAN auto-update launcher for CMBot (docs/10-lan-update.md, stage 3). HTTP auto-update launcher for CMBot (docs/10-lan-update.md, stage 3).
.DESCRIPTION .DESCRIPTION
Run instead of launching CMBot.exe directly. On each start it: Run instead of launching CMBot.exe directly. On each start it:
1. reads the local version from current.txt; 1. reads the local version from app\version.txt;
2. reads update_source from data\config\app_config.json and the remote 2. reads update_source/update_user/update_pass from data\config\app_config.json;
manifest.json there; 3. downloads manifest.json and, when newer, downloads the release zip;
3. if a newer version is advertised, copies it into staging, verifies it, 4. verifies SHA-256, extracts to staging\app.new, and switches app/app.old;
then atomically swaps it into versions\<ver> and flips current.txt; 5. launches app\CMBot.exe with CMBOT_DATA_DIR pointed at data\.
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 Degrades safely for normal updates: network errors, malformed manifests,
copy never blocks startup — the existing local version is launched instead. failed downloads, bad hashes, or bad zips leave the existing app\ version
Never overwrites the running version (new version installs alongside). in place and launch it.
.PARAMETER InstallRoot .PARAMETER InstallRoot
Install directory holding current.txt, versions\, data\, staging\. Install directory holding Launcher.exe, app\, app.old\, data\, staging\.
Defaults to the launcher's own folder. Defaults to the launcher's own folder.
.PARAMETER NoLaunch .PARAMETER NoLaunch
@@ -33,9 +31,10 @@ $ErrorActionPreference = "Stop"
$AppExe = "CMBot.exe" $AppExe = "CMBot.exe"
$ManifestName = "manifest.json" $ManifestName = "manifest.json"
$VersionFileName = "version.txt"
$CurrentTxt = Join-Path $InstallRoot "current.txt" $AppDir = Join-Path $InstallRoot "app"
$VersionsDir = Join-Path $InstallRoot "versions" $OldAppDir = Join-Path $InstallRoot "app.old"
$DataDir = Join-Path $InstallRoot "data" $DataDir = Join-Path $InstallRoot "data"
$StagingDir = Join-Path $InstallRoot "staging" $StagingDir = Join-Path $InstallRoot "staging"
$ConfigFile = Join-Path $DataDir "config\app_config.json" $ConfigFile = Join-Path $DataDir "config\app_config.json"
@@ -49,21 +48,31 @@ function Write-Log {
} }
function Get-LocalVersion { function Get-LocalVersion {
if (Test-Path -LiteralPath $CurrentTxt) { $versionFile = Join-Path $AppDir $VersionFileName
return (Get-Content -LiteralPath $CurrentTxt -Raw).TrimStart([char]0xFEFF).Trim() if (Test-Path -LiteralPath $versionFile) {
return (Get-Content -LiteralPath $versionFile -Raw).TrimStart([char]0xFEFF).Trim()
} }
return "" return ""
} }
function Get-UpdateSource { function Get-UpdateConfig {
if (-not (Test-Path -LiteralPath $ConfigFile)) { return "" } $result = [ordered]@{
Source = ""
User = ""
Pass = ""
}
if (-not (Test-Path -LiteralPath $ConfigFile)) {
return $result
}
try { try {
$cfg = Get-Content -LiteralPath $ConfigFile -Raw | ConvertFrom-Json $cfg = Get-Content -LiteralPath $ConfigFile -Raw | ConvertFrom-Json
return [string]$cfg.update_source $result.Source = [string]$cfg.update_source
$result.User = [string]$cfg.update_user
$result.Pass = [string]$cfg.update_pass
} catch { } catch {
Write-Log "Cannot read update_source: $_" Write-Log "Cannot read update config: $_"
return ""
} }
return $result
} }
function ConvertTo-VersionParts { function ConvertTo-VersionParts {
@@ -90,21 +99,145 @@ function Test-IsNewer {
return $false return $false
} }
function Invoke-Update { function Get-AuthHeaders {
param([string]$Source, [string]$LocalVersion) param(
[string]$User,
$manifestPath = Join-Path $Source $ManifestName [string]$Pass
if (-not (Test-Path -LiteralPath $manifestPath)) { )
Write-Log "No manifest at $manifestPath - skip update." $headers = @{}
return 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 { try {
$m = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json Move-Item -LiteralPath $PackageRoot -Destination $AppDir
} catch { } catch {
Write-Log "Manifest unreadable - skip update: $_" if ((-not (Test-Path -LiteralPath $AppDir)) -and (Test-Path -LiteralPath $OldAppDir)) {
return 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 $remoteVer = [string]$m.version
if (-not $remoteVer) { if (-not $remoteVer) {
@@ -116,56 +249,54 @@ function Invoke-Update {
return return
} }
$srcFiles = [string]$m.source $releaseField = [string]$m.url
if (-not $srcFiles) { $srcFiles = $Source } $expectedHash = ([string]$m.sha256).Trim().ToLowerInvariant()
if (-not (Test-Path -LiteralPath $srcFiles)) { if (-not $releaseField -or -not $expectedHash) {
Write-Log "Version folder not found: $srcFiles - skip update." throw "manifest must contain url and sha256"
return
} }
$marker = if ($m.marker) { [string]$m.marker } else { $AppExe } $releaseUrl = Resolve-ReleaseUrl -ManifestUrl $manifestUrl -ReleaseUrl $releaseField
# 1. copy into a temp staging folder (never into versions\ directly) $zipPath = Join-Path $StagingDir ("{0}.zip" -f $remoteVer)
$stageTmp = Join-Path $StagingDir ("{0}.tmp" -f $remoteVer) $extractDir = Join-Path $StagingDir "app.new"
if (Test-Path -LiteralPath $stageTmp) { Remove-Item -LiteralPath $stageTmp -Recurse -Force } Remove-PathIfExists -Path $zipPath
New-Item -ItemType Directory -Force -Path $stageTmp | Out-Null Remove-PathIfExists -Path $extractDir
Write-Log "Downloading v$remoteVer from $srcFiles ..." Write-Log "Downloading v$remoteVer from $releaseUrl ..."
& robocopy $srcFiles $stageTmp /E /NFL /NDL /NJH /NJS /NP /R:1 /W:1 | Out-Null Save-HttpFile -Url $releaseUrl -Destination $zipPath -Headers $headers
if ($LASTEXITCODE -ge 8) {
Remove-Item -LiteralPath $stageTmp -Recurse -Force -ErrorAction SilentlyContinue if ($m.size) {
throw "robocopy failed (exit $LASTEXITCODE)" $actualSize = (Get-Item -LiteralPath $zipPath).Length
if ($actualSize -ne [int64]$m.size) {
throw "download size mismatch: got $actualSize, expected $($m.size)"
}
} }
# 2. verify completeness via the marker file $actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
if (-not (Test-Path -LiteralPath (Join-Path $stageTmp $marker))) { if ($actualHash -ne $expectedHash) {
Remove-Item -LiteralPath $stageTmp -Recurse -Force -ErrorAction SilentlyContinue throw "sha256 mismatch"
throw "marker '$marker' missing after copy - corrupt download"
} }
# 3. atomic-ish swap: rename staging -> versions\<ver>, then flip pointer Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDir -Force
$verDir = Join-Path $VersionsDir $remoteVer $packageRoot = Get-PackageRoot -ExtractDir $extractDir
if (Test-Path -LiteralPath $verDir) { Remove-Item -LiteralPath $verDir -Recurse -Force } Assert-PackageValid -PackageRoot $packageRoot -ExpectedVersion $remoteVer
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
Switch-AppDirectory -PackageRoot $packageRoot
Remove-PathIfExists -Path $zipPath
Remove-PathIfExists -Path $extractDir
Write-Log "Installed and switched to v$remoteVer." Write-Log "Installed and switched to v$remoteVer."
} }
# ── main ────────────────────────────────────────────────────────────────────── # -- main --------------------------------------------------------------------
New-Item -ItemType Directory -Force -Path $VersionsDir | Out-Null
New-Item -ItemType Directory -Force -Path $StagingDir | Out-Null New-Item -ItemType Directory -Force -Path $StagingDir | Out-Null
New-Item -ItemType Directory -Force -Path $DataDir | Out-Null
$local = Get-LocalVersion $local = Get-LocalVersion
$source = Get-UpdateSource $cfg = Get-UpdateConfig
if ($source) { if ($cfg.Source) {
try { try {
Invoke-Update -Source $source -LocalVersion $local Invoke-Update -Source $cfg.Source -User $cfg.User -Pass $cfg.Pass -LocalVersion $local
} catch { } catch {
Write-Log "Update skipped (degraded to local version): $_" Write-Log "Update skipped (degraded to local version): $_"
} }
@@ -174,19 +305,14 @@ if ($source) {
} }
$current = Get-LocalVersion $current = Get-LocalVersion
if (-not $current) { $exe = Join-Path $AppDir $AppExe
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)) { if (-not (Test-Path -LiteralPath $exe)) {
Write-Log "ERROR: executable not found: $exe" Write-Log "ERROR: executable not found: $exe"
exit 1 exit 1
} }
if ($NoLaunch) { if ($NoLaunch) {
Write-Log "NoLaunch: would start $exe (CMBOT_DATA_DIR=$DataDir)" Write-Log "NoLaunch: would start $exe v$current (CMBOT_DATA_DIR=$DataDir)"
exit 0 exit 0
} }