224 lines
13 KiB
PowerShell
224 lines
13 KiB
PowerShell
param(
|
|
[ValidateSet('Check','Install')][string]$Mode = 'Check',
|
|
[string]$CurrentVersion, [string]$ExpectedVersion,
|
|
[string]$InstallDirectory, [string]$WorkDirectory,
|
|
[int]$ParentProcessId, [string]$ReadyEvent,
|
|
[switch]$Library
|
|
)
|
|
$ErrorActionPreference = 'Stop'
|
|
Set-StrictMode -Version Latest
|
|
|
|
function Get-StableVersion([string]$Tag) {
|
|
if ($Tag -notmatch '^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
|
try { return [version]($Tag -replace '^v','') } catch { return $null }
|
|
}
|
|
function Assert-ReleaseUrl([string]$Url) {
|
|
$uri = [uri]$Url
|
|
if (!$uri.IsAbsoluteUri -or $uri.Scheme -ne 'https' -or $uri.Host -ne 'gitea.hrynco.com' -or
|
|
!$uri.IsDefaultPort -or $uri.UserInfo -or $uri.Query -or $uri.Fragment -or
|
|
!$uri.AbsolutePath.StartsWith('/hrynco/hrynco-kswitcher/releases/download/',[StringComparison]::Ordinal)) {
|
|
throw 'Release asset URL is outside the KSwitcher repository.'
|
|
}
|
|
}
|
|
function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
|
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
|
$request = [Net.HttpWebRequest]::Create($Url)
|
|
$request.AllowAutoRedirect = $false
|
|
$request.Timeout = 15000
|
|
$request.ReadWriteTimeout = 15000
|
|
$request.UserAgent = 'KSwitcher-Updater'
|
|
$response = $null; $inputStream = $null; $outputStream = $null
|
|
try {
|
|
$response = $request.GetResponse()
|
|
if ([int]$response.StatusCode -ne 200 -or $response.ContentLength -gt $Limit) { throw 'Invalid or oversized server response.' }
|
|
$inputStream = $response.GetResponseStream()
|
|
$outputStream = [IO.File]::Create($Path)
|
|
$buffer = New-Object byte[] 65536
|
|
$total = 0L
|
|
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
|
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
|
|
$total += $count
|
|
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
|
|
$outputStream.Write($buffer,0,$count)
|
|
}
|
|
} finally {
|
|
if ($outputStream) { $outputStream.Dispose() }
|
|
if ($inputStream) { $inputStream.Dispose() }
|
|
if ($response) { $response.Dispose() }
|
|
}
|
|
}
|
|
function Select-Release($Releases,[version]$Current) {
|
|
$candidates = @($Releases | Where-Object { !$_.draft -and !$_.prerelease -and (Get-StableVersion $_.tag_name) } |
|
|
Sort-Object { Get-StableVersion $_.tag_name } -Descending)
|
|
if (!$candidates.Count -or (Get-StableVersion $candidates[0].tag_name) -le $Current) { return $null }
|
|
$release = $candidates[0]
|
|
$zip = @($release.assets | Where-Object name -CEQ 'KSwitcher-win-x64.zip')
|
|
$sum = @($release.assets | Where-Object name -CEQ 'SHA256SUMS.txt')
|
|
if ($zip.Count -ne 1 -or $sum.Count -ne 1) { throw 'The newest release lacks its x64 ZIP or SHA256SUMS.txt.' }
|
|
Assert-ReleaseUrl $zip[0].browser_download_url
|
|
Assert-ReleaseUrl $sum[0].browser_download_url
|
|
return @{ version=$release.tag_name; zip=$zip[0].browser_download_url; checksum=$sum[0].browser_download_url }
|
|
}
|
|
function Assert-Checksum([string]$Archive,[string]$Checksum) {
|
|
$matchesFound = @([IO.File]::ReadAllLines($Checksum) | Where-Object { $_ -cmatch '^([a-fA-F0-9]{64})\s+\*?KSwitcher-win-x64\.zip$' })
|
|
if ($matchesFound.Count -ne 1) { throw 'Missing or ambiguous archive checksum.' }
|
|
$expected = ($matchesFound[0] -split '\s+')[0]
|
|
# Do not depend on PowerShell module autoloading: hosts can inherit a
|
|
# PowerShell 7 PSModulePath while this helper runs in Windows PowerShell 5.1.
|
|
$stream = [IO.File]::OpenRead($Archive)
|
|
$sha = [Security.Cryptography.SHA256]::Create()
|
|
try { $actual = [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','') }
|
|
finally { $sha.Dispose(); $stream.Dispose() }
|
|
if ($actual -ne $expected) { throw 'Release checksum verification failed.' }
|
|
}
|
|
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
|
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
|
|
try {
|
|
$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
|
$total = 0L
|
|
foreach ($entry in $zip.Entries) {
|
|
$name = $entry.FullName.Replace('\','/')
|
|
if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue }
|
|
if ($name -cnotmatch '^(KSwitcher(?:\.Config)?\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or
|
|
$name.EndsWith('/.') -or $name.EndsWith('/..') -or $name.EndsWith('.') -or
|
|
(($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($name)) {
|
|
throw "Unexpected or unsafe archive entry: $name"
|
|
}
|
|
$total += $entry.Length
|
|
if ($total -gt 100MB -or $zip.Entries.Count -gt 100) { throw 'Release expands beyond allowed limits.' }
|
|
$destinationFile = Join-Path $Destination $name
|
|
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destinationFile)) | Out-Null
|
|
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry,$destinationFile,$false)
|
|
}
|
|
foreach ($required in @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md')) {
|
|
if (!$seen.Contains($required)) { throw "Incomplete release: $required" }
|
|
}
|
|
if ($Expected -gt [version]'0.2.3' -and !$seen.Contains('KSwitcher.Config.exe')) {
|
|
throw 'Incomplete release: KSwitcher.Config.exe'
|
|
}
|
|
} finally { $zip.Dispose() }
|
|
foreach ($binary in @('KSwitcher.exe','KSwitcher.Config.exe')) {
|
|
$exe = Join-Path $Destination $binary
|
|
if (!(Test-Path -LiteralPath $exe)) { continue }
|
|
$version = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe)
|
|
if ($version.ProductName -ne 'KSwitcher' -or (Get-StableVersion $version.FileVersion) -ne $Expected) {
|
|
throw 'Executable identity/version does not match the release.'
|
|
}
|
|
$bytes = [IO.File]::ReadAllBytes($exe)
|
|
if ($bytes.Length -lt 64 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) { throw 'Invalid release executable.' }
|
|
$pe = [BitConverter]::ToInt32($bytes,60)
|
|
if ($pe -lt 64 -or $pe -gt $bytes.Length-6 -or [BitConverter]::ToUInt32($bytes,$pe) -ne 0x4550 -or
|
|
[BitConverter]::ToUInt16($bytes,$pe+4) -ne 0x8664) { throw 'Release is not a Windows x64 executable.' }
|
|
}
|
|
}
|
|
function Assert-PlainDirectory([string]$Directory) {
|
|
$item = Get-Item -LiteralPath $Directory -Force
|
|
while ($item) {
|
|
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Update directory must not contain junctions or symbolic links.' }
|
|
$item = $item.Parent
|
|
}
|
|
}
|
|
function Install-Files([string]$Stage,[string]$Target,[string]$Backup,[Collections.Generic.List[string]]$Changed) {
|
|
Assert-PlainDirectory $Target
|
|
foreach ($file in @(Get-ChildItem -LiteralPath $Stage -File -Recurse)) {
|
|
$relative = $file.FullName.Substring($Stage.Length+1)
|
|
$destination = Join-Path $Target $relative
|
|
if ($relative.StartsWith('dictionaries\') -and (Test-Path -LiteralPath $destination)) { continue }
|
|
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
|
|
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($destination))
|
|
if (Test-Path -LiteralPath $destination) {
|
|
if ((Get-Item -LiteralPath $destination -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Cannot replace a linked file.' }
|
|
$backupFile = Join-Path $Backup $relative
|
|
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($backupFile)) | Out-Null
|
|
Move-Item -LiteralPath $destination -Destination $backupFile
|
|
}
|
|
$Changed.Add($relative)
|
|
Move-Item -LiteralPath $file.FullName -Destination $destination
|
|
}
|
|
}
|
|
function Undo-Files([string]$Target,[string]$Backup,[Collections.Generic.List[string]]$Changed) {
|
|
for ($i=$Changed.Count-1; $i -ge 0; --$i) {
|
|
$destination = Join-Path $Target $Changed[$i]
|
|
if (Test-Path -LiteralPath $destination) { Remove-Item -LiteralPath $destination -Force }
|
|
$original = Join-Path $Backup $Changed[$i]
|
|
if (Test-Path -LiteralPath $original) { Move-Item -LiteralPath $original -Destination $destination }
|
|
}
|
|
}
|
|
function Write-Result($Value) {
|
|
$Value | ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $WorkDirectory 'result.json') -Encoding UTF8
|
|
}
|
|
if ($Library) { return }
|
|
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
|
|
$changed = [Collections.Generic.List[string]]::new()
|
|
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null
|
|
try {
|
|
$current = Get-StableVersion $CurrentVersion
|
|
if (!$current) { throw 'Invalid installed version.' }
|
|
if ($Mode -eq 'Install') {
|
|
$parentProcess = Get-Process -Id $ParentProcessId
|
|
if ([IO.Path]::GetFullPath($parentProcess.Path) -ne [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.exe'))) {
|
|
throw 'The running executable does not match the update target.'
|
|
}
|
|
$ready = [Threading.EventWaitHandle]::OpenExisting($ReadyEvent)
|
|
}
|
|
$metadata = Join-Path $WorkDirectory 'releases.json'
|
|
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' $metadata 2MB
|
|
$release = Select-Release (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json) $current
|
|
if (!$release) { Write-Result @{state='current'}; exit 0 }
|
|
if ($Mode -eq 'Check') { Write-Result @{state='available';version=$release.version}; exit 0 }
|
|
if ($ExpectedVersion -ne $release.version) { throw 'Release changed since checking. Please select Update again.' }
|
|
Assert-PlainDirectory $InstallDirectory
|
|
$transaction = Join-Path $InstallDirectory ('.kswitcher-update-'+[guid]::NewGuid().ToString('N'))
|
|
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
|
|
[IO.Directory]::CreateDirectory($stage) | Out-Null
|
|
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt'
|
|
Receive-File $release.zip $archive 50MB
|
|
Receive-File $release.checksum $checksum 64KB
|
|
Assert-Checksum $archive $checksum
|
|
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
|
Write-Result @{state='prepared';version=$release.version}
|
|
$ready.Set() | Out-Null
|
|
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
|
$parentExited = $true
|
|
Install-Files $stage $InstallDirectory $backup $changed
|
|
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
|
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
|
|
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
|
|
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
|
|
$success = $true
|
|
} catch {
|
|
$message = $_.Exception.Message
|
|
if ($parentExited) {
|
|
try {
|
|
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
|
|
Undo-Files $InstallDirectory $backup $changed
|
|
Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -WorkingDirectory $InstallDirectory -WindowStyle Hidden
|
|
$message += "`nThe previous version has been restored and restarted."
|
|
} catch {
|
|
$preserveBackup = $true
|
|
$message += "`nRecovery needs attention: $($_.Exception.Message)`nBackup: $backup"
|
|
}
|
|
}
|
|
Write-Result @{state='error';error=$message}
|
|
if ($ready -and $ready.WaitOne(0)) {
|
|
Add-Type -AssemblyName System.Windows.Forms
|
|
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
|
|
}
|
|
exit 1
|
|
} finally {
|
|
if ($ready) { $ready.Dispose() }
|
|
if ($started) { $started.Dispose() }
|
|
if ($parentProcess) { $parentProcess.Dispose() }
|
|
# Only remove the uniquely created transaction directory directly under the install directory.
|
|
if ($transaction -and !$preserveBackup -and [IO.Path]::GetDirectoryName($transaction) -eq [IO.Path]::GetFullPath($InstallDirectory).TrimEnd('\') -and (Test-Path -LiteralPath $transaction)) {
|
|
Remove-Item -LiteralPath $transaction -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
if ($success) {
|
|
# The script is loaded in memory; its unique temporary working directory is no longer needed.
|
|
if ((Split-Path -Leaf $WorkDirectory) -like 'KSwitcher-update-*' -and [IO.Path]::GetDirectoryName($WorkDirectory) -eq [IO.Path]::GetTempPath().TrimEnd('\')) {
|
|
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
}
|