Release v0.4.2: bridge legacy clients to manifest-based updates

This commit is contained in:
2026-10-28 22:52:51 +02:00
parent d1eabfca88
commit ddef996d0a
8 changed files with 121 additions and 12 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.4.1 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.4.2 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
+10
View File
@@ -346,3 +346,13 @@ It still uses the normal instance lock; close a running KSwitcher first if measu
Application code is MIT licensed; preserves the original [Typlo](https://github.com/bitcodepro/Typlo) attribution in `LICENSE`.
Dictionary data has its own CC BY-SA 4.0 license as described above.
## Compatibility update bridge (v0.4.2)
This two-executable release can be installed by the v0.3.x–v0.4.1 updater.
It upgrades the updater to discover stable `release-vX.Y.Z` tags and validate
manifest-based packages. Use Update again after this version starts to install
the full current release, including the standalone screenshot editor.
The screenshot editor remains embedded only in this transitional release.
Future full releases must use the `release-v` prefix so unmigrated clients
continue to discover v0.4.2 first rather than skip this bridge.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
7c29980c5d3b8862b1d78c06d1c3daa2b2bff24b56d200616971b92d784b51e6 KSwitcher-win-x64.zip
7f0ee55eb5b56a6227431c1951ac2751208d3d0c7c34e4d1d616e180c37b1d9f KSwitcher-win-x64.zip
+3 -3
View File
@@ -6,8 +6,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1"
1 VERSIONINFO
FILEVERSION 0,4,1,0
PRODUCTVERSION 0,4,1,0
FILEVERSION 0,4,2,0
PRODUCTVERSION 0,4,2,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
@@ -17,7 +17,7 @@ BEGIN
BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.4.1"
VALUE "FileVersion", "0.4.2"
END
END
BLOCK "VarFileInfo"
+77 -6
View File
@@ -9,8 +9,8 @@ $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 }
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
@@ -71,7 +71,7 @@ function Assert-Checksum([string]$Archive,[string]$Checksum) {
finally { $sha.Dispose(); $stream.Dispose() }
if ($actual -ne $expected) { throw 'Release checksum verification failed.' }
}
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
function Expand-LegacyRelease([string]$Archive,[string]$Destination,[version]$Expected) {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
try {
@@ -80,7 +80,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
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
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"
@@ -98,7 +98,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
throw 'Incomplete release: KSwitcher.Config.exe'
}
} finally { $zip.Dispose() }
foreach ($binary in @('KSwitcher.exe','KSwitcher.Config.exe')) {
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)
@@ -112,6 +112,75 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
[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) {
@@ -124,7 +193,7 @@ function Install-Files([string]$Stage,[string]$Target,[string]$Backup,[Collectio
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 }
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) {
@@ -177,6 +246,8 @@ try {
Receive-File $release.checksum $checksum 64KB
Assert-Checksum $archive $checksum
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}
$ready.Set() | Out-Null
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.4.1";
inline constexpr wchar_t AppVersion[] = L"0.4.2";
inline constexpr UINT UpdateMessage = WM_APP + 7;
struct UpdateResult
{
+28
View File
@@ -22,6 +22,9 @@ try {
Assert ((Select-Release @($release) ([version]'0.2.0')).version -eq 'v0.2.1') 'newer release'
Assert ($null -eq (Select-Release @($release) ([version]'0.2.1'))) 'same version'
Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade'
Assert ((Get-StableVersion 'release-v0.5.0') -eq [version]'0.5.0') 'modern stable release tag'
$modern=@{draft=$false;prerelease=$false;tag_name='release-v0.5.0';assets=$assets}
Assert ((Select-Release @($release,$modern) ([version]'0.4.2')).version -eq 'release-v0.5.0') 'bridge discovers modern channel'
$draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets}
Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored'
Reject { Select-Release @(@{draft=$false;prerelease=$false;tag_name='v2.0.0';assets=@()}) ([version]'0.2.0') } 'incomplete release rejected'
@@ -34,9 +37,19 @@ try {
if (Test-Path -LiteralPath $configurator) {
Copy-Item -LiteralPath $configurator -Destination (Join-Path $source 'KSwitcher.Config.exe')
}
$screenshot = Join-Path (Split-Path $Executable) 'KSwitcher.Screenshot.exe'
if (Test-Path -LiteralPath $screenshot) { Copy-Item -LiteralPath $screenshot -Destination (Join-Path $source 'KSwitcher.Screenshot.exe') }
foreach ($name in @('LICENSE','json-LICENSE.txt','README.md')) { [IO.File]::WriteAllText((Join-Path $source $name),'fixture') }
[IO.Directory]::CreateDirectory((Join-Path $source 'dictionaries')) | Out-Null
[IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary')
function Write-TestManifest($Folder) {
$entries=@(Get-ChildItem -LiteralPath $Folder -File -Recurse | Where-Object Name -ne 'release-files.json' | ForEach-Object {
@{path=$_.FullName.Substring($Folder.Length+1).Replace('\','/');size=$_.Length;sha256=(Get-FileHash -LiteralPath $_.FullName).Hash}
})
@{schema=1;version=$binaryVersion.ToString();files=$entries} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $Folder 'release-files.json') -Encoding utf8
}
[IO.File]::WriteAllText((Join-Path $source 'future-tool.exe'),'future companion fixture')
Write-TestManifest $source
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive=Join-Path $root 'release.zip'
[IO.Compression.ZipFile]::CreateFromDirectory($source,$archive)
@@ -57,6 +70,19 @@ try {
$z=[IO.Compression.ZipFile]::Open($bad,[IO.Compression.ZipArchiveMode]::Create)
$null=$z.CreateEntry('../escape.exe'); $z.Dispose()
Reject { Expand-Release $bad (Join-Path $root 'badstage') ([version]'0.2.1') } 'zip traversal rejected'
foreach ($unsafe in @('../escape','C:/escape','data/settings.json','DATA/history.bin','folder/../escape','folder/file:stream','CON.txt','folder/file.','/absolute','folder\file')) {
Reject { Assert-PackagePath $unsafe } 'unsafe or protected manifest path rejected'
}
$corrupt=Join-Path $root 'corrupt.zip'
Copy-Item -LiteralPath $archive -Destination $corrupt
$z=[IO.Compression.ZipFile]::Open($corrupt,[IO.Compression.ZipArchiveMode]::Update)
$z.GetEntry('future-tool.exe').Delete()
$writer=[IO.StreamWriter]::new($z.CreateEntry('future-tool.exe').Open()); $writer.Write('tampered'); $writer.Dispose(); $z.Dispose()
Reject { Expand-Release $corrupt (Join-Path $root 'corruptstage') $binaryVersion } 'manifest detects tampered companion'
$extra=Join-Path $root 'extra.zip'; Copy-Item -LiteralPath $archive -Destination $extra
$z=[IO.Compression.ZipFile]::Open($extra,[IO.Compression.ZipArchiveMode]::Update)
$null=$z.CreateEntry('undeclared.dll'); $z.Dispose()
Reject { Expand-Release $extra (Join-Path $root 'extrastage') $binaryVersion } 'undeclared file rejected'
$target=Join-Path $root 'install'; $backup=Join-Path $root 'backup'
[IO.Directory]::CreateDirectory((Join-Path $target 'data')) | Out-Null
[IO.Directory]::CreateDirectory((Join-Path $target 'dictionaries')) | Out-Null
@@ -69,12 +95,14 @@ try {
if (Test-Path -LiteralPath $configurator) {
Assert ((Get-FileHash -LiteralPath (Join-Path $target 'KSwitcher.Config.exe')).Hash -eq (Get-FileHash -LiteralPath $configurator).Hash) 'configurator installed'
}
Assert ([IO.File]::ReadAllText((Join-Path $target 'future-tool.exe')) -eq 'future companion fixture') 'new companion name installs without changing updater'
Assert ([IO.File]::ReadAllText((Join-Path $target 'data/settings.json')) -eq 'personal settings') 'settings preserved'
Assert ([IO.File]::ReadAllText((Join-Path $target 'dictionaries/en.txt')) -eq 'personal dictionary') 'dictionary preserved'
Undo-Files $target $backup $changed
Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'rollback restores original'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'README.md'))) 'rollback removes newly introduced files'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Config.exe'))) 'rollback removes newly introduced configurator'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Screenshot.exe'))) 'rollback removes newly introduced screenshot editor'
# Fail after the original exe has moved to backup, as if the staged file vanished.
$stage2=Join-Path $root 'stage2'; [IO.Directory]::CreateDirectory($stage2) | Out-Null
[IO.File]::WriteAllText((Join-Path $stage2 'KSwitcher.exe'),'new binary fixture')