feat: update launcher over http
This commit is contained in:
+197
-71
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user