diff --git a/scripts/update.ps1 b/scripts/update.ps1 index 11591a2..a64c0ee 100644 --- a/scripts/update.ps1 +++ b/scripts/update.ps1 @@ -1,23 +1,21 @@ <# .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 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. + 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: 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). + 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 current.txt, versions\, data\, staging\. + Install directory holding Launcher.exe, app\, app.old\, data\, staging\. Defaults to the launcher's own folder. .PARAMETER NoLaunch @@ -33,9 +31,10 @@ $ErrorActionPreference = "Stop" $AppExe = "CMBot.exe" $ManifestName = "manifest.json" +$VersionFileName = "version.txt" -$CurrentTxt = Join-Path $InstallRoot "current.txt" -$VersionsDir = Join-Path $InstallRoot "versions" +$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" @@ -49,21 +48,31 @@ function Write-Log { } function Get-LocalVersion { - if (Test-Path -LiteralPath $CurrentTxt) { - return (Get-Content -LiteralPath $CurrentTxt -Raw).TrimStart([char]0xFEFF).Trim() + $versionFile = Join-Path $AppDir $VersionFileName + if (Test-Path -LiteralPath $versionFile) { + return (Get-Content -LiteralPath $versionFile -Raw).TrimStart([char]0xFEFF).Trim() } return "" } -function Get-UpdateSource { - if (-not (Test-Path -LiteralPath $ConfigFile)) { 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 - return [string]$cfg.update_source + $result.Source = [string]$cfg.update_source + $result.User = [string]$cfg.update_user + $result.Pass = [string]$cfg.update_pass } catch { - Write-Log "Cannot read update_source: $_" - return "" + Write-Log "Cannot read update config: $_" } + return $result } function ConvertTo-VersionParts { @@ -90,21 +99,145 @@ function Test-IsNewer { 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 +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 { - $m = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + Move-Item -LiteralPath $PackageRoot -Destination $AppDir } catch { - Write-Log "Manifest unreadable - skip update: $_" - return + 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) { @@ -116,56 +249,54 @@ function Invoke-Update { 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 + $releaseField = [string]$m.url + $expectedHash = ([string]$m.sha256).Trim().ToLowerInvariant() + if (-not $releaseField -or -not $expectedHash) { + throw "manifest must contain url and sha256" } - $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) - $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 + $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 $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)" + 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)" + } } - # 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" + $actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $expectedHash) { + throw "sha256 mismatch" } - # 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 + 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 ────────────────────────────────────────────────────────────────────── +# -- 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 $DataDir | Out-Null $local = Get-LocalVersion -$source = Get-UpdateSource +$cfg = Get-UpdateConfig -if ($source) { +if ($cfg.Source) { try { - Invoke-Update -Source $source -LocalVersion $local + Invoke-Update -Source $cfg.Source -User $cfg.User -Pass $cfg.Pass -LocalVersion $local } catch { Write-Log "Update skipped (degraded to local version): $_" } @@ -174,19 +305,14 @@ if ($source) { } $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 +$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 (CMBOT_DATA_DIR=$DataDir)" + Write-Log "NoLaunch: would start $exe v$current (CMBOT_DATA_DIR=$DataDir)" exit 0 }