505 lines
31 KiB
PowerShell
505 lines
31 KiB
PowerShell
param(
|
|
[ValidateSet('Check','Install')][string]$Mode = 'Check',
|
|
[string]$CurrentVersion, [string]$ExpectedVersion,
|
|
[string]$InstallDirectory, [string]$WorkDirectory,
|
|
[int]$ParentProcessId, [string]$ReadyEvent,
|
|
[switch]$ShowProgress,
|
|
[switch]$Library
|
|
)
|
|
$ErrorActionPreference = 'Stop'
|
|
Set-StrictMode -Version Latest
|
|
$script:progressWindow = $null
|
|
|
|
function Start-UpdateProgress {
|
|
Add-Type -AssemblyName System.Windows.Forms
|
|
Add-Type -AssemblyName System.Drawing
|
|
if (!('KSwitcherUpdateProgress' -as [type])) {
|
|
Add-Type -ReferencedAssemblies System.Windows.Forms,System.Drawing -TypeDefinition @'
|
|
using System;
|
|
using System.Drawing;
|
|
using System.Threading;
|
|
using System.Windows.Forms;
|
|
using System.Drawing.Drawing2D;
|
|
using System.Runtime.InteropServices;
|
|
public sealed class UpdateMeter : Control {
|
|
public int Percent = -1;
|
|
private int position;
|
|
private readonly System.Windows.Forms.Timer timer = new System.Windows.Forms.Timer { Interval = 35 };
|
|
public UpdateMeter() {
|
|
DoubleBuffered = true;
|
|
timer.Tick += delegate { if (Percent < 0) { position = (position + 5) % Math.Max(1,Width); Invalidate(); } };
|
|
timer.Start();
|
|
}
|
|
protected override void OnPaint(PaintEventArgs e) {
|
|
using(var track = new SolidBrush(Color.FromArgb(232,234,241))) e.Graphics.FillRectangle(track,0,0,Width,Height);
|
|
using(var fill = new SolidBrush(Color.FromArgb(79,70,229))) {
|
|
if(Percent < 0) e.Graphics.FillRectangle(fill,position-Width/4,0,Width/4,Height);
|
|
else e.Graphics.FillRectangle(fill,0,0,Width*Percent/100,Height);
|
|
}
|
|
}
|
|
protected override void Dispose(bool disposing) { if(disposing) timer.Dispose(); base.Dispose(disposing); }
|
|
}
|
|
public sealed class UpdateSteps : Control {
|
|
public int Current = -1;
|
|
public bool Complete;
|
|
public UpdateSteps() { DoubleBuffered = true; }
|
|
protected override void OnPaint(PaintEventArgs e) {
|
|
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
|
string[] names = {"Download","Verify","Install","Restart"};
|
|
for(int i=0;i<4;i++) {
|
|
int x = i*(Width-80)/3;
|
|
Color color = Complete || i<=Current ? Color.FromArgb(79,70,229) : Color.FromArgb(135,141,158);
|
|
using(var pen = new Pen(Color.FromArgb(231,233,240)))
|
|
if(i<3) e.Graphics.DrawLine(pen,x+22,8,x+(Width-80)/3-10,8);
|
|
using(var brush = new SolidBrush(color)) {
|
|
if(Complete || i<Current) {
|
|
e.Graphics.FillEllipse(brush,x,0,16,16);
|
|
using(var pen = new Pen(Color.White,1.6f)) e.Graphics.DrawLines(pen,new Point[]{new Point(x+4,8),new Point(x+7,11),new Point(x+12,5)});
|
|
} else {
|
|
using(var pen = new Pen(color,i==Current?2:1)) e.Graphics.DrawEllipse(pen,x+1,1,14,14);
|
|
if(i==Current) e.Graphics.FillEllipse(brush,x+5,5,6,6);
|
|
}
|
|
e.Graphics.DrawString(names[i],Font,brush,x,26);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
public sealed class KSwitcherUpdateProgress : IDisposable {
|
|
private Form form;
|
|
private Label stage, detail, percentage, footer;
|
|
private UpdateMeter progress;
|
|
private UpdateSteps steps;
|
|
private Button done, close;
|
|
private bool closing;
|
|
private Exception failure;
|
|
private readonly ManualResetEvent ready = new ManualResetEvent(false);
|
|
private readonly Thread thread;
|
|
[DllImport("user32.dll")] private static extern bool ReleaseCapture();
|
|
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr h,int m,IntPtr w,IntPtr l);
|
|
[DllImport("dwmapi.dll")] private static extern int DwmSetWindowAttribute(IntPtr h,int a,ref int v,int size);
|
|
private void Drag(object sender,MouseEventArgs e) { if(e.Button==MouseButtons.Left) { ReleaseCapture(); SendMessage(form.Handle,0xA1,(IntPtr)2,IntPtr.Zero); } }
|
|
public KSwitcherUpdateProgress() {
|
|
thread = new Thread(delegate() {
|
|
try {
|
|
Application.EnableVisualStyles();
|
|
form = new Form { Text = "KSwitcher Update", ClientSize = new Size(540,350), BackColor = Color.White,
|
|
ForeColor = Color.FromArgb(32,36,48), StartPosition = FormStartPosition.CenterScreen,
|
|
FormBorderStyle = FormBorderStyle.None, Font = new Font("Segoe UI",10), AutoScaleMode = AutoScaleMode.Dpi };
|
|
var logo = new Panel { Location = new Point(28,24), Size = new Size(34,34), BackColor = Color.FromArgb(79,70,229) };
|
|
logo.Paint += delegate(object sender,PaintEventArgs e) {
|
|
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
|
using(var pen = new Pen(Color.White,4)) { e.Graphics.DrawLine(pen,10,8,10,26); e.Graphics.DrawLine(pen,11,17,25,7); }
|
|
using(var pen = new Pen(Color.FromArgb(94,234,212),4)) e.Graphics.DrawLine(pen,12,17,25,27);
|
|
};
|
|
var brand = new Label { Text = "KSwitcher", Location = new Point(74,22), Size = new Size(260,24), Font = new Font("Segoe UI Semibold",11) };
|
|
var caption = new Label { Text = "Application update", Location = new Point(74,45), Size = new Size(260,20), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
|
|
var minimize = new Button { Text = "\u2013", AccessibleName = "Minimize", Location = new Point(452,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, TabStop = false };
|
|
minimize.FlatAppearance.BorderSize = 0; minimize.Click += delegate { form.WindowState = FormWindowState.Minimized; };
|
|
close = new Button { Text = "\u00d7", AccessibleName = "Close", Location = new Point(490,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, Enabled = false };
|
|
close.FlatAppearance.BorderSize = 0; close.Click += delegate { form.Close(); };
|
|
stage = new Label { Text = "Checking for updates", Location = new Point(28,98), Size = new Size(414,36), Font = new Font("Segoe UI Semibold",20) };
|
|
percentage = new Label { Location = new Point(440,104), Size = new Size(72,30), TextAlign = ContentAlignment.MiddleRight, ForeColor = Color.FromArgb(79,70,229), Font = new Font("Segoe UI Semibold",16) };
|
|
detail = new Label { Text = "Connecting to the release server", Location = new Point(28,148), Size = new Size(484,42), ForeColor = Color.FromArgb(111,117,134) };
|
|
progress = new UpdateMeter { Location = new Point(28,202), Size = new Size(484,6), AccessibleName = "Update progress" };
|
|
steps = new UpdateSteps { Location = new Point(28,230), Size = new Size(484,48), Font = new Font("Segoe UI",9) };
|
|
var bottom = new Panel { Location = new Point(1,296), Size = new Size(538,53), BackColor = Color.FromArgb(247,248,251) };
|
|
footer = new Label { Text = "Your settings and history stay with you.", Location = new Point(27,18), Size = new Size(350,22), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
|
|
done = new Button { Text = "Done", Location = new Point(410,9), Size = new Size(100,34), BackColor = Color.FromArgb(79,70,229), ForeColor = Color.White, FlatStyle = FlatStyle.Flat, Visible = false };
|
|
done.FlatAppearance.BorderSize = 0; done.Click += delegate { form.Close(); };
|
|
bottom.Controls.AddRange(new Control[]{footer,done});
|
|
form.Controls.AddRange(new Control[]{logo,brand,caption,minimize,close,stage,percentage,detail,progress,steps,bottom});
|
|
form.MouseDown += Drag; logo.MouseDown += Drag; brand.MouseDown += Drag; caption.MouseDown += Drag;
|
|
form.Paint += delegate(object sender,PaintEventArgs e) { using(var pen = new Pen(Color.FromArgb(220,223,232))) e.Graphics.DrawRectangle(pen,0,0,form.ClientSize.Width-1,form.ClientSize.Height-1); };
|
|
form.FormClosing += delegate(object sender, FormClosingEventArgs e) { if (!closing) e.Cancel = true; };
|
|
form.Shown += delegate { int rounded=2; try { DwmSetWindowAttribute(form.Handle,33,ref rounded,4); } catch { } ready.Set(); };
|
|
Application.Run(form);
|
|
} catch(Exception e) { failure = e; ready.Set(); }
|
|
});
|
|
thread.IsBackground = true;
|
|
thread.SetApartmentState(ApartmentState.STA);
|
|
thread.Start();
|
|
if (!ready.WaitOne(10000)) throw new Exception("Update progress window did not start.");
|
|
if (failure != null) throw new Exception("Cannot display update progress.",failure);
|
|
}
|
|
public void Report(string title, string text, int percent) {
|
|
if (form == null || form.IsDisposed) return;
|
|
form.BeginInvoke((Action)delegate {
|
|
stage.Text = title; detail.Text = text;
|
|
progress.Percent = percent < 0 ? -1 : Math.Max(0,Math.Min(100,percent));
|
|
percentage.Text = percent < 0 ? "" : progress.Percent+"%";
|
|
int separator = text.IndexOf("% - ");
|
|
if(separator>=0) detail.Text = text.Substring(separator+4)+" downloaded";
|
|
if(title.StartsWith("Downloading")) steps.Current=0;
|
|
else if(title.StartsWith("Verifying") || title.StartsWith("Preparing")) steps.Current=1;
|
|
else if(title.StartsWith("Installing") || title.StartsWith("Closing")) steps.Current=2;
|
|
else if(title.StartsWith("Restarting")) steps.Current=3;
|
|
progress.Invalidate(); steps.Invalidate();
|
|
});
|
|
}
|
|
public void SavePreview(string path) {
|
|
form.Invoke((Action)delegate {
|
|
using(var bitmap = new Bitmap(form.Width,form.Height)) {
|
|
form.DrawToBitmap(bitmap,new Rectangle(0,0,bitmap.Width,bitmap.Height));
|
|
bitmap.Save(path,System.Drawing.Imaging.ImageFormat.Png);
|
|
}
|
|
});
|
|
}
|
|
public string Status { get { return (string)form.Invoke((Func<string>)delegate { return stage.Text; }); } }
|
|
public int Percent { get { return (int)form.Invoke((Func<int>)delegate { return progress.Percent; }); } }
|
|
public void Finish(string title, string text, bool success) {
|
|
form.Invoke((Action)delegate {
|
|
closing = true;
|
|
stage.Text = title; detail.Text = text;
|
|
progress.Percent = success ? 100 : 0; percentage.Text = success ? "100%" : "";
|
|
steps.Complete = success;
|
|
if(!success) {
|
|
detail.Visible = false; steps.Visible = false;
|
|
var result = new TextBox { Text = text, Location = new Point(28,148), Size = new Size(484,126),
|
|
Multiline = true, ReadOnly = true, BorderStyle = BorderStyle.None,
|
|
BackColor = Color.White, ScrollBars = ScrollBars.Vertical };
|
|
form.Controls.Add(result); result.BringToFront(); progress.Visible = false;
|
|
}
|
|
footer.Text = success ? "Your settings and history are preserved." : "Review the details above.";
|
|
close.Enabled = true; done.Visible = true;
|
|
form.AcceptButton = done; form.CancelButton = done;
|
|
progress.Invalidate(); steps.Invalidate();
|
|
});
|
|
}
|
|
public void WaitForClose() { thread.Join(); }
|
|
public void Dispose() {
|
|
if (form != null && !form.IsDisposed)
|
|
form.BeginInvoke((Action)delegate { closing = true; form.Close(); });
|
|
thread.Join(3000);
|
|
}
|
|
}
|
|
'@
|
|
}
|
|
$script:progressWindow = New-Object KSwitcherUpdateProgress
|
|
}
|
|
function Show-UpdateProgress([string]$Stage,[string]$Detail,[int]$Percent = -1) {
|
|
if ($script:progressWindow) {
|
|
try { $script:progressWindow.Report($Stage,$Detail,$Percent) }
|
|
catch { Write-Warning 'Could not refresh update progress.' }
|
|
}
|
|
}
|
|
function Complete-UpdateProgress([string]$Title,[string]$Detail,[bool]$Success) {
|
|
if ($script:progressWindow) {
|
|
try { $script:progressWindow.Finish($Title,$Detail,$Success); return $true }
|
|
catch { Write-Warning 'Could not display the update result.' }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Get-StableVersion([string]$Tag) {
|
|
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
|
try { return [version]($Tag -replace '^(?:release-)?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,[string]$ProgressLabel = '') {
|
|
[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)
|
|
$lastProgress = [DateTime]::MinValue
|
|
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)
|
|
if ($ProgressLabel -and ([DateTime]::UtcNow - $lastProgress).TotalMilliseconds -ge 100) {
|
|
$percent = if ($response.ContentLength -gt 0) { [int][Math]::Min(100,100.0*$total/$response.ContentLength) } else { -1 }
|
|
$detail = if ($percent -ge 0) { '{0}% - {1:N1} / {2:N1} MB' -f $percent,($total/1MB),($response.ContentLength/1MB) } else { '{0:N1} MB received' -f ($total/1MB) }
|
|
Show-UpdateProgress $ProgressLabel $detail $percent
|
|
$lastProgress = [DateTime]::UtcNow
|
|
}
|
|
}
|
|
if ($response.ContentLength -ge 0 -and $total -ne $response.ContentLength) { throw 'The download ended before the complete file was received.' }
|
|
if ($ProgressLabel) { Show-UpdateProgress $ProgressLabel ('Downloaded {0:N1} MB' -f ($total/1MB)) 100 }
|
|
} 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-LegacyRelease([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|Screenshot))?\.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','KSwitcher.Screenshot.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 Get-Sha256([IO.Stream]$Stream) {
|
|
$sha=[Security.Cryptography.SHA256]::Create()
|
|
try { return [BitConverter]::ToString($sha.ComputeHash($Stream)).Replace('-','').ToLowerInvariant() }
|
|
finally { $sha.Dispose() }
|
|
}
|
|
function Assert-PackagePath([string]$Name) {
|
|
if (!$Name -or $Name.Length -gt 220 -or $Name -match '[\\:<>"|?*]' -or $Name.StartsWith('/')) { throw "Unsafe package path: $Name" }
|
|
$parts=$Name.Split('/')
|
|
foreach ($part in $parts) {
|
|
if (!$part -or $part -in @('.','..') -or $part -match '[. ]$|[\x00-\x1f]' -or
|
|
$part -match '^(?i:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\.|$)') { throw "Unsafe package path: $Name" }
|
|
}
|
|
if ($parts[0] -ieq 'data' -or $parts[0] -like '.kswitcher-*') { throw "Protected user path: $Name" }
|
|
}
|
|
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
|
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
$zip=[IO.Compression.ZipFile]::OpenRead($Archive)
|
|
try {
|
|
$manifests=@($zip.Entries | Where-Object FullName -CEQ 'release-files.json')
|
|
if (!$manifests.Count) {
|
|
if ($Expected -gt [version]'0.4.2') { throw 'Release manifest is missing.' }
|
|
$zip.Dispose(); $zip=$null
|
|
Expand-LegacyRelease $Archive $Destination $Expected
|
|
return
|
|
}
|
|
if ($manifests.Count -ne 1 -or $manifests[0].Length -gt 256KB) { throw 'Invalid release manifest.' }
|
|
$reader=[IO.StreamReader]::new($manifests[0].Open())
|
|
try { $manifest=$reader.ReadToEnd() | ConvertFrom-Json } finally { $reader.Dispose() }
|
|
if ($manifest.schema -ne 1 -or (Get-StableVersion $manifest.version) -ne $Expected -or
|
|
@($manifest.files).Count -lt 1 -or @($manifest.files).Count -gt 1000) { throw 'Unsupported or mismatched release manifest.' }
|
|
$declared=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase)
|
|
foreach ($file in $manifest.files) {
|
|
Assert-PackagePath $file.path
|
|
if ($file.path -ieq 'release-files.json' -or $declared.ContainsKey($file.path) -or
|
|
$file.sha256 -notmatch '^[a-fA-F0-9]{64}$' -or $file.size -lt 0 -or $file.size -gt 100MB) { throw 'Invalid manifest file entry.' }
|
|
$declared.Add($file.path,$file)
|
|
}
|
|
if (!$declared.ContainsKey('KSwitcher.exe')) { throw 'Main executable is missing from the manifest.' }
|
|
$seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
|
$total=0L
|
|
foreach ($entry in $zip.Entries) {
|
|
$name=$entry.FullName.Replace('\','/')
|
|
if ($name.EndsWith('/')) { Assert-PackagePath $name.TrimEnd('/'); continue }
|
|
Assert-PackagePath $name
|
|
if (!$seen.Add($name) -or (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000) { throw 'Duplicate or linked archive entry.' }
|
|
if ($name -ceq 'release-files.json') { continue }
|
|
if (!$declared.ContainsKey($name)) { throw "Undeclared package file: $name" }
|
|
$file=$declared[$name]
|
|
if ($entry.Length -ne $file.size) { throw "Package file size mismatch: $name" }
|
|
$total+=$entry.Length
|
|
if ($total -gt 100MB -or $zip.Entries.Count -gt 1100) { throw 'Release expands beyond allowed limits.' }
|
|
$stream=$entry.Open()
|
|
try { $hash=Get-Sha256 $stream } finally { $stream.Dispose() }
|
|
if ($hash -ne $file.sha256) { throw "Package file checksum mismatch: $name" }
|
|
}
|
|
if ($seen.Count -ne $declared.Count+1) { throw 'Archive does not contain every manifest file.' }
|
|
[IO.Directory]::CreateDirectory($Destination) | Out-Null
|
|
Assert-PlainDirectory $Destination
|
|
foreach ($entry in $zip.Entries) {
|
|
if ($entry.FullName.EndsWith('/')) { continue }
|
|
$target=Join-Path $Destination $entry.FullName
|
|
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($target)) | Out-Null
|
|
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($target))
|
|
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry,$target,$false)
|
|
}
|
|
$info=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $Destination 'KSwitcher.exe'))
|
|
if ($info.ProductName -ne 'KSwitcher' -or (Get-StableVersion $info.FileVersion) -ne $Expected) { throw 'Main executable identity/version mismatch.' }
|
|
} finally { if ($zip) { $zip.Dispose() } }
|
|
}
|
|
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\',[StringComparison]::OrdinalIgnoreCase) -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; $uiFinished = $false
|
|
try {
|
|
if ($ShowProgress -or $Mode -eq 'Install') { Start-UpdateProgress }
|
|
$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'
|
|
Show-UpdateProgress 'Downloading update' $release.version 0
|
|
Receive-File $release.zip $archive 50MB 'Downloading update'
|
|
Show-UpdateProgress 'Verifying download' 'Checking the archive SHA-256 checksum'
|
|
Receive-File $release.checksum $checksum 64KB
|
|
Assert-Checksum $archive $checksum
|
|
Show-UpdateProgress 'Preparing installation' 'Validating and unpacking the release files'
|
|
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
|
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
|
|
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
|
|
Write-Result @{state='prepared';version=$release.version}
|
|
Show-UpdateProgress 'Closing KSwitcher' 'Waiting for the running application to exit safely'
|
|
$ready.Set() | Out-Null
|
|
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
|
$parentExited = $true
|
|
Show-UpdateProgress 'Installing update' 'Replacing application files; keeping your personal data'
|
|
Install-Files $stage $InstallDirectory $backup $changed
|
|
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
|
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
|
|
Show-UpdateProgress 'Restarting KSwitcher' 'Waiting for the updated application to confirm startup'
|
|
$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
|
|
if ($script:progressWindow) {
|
|
$uiFinished = Complete-UpdateProgress 'Update completed' "KSwitcher $(Get-StableVersion $release.version) is now running." $true
|
|
}
|
|
} catch {
|
|
$message = $_.Exception.Message
|
|
if ($parentExited) {
|
|
Show-UpdateProgress 'Restoring previous version' 'The update failed; recovering the previous installation'
|
|
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)) {
|
|
if ($script:progressWindow) {
|
|
$uiFinished = Complete-UpdateProgress 'Update failed' $message $false
|
|
} else {
|
|
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
|
|
}
|
|
}
|
|
if ($script:progressWindow) {
|
|
if ($uiFinished) { $script:progressWindow.WaitForClose() }
|
|
$script:progressWindow.Dispose(); $script:progressWindow = $null
|
|
}
|
|
}
|