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] if ((Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash -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\.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" } } } finally { $zip.Dispose() } $exe = Join-Path $Destination 'KSwitcher.exe' $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 } } }