diff --git a/CMakeLists.txt b/CMakeLists.txt index e815971..de33c33 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.4.1 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.5.0 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() @@ -9,7 +9,7 @@ add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorre target_include_directories(kscore PUBLIC src PRIVATE vendor) target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00) target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore) -add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) +add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_launcher.cpp src/configuration.cpp src/ui.cpp resources/app.rc) target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP) target_link_libraries(KSwitcher PRIVATE kscore) add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc) @@ -18,12 +18,18 @@ target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP) target_link_libraries(KSwitcherConfig PRIVATE kscore) add_executable(KSwitcherTests tests/tests.cpp) target_link_libraries(KSwitcherTests PRIVATE kscore) -add_executable(KSwitcherIntegration tests/integration.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) +add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) target_link_libraries(KSwitcherIntegration PRIVATE kscore) -add_executable(KSwitcherInputTests tests/input.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) +add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) target_link_libraries(KSwitcherInputTests PRIVATE kscore) -add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp src/app.cpp src/configuration.cpp src/ui.cpp resources/app.rc) +target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES) +add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp resources/app.rc) target_link_libraries(KSwitcherScreenshotUITests PRIVATE kscore) +target_compile_definitions(KSwitcherScreenshotUITests PRIVATE KS_SCREENSHOT_RESOURCES) +add_executable(KSwitcherScreenshot WIN32 src/screenshot_main.cpp src/screenshot.cpp resources/app.rc) +set_target_properties(KSwitcherScreenshot PROPERTIES OUTPUT_NAME "KSwitcher.Screenshot") +target_compile_definitions(KSwitcherScreenshot PRIVATE KS_SCREENSHOT_RESOURCES) +target_link_libraries(KSwitcherScreenshot PRIVATE kscore) if(MSVC) target_compile_options(KSwitcherScreenshotUITests PRIVATE /utf-8) set_property(TARGET KSwitcherScreenshotUITests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") @@ -33,13 +39,15 @@ endif() add_executable(KSwitcherOcrTests tests/ocr.cpp) target_link_libraries(KSwitcherOcrTests PRIVATE kscore) if(MSVC) - set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") target_compile_options(kscore PRIVATE /W4 /utf-8) target_compile_options(KSwitcher PRIVATE /W4 /utf-8) target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8) + target_compile_options(KSwitcherScreenshot PRIVATE /W4 /utf-8) else() target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++) + target_link_options(KSwitcherScreenshot PRIVATE -municode -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++) @@ -51,7 +59,7 @@ add_test(NAME input-regressions COMMAND KSwitcherInputTests) add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests) add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$") set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") -install(TARGETS KSwitcher KSwitcherConfig RUNTIME DESTINATION .) +install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .) install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .) add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$/dictionaries") diff --git a/README.md b/README.md index 72a050f..27c66d6 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,25 @@ Settings and the snippet editor run on demand in **KSwitcher.Config.exe**, besid Repeated launches activate the existing configurator; closing its last window exits that process. Saving applies changes immediately through the resident KSwitcher process. The configurator does not load clipboard history, dictionaries or global input hooks. Clipboard history and the log viewer remain -in the resident app. Keep both executables together when copying or updating the application. +in the resident app. Screen capture and all screenshot editors run on demand in +**KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same +process. Closing the last editor (or cancelling the only selection) exits that process. It does not +load resident settings, dictionaries, clipboard history or keyboard hooks. Editor windows have +stable numbered titles within that process. Keep all three executables together when copying or +updating the application. Close screenshot editors before installing an update. If settings change in the tray while the configurator is open, a conflicting Save is rejected; reopen the configurator to load the latest settings. -The first upgrade from v0.2.3 or earlier to this two-executable build requires manual extraction: -the older updater rejects additional executable files. Exit KSwitcher, replace both executables, -and retain `data/` and customized dictionaries. Subsequent updates support both executables. +Updates from v0.3.x–v0.4.1 first install the compatible **v0.4.2 bridge** through +Update in the tray menu. Select Update again to install the full current release; +manual extraction is not required. Future full releases use stable `release-vX.Y.Z` +tags, which the bridge and newer clients recognize. Older clients ignore those tags +and continue to find the bridge instead of skipping it. Versions v0.2.3 and earlier +still require the previously documented initial configurator migration. + +Modern ZIPs contain `release-files.json` (schema, version, file paths, sizes and SHA-256 +hashes). The updater validates that every packaged file is declared, all hashes match, +and paths stay within the installation directory. `data/` is protected; existing +dictionaries are preserved. Adding a new program file no longer needs a new updater. In Settings, enable **Start with Windows** and **Start with Windows as administrator** to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing the elevated task requests UAC approval if the app is not already elevated. No password is stored; @@ -118,10 +131,10 @@ Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes. The window close button asks for confirmation before discarding an uncopied screenshot. -**Escape** cancels region selection or inline text edits; it does not close the editor. Copy closes the editor and publishes the image +**Escape** cancels region selection or inline text edits; it does not close the editor. Copy keeps the editor open and publishes the image at its original pixel size, ready to paste. The preview fits the window without changing export size. The shortcut is configurable in Settings under **Screen capture**. -Each capture opens an independent editor; use the shortcut again to take another screenshot while +Each capture opens an independent editor available through Alt+Tab; use the shortcut again to take another screenshot while keeping earlier editors and their annotations open. Captures use ordinary clipboard history settings and limits. Protected video and secure desktops cannot be captured. @@ -346,3 +359,15 @@ 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. + +The screenshot process lifecycle can be checked interactively with `tests/screenshot_process.ps1` +against a staged build. It briefly opens real selection/editor windows without changing the clipboard. + +### Release channel rule + +Keep `v0.4.2` as the last legacy `vX.Y.Z` release. Publish full packages as +`release-vX.Y.Z` (stable, not prerelease), including `release-files.json`, +`KSwitcher-win-x64.zip` and `SHA256SUMS.txt`. Never publish a manifest package under a +legacy version tag: clients that have not installed the bridge would select it. +Keep the compatibility bridge in the first 100 releases returned by Gitea while +supporting old clients; their immutable update code only reads that page. diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index 28de3a6..a692704 100644 Binary files a/Release/KSwitcher-win-x64.zip and b/Release/KSwitcher-win-x64.zip differ diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index 059b0e1..d6b8785 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -7c29980c5d3b8862b1d78c06d1c3daa2b2bff24b56d200616971b92d784b51e6 KSwitcher-win-x64.zip +7b9f07168023d2ad5c71672d0e699144bc15b11175eb05a7149b72b8be4f90b4 KSwitcher-win-x64.zip diff --git a/build.ps1 b/build.ps1 index 71694f4..ed31d8c 100644 --- a/build.ps1 +++ b/build.ps1 @@ -14,7 +14,7 @@ Push-Location $root try { $common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections') $libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore') - foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','app','ui','main','configuration','config_main')) { + foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','screenshot_main','screenshot_launcher','app','ui','main','configuration','config_main')) { & $compiler @common -c "src/$name.cpp" -o "build/$name.o" if ($LASTEXITCODE) { throw "Compilation failed: $name" } } @@ -24,24 +24,30 @@ try { if ($LASTEXITCODE) { throw 'Configurator UI compilation failed' } & windres --codepage=65001 resources/app.rc -O coff -o build/app-res.o if ($LASTEXITCODE) { throw 'Resource compilation failed' } + & windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o + if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' } New-Item -ItemType Directory -Force build/release | Out-Null - & $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath + & $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath if ($LASTEXITCODE) { throw 'Link failed' } Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force $configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe' & $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath if ($LASTEXITCODE) { throw 'Configurator link failed' } Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force + $screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe' + & $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/screenshot.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath + if ($LASTEXITCODE) { throw 'Screenshot link failed' } + Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force if ($Test) { & $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe if ($LASTEXITCODE) { throw 'Test build failed' } & .\build\KSwitcherTests.exe if ($LASTEXITCODE) { throw 'Tests failed' } - & $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherInputTests.exe + & $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe if ($LASTEXITCODE) { throw 'Input test build failed' } & .\build\KSwitcherInputTests.exe if ($LASTEXITCODE) { throw 'Input tests failed' } - & $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe + & $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' } & .\build\KSwitcherScreenshotUITests.exe if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' } @@ -49,7 +55,7 @@ try { if ($LASTEXITCODE) { throw 'Updater tests failed' } } if ($Integration) { - & $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherIntegration.exe + & $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe if ($LASTEXITCODE) { throw 'Integration test build failed' } & .\build\KSwitcherIntegration.exe if ($LASTEXITCODE) { throw 'Integration tests failed' } diff --git a/package.ps1 b/package.ps1 index 767d097..c06f9d0 100644 --- a/package.ps1 +++ b/package.ps1 @@ -5,12 +5,22 @@ $destination = Join-Path $root 'dist\KSwitcher' New-Item -ItemType Directory -Force $destination | Out-Null Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination +Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Screenshot.exe') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force # Enumerate the distributable files explicitly; do not include a user's data directory. -$files = @('KSwitcher.exe','KSwitcher.Config.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ } +$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ } +$entries = foreach ($path in $files) { + foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) { + @{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()} + } +} +$version=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $destination 'KSwitcher.exe')).FileVersion +$manifest=Join-Path $destination 'release-files.json' +@{schema=1; version=$version; files=@($entries)} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifest -Encoding utf8 +$files += $manifest $archive = Join-Path $root 'dist\KSwitcher-win-x64.zip' Compress-Archive -LiteralPath $files -DestinationPath $archive -Force Get-FileHash -LiteralPath $archive -Algorithm SHA256 diff --git a/resources/app.rc b/resources/app.rc index dd255f4..a00c157 100644 --- a/resources/app.rc +++ b/resources/app.rc @@ -2,12 +2,15 @@ #include #include "resource.h" IDI_KSWITCHER ICON "kswitcher.ico" +#ifdef KS_SCREENSHOT_RESOURCES +IDI_SCREENSHOT_QUESTION ICON "icons/question.ico" IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur" +#endif 1 RT_MANIFEST "app.manifest" 201 RCDATA "update.ps1" 1 VERSIONINFO - FILEVERSION 0,4,1,0 - PRODUCTVERSION 0,4,1,0 + FILEVERSION 0,5,0,0 + PRODUCTVERSION 0,5,0,0 FILEOS VOS_NT_WINDOWS32 FILETYPE VFT_APP BEGIN @@ -17,7 +20,7 @@ BEGIN BEGIN VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "ProductName", "KSwitcher" - VALUE "FileVersion", "0.4.1" + VALUE "FileVersion", "0.5.0" END END BLOCK "VarFileInfo" @@ -25,6 +28,7 @@ BEGIN VALUE "Translation", 0x409, 1200 END END +#ifdef KS_SCREENSHOT_RESOURCES 300 ICON "icons/lucide/circle-dot-normal.ico" 310 ICON "icons/lucide/circle-dot-active.ico" 320 ICON "icons/lucide/circle-dot-disabled.ico" @@ -73,3 +77,5 @@ END 405 ICON "icons/lucide/a-large-small-normal.ico" 415 ICON "icons/lucide/a-large-small-active.ico" 425 ICON "icons/lucide/a-large-small-disabled.ico" + +#endif diff --git a/resources/icons/question.ico b/resources/icons/question.ico new file mode 100644 index 0000000..69c740b Binary files /dev/null and b/resources/icons/question.ico differ diff --git a/resources/icons/question.svg b/resources/icons/question.svg new file mode 100644 index 0000000..5e9c86e --- /dev/null +++ b/resources/icons/question.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/resources/resource.h b/resources/resource.h index 8b537bd..7e67068 100644 --- a/resources/resource.h +++ b/resources/resource.h @@ -1,3 +1,4 @@ #pragma once #define IDI_KSWITCHER 101 +#define IDI_SCREENSHOT_QUESTION 102 #define IDC_SCREENSHOT_PENCIL 500 diff --git a/resources/update.ps1 b/resources/update.ps1 index 982f03c..cf327fc 100644 --- a/resources/update.ps1 +++ b/resources/update.ps1 @@ -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.' } diff --git a/src/app.cpp b/src/app.cpp index 0659651..ec7d5c5 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -589,7 +589,7 @@ void App::dispatch(int command) { if (command == Screenshot) { - if (!busy) showScreenshot(*this); + if (!busy) { resetInput(); launchScreenshot(*this); } return; } if (command == ShowHistory) diff --git a/src/app.h b/src/app.h index 3a46741..7245ff4 100644 --- a/src/app.h +++ b/src/app.h @@ -100,7 +100,7 @@ class App static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM); }; void showHistory(App &); -void showScreenshot(App &); +void launchScreenshot(App &); void refreshHistory(App &); void showSettings(App &, bool visible = true); void showLogs(App &); diff --git a/src/screenshot.cpp b/src/screenshot.cpp index e2fe891..9772d19 100644 --- a/src/screenshot.cpp +++ b/src/screenshot.cpp @@ -1,4 +1,3 @@ -#include "app.h" #include "screenshot.h" #include "../resources/resource.h" #include @@ -13,14 +12,21 @@ namespace { // Only selection is exclusive; each completed capture owns an independent editor. HWND activeCapture{}; +// Keep titles stable and distinct for every editor opened during this session. +unsigned long long nextEditorIndex=1; bool confirmScreenshotClose(HWND owner) { TASKDIALOGCONFIG dialog{sizeof(dialog)}; dialog.hwndParent=owner; - dialog.dwFlags=TDF_POSITION_RELATIVE_TO_WINDOW|TDF_ALLOW_DIALOG_CANCELLATION; + dialog.dwFlags=TDF_POSITION_RELATIVE_TO_WINDOW|TDF_ALLOW_DIALOG_CANCELLATION|TDF_USE_HICON_MAIN; + const int iconSize=MulDiv(32,GetDpiForWindow(owner),96); + auto icon=(HICON)LoadImageW(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(IDI_SCREENSHOT_QUESTION), + IMAGE_ICON,iconSize,iconSize,0); + dialog.hMainIcon=icon; dialog.dwCommonButtons=TDCBF_YES_BUTTON|TDCBF_NO_BUTTON; dialog.pszWindowTitle=L"KSwitcher"; - dialog.pszContent=L"Close without copying this screenshot to the clipboard?"; + dialog.pszMainInstruction=L"Close without copying?"; + dialog.pszContent=L"Your latest screenshot changes have not been copied to the clipboard."; dialog.nDefaultButton=IDNO; dialog.lpCallbackData=(LONG_PTR)owner; dialog.pfCallback=[](HWND window,UINT notification,WPARAM,LPARAM,LONG_PTR data)->HRESULT { @@ -40,6 +46,7 @@ bool confirmScreenshotClose(HWND owner) }; int button=IDNO; auto result=TaskDialogIndirect(&dialog,&button,nullptr,nullptr); + if(icon) DestroyIcon(icon); // If Windows cannot display the confirmation, keep the screenshot open. return SUCCEEDED(result) && button==IDYES; } @@ -115,7 +122,7 @@ bool chooseTextFont(HWND owner,Mark &m) } struct Capture { - App &app; + ScreenshotHost &app; std::unique_ptr image; std::unique_ptr dimmed, frame; bool editing{}, dragging{}, arrow{}, owned{}, copied{}; @@ -153,7 +160,7 @@ struct Capture int hoveredTool=0; MarkPart hoveredPart=MarkPart::None; std::wstring statusText; - explicit Capture(App &a) : app(a) {} + explicit Capture(ScreenshotHost &a) : app(a) {} ~Capture() { if(pencilCursor) DestroyCursor(pencilCursor); if (font) DeleteObject(font); @@ -162,6 +169,7 @@ struct Capture } void remember(const std::vector &previous) { + copied=false; history.remember(previous); syncHistory(); } @@ -720,14 +728,13 @@ LRESULT CALLBACK toolHover(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWO if(msg==WM_MOUSELEAVE) InvalidateRect(w,nullptr,FALSE); return DefSubclassProc(w,msg,wp,lp); } -void copy(HWND w, Capture &c) +void copy(HWND w, Capture &c, DWORD (*publish)(HWND,const Clip &)=writeClipboard) { finishInlineText(w,c,true); - // The resident message window owns the clipboard after this editor closes. + // The resident message window keeps ownership if the editor is closed later. auto clip=screenshotClip(c.image->dc,c.image->width,c.image->height,c.marks); - writeClipboard(c.app.window, clip); + publish(c.app.window, clip); c.copied=true; - DestroyWindow(w); } void edit(HWND w, Capture &c) { @@ -745,8 +752,11 @@ void edit(HWND w, Capture &c) POINT cursor; GetCursorPos(&cursor); GetMonitorInfoW(MonitorFromPoint(cursor,MONITOR_DEFAULTTOPRIMARY), &monitor); ShowWindow(w,SW_HIDE); + SetWindowLongPtrW(w,GWLP_HWNDPARENT,0); + SetWindowLongPtrW(w,GWL_EXSTYLE,(GetWindowLongPtrW(w,GWL_EXSTYLE)|WS_EX_APPWINDOW)&~WS_EX_TOOLWINDOW); SetWindowLongPtrW(w,GWL_STYLE,WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN); - SetWindowTextW(w,L"KSwitcher — Screenshot"); + auto title=L"KSwitcher — Screenshot #"+std::to_wstring(nextEditorIndex++); + SetWindowTextW(w,title.c_str()); INITCOMMONCONTROLSEX statusControls{sizeof(statusControls),ICC_BAR_CLASSES}; InitCommonControlsEx(&statusControls); CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS, @@ -806,7 +816,8 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) switch (msg) { case WM_CLOSE: - if(c->editing && !c->copied && !confirmScreenshotClose(w)) return 0; + finishInlineText(w,*c,true); + if(c->editing && (!c->copied || c->dragging) && !confirmScreenshotClose(w)) return 0; DestroyWindow(w); return 0; case FinishTextMessage: if(c->inlineText && (HWND)lp==c->inlineText && (wp!=0 || GetFocus()!=c->inlineText)) { @@ -908,7 +919,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) if (LOWORD(wp)==12 || LOWORD(wp)==18) { bool changed=LOWORD(wp)==12?c->history.undo(c->marks):c->history.redo(c->marks); - if(changed) { c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; } + if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; } c->syncHistory(); } if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || (LOWORD(wp)>=21 && LOWORD(wp)<=26)) @@ -1226,7 +1237,11 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) case WM_NCDESTROY: if (activeCapture == w) activeCapture=nullptr; SetWindowLongPtrW(w,GWLP_USERDATA,0); - if (c->owned) delete c; + if (c->owned) { + auto &host=c->app; + if (--host.windows==0) PostMessageW(host.window,ScreenshotIdle,0,0); + delete c; + } return DefWindowProcW(w,msg,wp,lp); } } @@ -1498,10 +1513,9 @@ Clip screenshotClip(HDC source, int width, int height, const std::vector(app); int x=GetSystemMetrics(SM_XVIRTUALSCREEN),y=GetSystemMetrics(SM_YVIRTUALSCREEN); int width=GetSystemMetrics(SM_CXVIRTUALSCREEN),height=GetSystemMetrics(SM_CYVIRTUALSCREEN); @@ -1528,6 +1542,7 @@ void showScreenshot(App &app) WS_POPUP,x,y,width,height,app.window,nullptr,app.instance,c.get()); if (!activeCapture) throw std::runtime_error("Cannot open screen capture"); c->owned=true; + ++app.windows; c.release(); showRequestedWindow(activeCapture); SetForegroundWindow(activeCapture); SetFocus(activeCapture); } diff --git a/src/screenshot.h b/src/screenshot.h index 46199b9..640ad5b 100644 --- a/src/screenshot.h +++ b/src/screenshot.h @@ -2,6 +2,14 @@ #include "platform.h" namespace ks { +// Only the on-demand screenshot process owns this state; no resident App dependency. +struct ScreenshotHost { + HINSTANCE instance{}; + HWND window{}; + unsigned windows{}; +}; +inline constexpr UINT ScreenshotRequest=WM_APP+71, ScreenshotIdle=WM_APP+72; +void showScreenshot(ScreenshotHost &); enum class LineStyle { Solid, Dotted, Dashed, DashDot }; enum class LineEnds { End, Start, Both, None }; enum class ScreenshotShape { None, Rectangle, Circle, Ellipse }; diff --git a/src/screenshot_launcher.cpp b/src/screenshot_launcher.cpp new file mode 100644 index 0000000..190c2f3 --- /dev/null +++ b/src/screenshot_launcher.cpp @@ -0,0 +1,21 @@ +#include "app.h" + +namespace ks { +void launchScreenshot(App &) +{ + auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe"; + if (!std::filesystem::exists(path)) + throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package."); + auto command=L"\""+path.wstring()+L"\""; + STARTUPINFOW startup{sizeof(startup)}; + PROCESS_INFORMATION process{}; + // Grant foreground rights before the child's first selection window opens. + if (!CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED, + nullptr,path.parent_path().c_str(),&startup,&process)) + throw std::runtime_error(utf8(lastError(L"Cannot start screenshot editor"))); + AllowSetForegroundWindow(process.dwProcessId); + ResumeThread(process.hThread); + CloseHandle(process.hThread); + CloseHandle(process.hProcess); +} +} diff --git a/src/screenshot_main.cpp b/src/screenshot_main.cpp new file mode 100644 index 0000000..6c95df9 --- /dev/null +++ b/src/screenshot_main.cpp @@ -0,0 +1,69 @@ +#include "screenshot.h" +#include + +namespace { +constexpr wchar_t ControllerClass[]=L"KSwitcher.Screenshot.Controller"; +LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM lp) +{ + auto host=(ks::ScreenshotHost *)GetWindowLongPtrW(window,GWLP_USERDATA); + if(message==WM_NCCREATE) { + host=(ks::ScreenshotHost *)((CREATESTRUCTW *)lp)->lpCreateParams; + SetWindowLongPtrW(window,GWLP_USERDATA,(LONG_PTR)host); + } + if(host && message==ks::ScreenshotRequest) { + try { ks::showScreenshot(*host); } + catch(const std::exception &e) { + MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screen capture",MB_OK|MB_ICONERROR); + PostMessageW(window,ks::ScreenshotIdle,0,0); + } + return TRUE; + } + if(host && message==ks::ScreenshotIdle && !host->windows) { + DestroyWindow(window); + PostQuitMessage(0); + return 0; + } + return DefWindowProcW(window,message,wp,lp); +} +} + +int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int) +{ + HANDLE mutex=CreateMutexW(nullptr,FALSE,L"Local\\KSwitcher.Screenshot.Singleton"); + if(!mutex) return 1; + // Wait through startup/shutdown races; the controller accepts requests only while alive. + bool acquired=false; + for(int attempt=0;attempt<100;++attempt) { + auto wait=WaitForSingleObject(mutex,0); + if(wait==WAIT_OBJECT_0 || wait==WAIT_ABANDONED) { acquired=true; break; } + if(auto existing=FindWindowW(ControllerClass,nullptr)) { + DWORD pid{}; GetWindowThreadProcessId(existing,&pid); + AllowSetForegroundWindow(pid); + DWORD_PTR accepted{}; + if(SendMessageTimeoutW(existing,ks::ScreenshotRequest,0,0,SMTO_ABORTIFHUNG,2000,&accepted) && accepted) { + CloseHandle(mutex); return 0; + } + } + Sleep(50); + } + if(!acquired) { CloseHandle(mutex); return 1; } + CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED); + INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_BAR_CLASSES}; + InitCommonControlsEx(&controls); + ks::ScreenshotHost host{instance}; + WNDCLASSW cls{}; cls.hInstance=instance; cls.lpfnWndProc=controller; cls.lpszClassName=ControllerClass; + RegisterClassW(&cls); + host.window=CreateWindowW(ControllerClass,L"KSwitcher Screenshot",0,0,0,0,0,nullptr,nullptr,instance,&host); + int result=1; + if(host.window) { + PostMessageW(host.window,ks::ScreenshotRequest,0,0); + MSG message{}; + while((result=GetMessageW(&message,nullptr,0,0))>0) { + TranslateMessage(&message); DispatchMessageW(&message); + } + result=result<0?1:0; + } + CoUninitialize(); + ReleaseMutex(mutex); CloseHandle(mutex); + return result; +} diff --git a/src/update.h b/src/update.h index c4b6518..a0af82b 100644 --- a/src/update.h +++ b/src/update.h @@ -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.5.0"; inline constexpr UINT UpdateMessage = WM_APP + 7; struct UpdateResult { diff --git a/tests/screenshot_process.ps1 b/tests/screenshot_process.ps1 new file mode 100644 index 0000000..fce0e22 --- /dev/null +++ b/tests/screenshot_process.ps1 @@ -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(); + 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 } +} diff --git a/tests/screenshot_ui.cpp b/tests/screenshot_ui.cpp index 18e7342..6c96a7b 100644 --- a/tests/screenshot_ui.cpp +++ b/tests/screenshot_ui.cpp @@ -4,10 +4,9 @@ #include 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(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<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 + } +} diff --git a/tools/generate-toolbar-icons.py b/tools/generate-toolbar-icons.py index e89f4b8..a456155 100644 --- a/tools/generate-toolbar-icons.py +++ b/tools/generate-toolbar-icons.py @@ -40,3 +40,9 @@ for index in range(struct.unpack_from('