<# .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