Release v0.5.0: isolate screenshot editor and support manifest-based updates
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
param([string]$Executable = "$PSScriptRoot\..\build\staged\KSwitcher.Screenshot.exe")
|
||||
$ErrorActionPreference='Stop'
|
||||
if (Get-Process KSwitcher.Screenshot -ErrorAction SilentlyContinue) { throw 'Close existing screenshot editors before this test.' }
|
||||
Add-Type @'
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
public static class ScreenshotProcessTest {
|
||||
public delegate bool EnumProc(IntPtr w, IntPtr p);
|
||||
[DllImport("user32.dll")] static extern bool EnumWindows(EnumProc callback, IntPtr param);
|
||||
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr w, out uint pid);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr w, StringBuilder name, int count);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetWindowText(IntPtr w, StringBuilder name, int count);
|
||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w, uint msg, IntPtr wp, IntPtr lp);
|
||||
public static IntPtr[] Windows(int process, string cls) {
|
||||
var windows=new List<IntPtr>();
|
||||
EnumWindows((w,p)=>{ uint pid; GetWindowThreadProcessId(w,out pid);
|
||||
var name=new StringBuilder(256); GetClassName(w,name,256);
|
||||
if(pid==process && name.ToString()==cls) windows.Add(w); return true;
|
||||
},IntPtr.Zero); return windows.ToArray();
|
||||
}
|
||||
public static string Title(IntPtr w) { var s=new StringBuilder(256); GetWindowText(w,s,256); return s.ToString(); }
|
||||
}
|
||||
'@
|
||||
function Wait-For([scriptblock]$Check,[string]$Message) {
|
||||
for($attempt=0;$attempt -lt 100;$attempt++) { if (& $Check) { return }; Start-Sleep -Milliseconds 50 }
|
||||
throw $Message
|
||||
}
|
||||
function Send($Window,$Message,$Wparam=0,$Lparam=0) {
|
||||
[ScreenshotProcessTest]::PostMessage($Window,$Message,[IntPtr]$Wparam,[IntPtr]$Lparam) | Out-Null
|
||||
}
|
||||
function Capture-Windows { @([ScreenshotProcessTest]::Windows($script:process.Id,'KSwitcher.Capture')) }
|
||||
function Select-Region($Window) {
|
||||
Send $Window 0x201 1 ((40 -shl 16) -bor 40)
|
||||
Send $Window 0x200 1 ((240 -shl 16) -bor 360)
|
||||
Send $Window 0x202 0 ((240 -shl 16) -bor 360)
|
||||
Wait-For { [ScreenshotProcessTest]::Title($Window) -match 'Screenshot #' } 'Selection did not become an editor.'
|
||||
}
|
||||
function Close-Editor($Window) {
|
||||
Send $Window 0x10
|
||||
Wait-For { @([ScreenshotProcessTest]::Windows($script:process.Id,'#32770')).Count -eq 1 } 'Missing close confirmation.'
|
||||
$dialog=[ScreenshotProcessTest]::Windows($script:process.Id,'#32770')[0]
|
||||
Send $dialog (0x400+102) 6 # TDM_CLICK_BUTTON / IDYES; no clipboard writes.
|
||||
Wait-For { (Capture-Windows) -notcontains $Window } 'Editor did not close.'
|
||||
}
|
||||
$process=$null
|
||||
try {
|
||||
$process=Start-Process -FilePath $Executable -WindowStyle Hidden -PassThru
|
||||
Wait-For { (Capture-Windows).Count -eq 1 } 'First selection did not open.'
|
||||
$first=(Capture-Windows)[0]; Select-Region $first
|
||||
$secondLaunch=Start-Process -FilePath $Executable -WindowStyle Hidden -PassThru
|
||||
if (!$secondLaunch.WaitForExit(10000) -or $secondLaunch.ExitCode -ne 0) { throw 'Second launch did not hand off.' }
|
||||
Wait-For { (Capture-Windows).Count -eq 2 } 'Second selection did not open in same process.'
|
||||
$second=(Capture-Windows | Where-Object { $_ -ne $first })[0]; Select-Region $second
|
||||
if ([ScreenshotProcessTest]::Title($first) -eq [ScreenshotProcessTest]::Title($second)) { throw 'Editor indices collide.' }
|
||||
Close-Editor $first
|
||||
if ($process.HasExited) { throw 'Process exited with an editor open.' }
|
||||
Close-Editor $second
|
||||
if (!$process.WaitForExit(5000) -or $process.ExitCode -ne 0) { throw 'Last editor did not exit process.' }
|
||||
$process=Start-Process -FilePath $Executable -WindowStyle Hidden -PassThru
|
||||
Wait-For { (Capture-Windows).Count -eq 1 } 'New process did not open selection.'
|
||||
Send (Capture-Windows)[0] 0x100 27 # Esc cancels a selection only.
|
||||
if (!$process.WaitForExit(5000) -or $process.ExitCode -ne 0) { throw 'Cancelling selection did not exit process.' }
|
||||
'Passed screenshot process lifecycle checks'
|
||||
} finally {
|
||||
if ($process -and !$process.HasExited) { Stop-Process -Id $process.Id }
|
||||
}
|
||||
+16
-4
@@ -4,10 +4,9 @@
|
||||
#include <iostream>
|
||||
int main()
|
||||
{
|
||||
auto temp=std::filesystem::temp_directory_path()/(L"KSwitcher-editor-test-"+std::to_wstring(GetCurrentProcessId()));
|
||||
try {
|
||||
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls);
|
||||
ks::App app(GetModuleHandleW(nullptr),true,temp);
|
||||
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
|
||||
app.window=CreateWindowW(L"STATIC",L"",0,0,0,0,0,HWND_MESSAGE,nullptr,app.instance,nullptr);
|
||||
ks::Capture c(app);
|
||||
c.image=std::make_unique<ks::Surface>(320,240);
|
||||
@@ -18,6 +17,8 @@ int main()
|
||||
auto require=[](bool ok,const char *message){ if(!ok) throw std::runtime_error(message); };
|
||||
require(window!=nullptr,"editor creation");
|
||||
ks::edit(window,c); ShowWindow(window,SW_HIDE);
|
||||
require(GetWindow(window,GW_OWNER)==nullptr && (GetWindowLongPtrW(window,GWL_EXSTYLE)&WS_EX_APPWINDOW),
|
||||
"editor is an independent Alt+Tab application window");
|
||||
require(GetDlgItem(window,50)!=nullptr && c.statusHeight>0,"status bar is created");
|
||||
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
|
||||
for(int size:{32,48,64,96}) {
|
||||
@@ -30,7 +31,7 @@ int main()
|
||||
DestroyCursor(cursor);
|
||||
}
|
||||
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
|
||||
require(std::wstring(windowTitle)==L"KSwitcher — Screenshot","title has no usage hints");
|
||||
require(std::wstring(windowTitle)==L"KSwitcher — Screenshot #1","title includes editor index without usage hints");
|
||||
RECT client{}; GetClientRect(window,&client);
|
||||
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
|
||||
ks::updateStatus(c);
|
||||
@@ -124,6 +125,18 @@ int main()
|
||||
SendMessageW(window,WM_COMMAND,18,0);
|
||||
require(ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0])).left==beforeNudge.left+5,"redo restores one nudge");
|
||||
SetWindowPos(window,nullptr,80,60,720,500,SWP_NOZORDER|SWP_SHOWWINDOW);
|
||||
ks::copy(window,c,[](HWND,const ks::Clip &clip)->DWORD {
|
||||
if(clip.formats.empty()) throw std::runtime_error("copy produced no image");
|
||||
return 0;
|
||||
});
|
||||
require(IsWindow(window) && c.copied,"copy publishes screenshot and keeps editor open");
|
||||
c.selected=0; c.selectedLabel=false;
|
||||
SendMessageW(window,WM_KEYDOWN,VK_RIGHT,0);
|
||||
require(!c.copied,"editing after copy requires confirmation again");
|
||||
c.copied=true; SendMessageW(window,WM_COMMAND,12,0);
|
||||
require(!c.copied,"undo after copy requires confirmation again");
|
||||
c.copied=true; SendMessageW(window,WM_COMMAND,18,0);
|
||||
require(!c.copied,"redo after copy requires confirmation again");
|
||||
SetPropW(window,L"ConfirmationCentered",nullptr);
|
||||
SetTimer(window,987,10,[](HWND owner,UINT,UINT_PTR id,DWORD) {
|
||||
EnumThreadWindows(GetCurrentThreadId(),[](HWND popup,LPARAM parent)->BOOL {
|
||||
@@ -150,7 +163,6 @@ int main()
|
||||
SendMessageW(window,WM_CLOSE,0,0);
|
||||
require(!IsWindow(window),"copied screenshot closes without confirmation");
|
||||
DestroyWindow(app.window); app.window=nullptr;
|
||||
std::filesystem::remove_all(temp);
|
||||
std::cout<<"Passed screenshot editor UI integration checks\n";
|
||||
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; return 1; }
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
param([Parameter(Mandatory)][string]$BridgeArchive,[Parameter(Mandatory)][string]$FullArchive)
|
||||
$ErrorActionPreference='Stop'
|
||||
Add-Type @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class UpdateResource {
|
||||
[DllImport("kernel32.dll", CharSet=CharSet.Unicode)] static extern IntPtr LoadLibraryEx(string path, IntPtr file, uint flags);
|
||||
[DllImport("kernel32.dll")] static extern IntPtr FindResource(IntPtr module, IntPtr name, IntPtr type);
|
||||
[DllImport("kernel32.dll")] static extern IntPtr LoadResource(IntPtr module, IntPtr resource);
|
||||
[DllImport("kernel32.dll")] static extern IntPtr LockResource(IntPtr resource);
|
||||
[DllImport("kernel32.dll")] static extern uint SizeofResource(IntPtr module, IntPtr resource);
|
||||
[DllImport("kernel32.dll")] static extern bool FreeLibrary(IntPtr module);
|
||||
public static byte[] Read(string path) {
|
||||
var module=LoadLibraryEx(path,IntPtr.Zero,2|32);
|
||||
if(module==IntPtr.Zero) throw new Exception("Cannot load bridge resources");
|
||||
try {
|
||||
var resource=FindResource(module,(IntPtr)201,(IntPtr)10);
|
||||
var size=SizeofResource(module,resource);
|
||||
if(size==0 || size>1024*1024) throw new Exception("Invalid bridge updater resource");
|
||||
var bytes=new byte[size];
|
||||
Marshal.Copy(LockResource(LoadResource(module,resource)),bytes,0,(int)size);
|
||||
return bytes;
|
||||
} finally { FreeLibrary(module); }
|
||||
}
|
||||
}
|
||||
'@
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('KSwitcher-transition-'+[guid]::NewGuid().ToString('N'))
|
||||
[IO.Directory]::CreateDirectory($root) | Out-Null
|
||||
try {
|
||||
$legacy=Join-Path $root 'legacy.ps1'
|
||||
$oldSource=git -C (Join-Path $PSScriptRoot '..') show v0.4.1:resources/update.ps1
|
||||
if ($LASTEXITCODE) { throw 'Cannot read the released v0.4.1 updater.' }
|
||||
$oldSource | Set-Content -LiteralPath $legacy -Encoding utf8
|
||||
$releases=@('v0.4.2','release-v0.5.0') | ForEach-Object {
|
||||
@{tag_name=$_;draft=$false;prerelease=$false;assets=@(
|
||||
@{name='KSwitcher-win-x64.zip';browser_download_url="https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/$_/KSwitcher-win-x64.zip"},
|
||||
@{name='SHA256SUMS.txt';browser_download_url="https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/$_/SHA256SUMS.txt"})}
|
||||
}
|
||||
$install=Join-Path $root 'install'
|
||||
[IO.Directory]::CreateDirectory((Join-Path $install 'data')) | Out-Null
|
||||
[IO.Directory]::CreateDirectory((Join-Path $install 'dictionaries')) | Out-Null
|
||||
[IO.File]::WriteAllText((Join-Path $install 'data/settings.json'),'personal settings')
|
||||
[IO.File]::WriteAllText((Join-Path $install 'dictionaries/en.txt'),'personal dictionary')
|
||||
& {
|
||||
. $legacy -Library
|
||||
$selected=Select-Release $releases ([version]'0.4.1')
|
||||
if ($selected.version -ne 'v0.4.2') { throw 'Legacy client skipped the bridge.' }
|
||||
$stage=Join-Path $root 'bridge-stage'
|
||||
Expand-Release $BridgeArchive $stage ([version]'0.4.2')
|
||||
$changed=[Collections.Generic.List[string]]::new()
|
||||
Install-Files $stage $install (Join-Path $root 'bridge-backup') $changed
|
||||
}
|
||||
$bridgeHelper=Join-Path $root 'bridge-helper.ps1'
|
||||
[IO.File]::WriteAllBytes($bridgeHelper,[UpdateResource]::Read((Join-Path $install 'KSwitcher.exe')))
|
||||
. $bridgeHelper -Library
|
||||
$selected=Select-Release $releases ([version]'0.4.2')
|
||||
if ($selected.version -ne 'release-v0.5.0') { throw 'Bridge did not find the full release.' }
|
||||
$stage=Join-Path $root 'full-stage'
|
||||
Expand-Release $FullArchive $stage ([version]'0.5.0')
|
||||
$changed=[Collections.Generic.List[string]]::new()
|
||||
Install-Files $stage $install (Join-Path $root 'full-backup') $changed
|
||||
foreach ($name in @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe')) {
|
||||
$version=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $install $name)).FileVersion
|
||||
if ((Get-StableVersion $version) -ne [version]'0.5.0') { throw "Incorrect installed version: $name" }
|
||||
}
|
||||
if ([IO.File]::ReadAllText((Join-Path $install 'data/settings.json')) -ne 'personal settings' -or
|
||||
[IO.File]::ReadAllText((Join-Path $install 'dictionaries/en.txt')) -ne 'personal dictionary') { throw 'User data changed during transition.' }
|
||||
if (Select-Release $releases ([version]'0.5.0')) { throw 'Current version offers another update.' }
|
||||
Undo-Files $install (Join-Path $root 'full-backup') $changed
|
||||
if ((Get-StableVersion ([Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $install 'KSwitcher.exe')).FileVersion)) -ne [version]'0.4.2' -or
|
||||
(Test-Path -LiteralPath (Join-Path $install 'KSwitcher.Screenshot.exe'))) { throw 'Full release rollback did not restore the bridge.' }
|
||||
'Passed real-package transition: v0.4.1 updater -> v0.4.2 bridge -> release-v0.5.0; user data preserved; rollback verified.'
|
||||
} finally {
|
||||
if ([IO.Path]::GetDirectoryName($root) -eq [IO.Path]::GetTempPath().TrimEnd('\') -and (Split-Path -Leaf $root) -like 'KSwitcher-transition-*') {
|
||||
Remove-Item -LiteralPath $root -Recurse -Force
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user