diff --git a/CMakeLists.txt b/CMakeLists.txt index ebd0441..23008ef 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,11 +1,11 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.2.0 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.2.1 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) -add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp) +add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp) 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) @@ -15,20 +15,25 @@ add_executable(KSwitcherTests tests/tests.cpp) target_link_libraries(KSwitcherTests PRIVATE kscore) add_executable(KSwitcherIntegration tests/integration.cpp src/app.cpp src/ui.cpp resources/app.rc) target_link_libraries(KSwitcherIntegration PRIVATE kscore) +add_executable(KSwitcherInputTests tests/input.cpp src/app.cpp src/ui.cpp resources/app.rc) +target_link_libraries(KSwitcherInputTests PRIVATE kscore) add_executable(KSwitcherOcrTests tests/ocr.cpp) target_link_libraries(KSwitcherOcrTests PRIVATE kscore) if(MSVC) - set_property(TARGET kscore KSwitcher KSwitcherTests KSwitcherIntegration KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + set_property(TARGET kscore KSwitcher 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) else() target_link_options(KSwitcher 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++) target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++) endif() enable_testing() add_test(NAME native-regressions COMMAND KSwitcherTests) +add_test(NAME input-regressions COMMAND KSwitcherInputTests) +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 RUNTIME DESTINATION .) install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .) diff --git a/README.md b/README.md index 4c74a2e..13f3f2a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,36 @@ combinations are intercepted. Unassigned shortcuts and simulated copy/paste pass Choose another shortcut or exit KSwitcher to restore the original action. Windows protects Ctrl+Alt+Delete and Win+L; these cannot be assigned. +Keyboard and mouse input forwarded by Microsoft Mouse Without Borders follows the same input +path as a local keyboard/mouse, including shortcuts, recent-word conversion and snippets. +KSwitcher marks its own generated input so copy/paste, selection, modifier releases and shell-mask +keys cannot trigger recursive commands or overwrite the recorded typing run. Other injected input +is accepted, including key-up events and VK-only keyboard events. Unicode packets do not provide +physical key positions and therefore reset recent-word tracking; selection conversion still works. + +### Updates + +Select **Update** in the tray menu to check the stable Releases of +[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases). +A startup check also reports an available version, but never installs it automatically. +Confirm the offered version to download and install it. The updater uses the existing +`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64 +executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen +before the application exits. If replacement or startup fails, it restores the previous files; +if recovery itself fails, it reports the retained backup directory. + +Saved settings and clipboard history in `data/` or the local-app-data fallback are preserved. +Existing dictionary files are preserved too; missing dictionary files are installed. Save settings +edits before updating. The installation directory must be writable at KSwitcher's current privilege +level. A blocked network request, incomplete release or checksum mismatch leaves the running version +intact and is reported when Update is selected. Startup network failures remain non-interactive. +The helper uses Windows' built-in Windows PowerShell 5.1; no additional runtime is installed. + +Settings and the other utility windows use the destination monitor's DPI for their initial frame, +controls and fonts. They can be resized, stay within a remaining monitor's work area after display +changes, and provide scrollbars when their content does not fit. Tab navigation scrolls the focused +field into view; Shift+wheel scrolls horizontally. + ## Implemented - Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only. @@ -114,10 +144,8 @@ This is a working native implementation, not a claim of complete UI/release pari - UI currently uses English and the native Windows control appearance. [Typlo](https://github.com/bitcodepro/Typlo)'s ten-language UI and explicit light/dark theme selector are not yet ported. -- Automatic release download/replacement is not enabled. KSwitcher has no configured release feed; - using [Typlo](https://github.com/bitcodepro/Typlo)'s feed would install the wrong application. Updates are manual. - No import of old encrypted clipboard history. Export/import applies to settings. -- The clipboard popup is fixed-size with per-monitor DPI scaling; PNG/DIB previews use Windows WIC/GDI. +- The clipboard popup uses per-monitor DPI scaling and scrolls when space is limited; PNG/DIB previews use Windows WIC/GDI. The Windows focus/injection rules still apply: an unelevated process cannot edit an elevated app. Conversion is cancelled rather than pasting into a different window or replacing unverified recent text. @@ -201,6 +229,18 @@ Run `./package.ps1 -PublishRelease` after building and testing to refresh the tr .\build.ps1 -Integration # Interactive, isolated test editor; avoid input for about 10 seconds ``` +`-Test` also checks remote/own input separation, hidden Settings layout and scrolling, and updater +package validation, file preservation and rollback in temporary fixture directories. It does not +replace a running installation. Optional read-only network checks: + +```powershell +.\build\KSwitcherInputTests.exe --update-check +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\tests\update.ps1 -Network +``` + +These validate the live release metadata/download/checksum in a temporary directory. They do not +prove hardware interoperability between two laptops or a physical monitor-disconnection sequence. + The interactive test requires installed English-US and Russian layouts. It opens its own editor, tests real copy/paste, undo, repeat conversion, recent-input verification and snippet cursor placement, then restores the clipboard. Test data is stored in its own temporary directory. It never sends messages diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index 825dd5e..2bb11d7 100644 Binary files a/Release/KSwitcher-win-x64.zip and b/Release/KSwitcher-win-x64.zip differ diff --git a/Release/README.md b/Release/README.md index 88edd08..15cb41d 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,34 +1,39 @@ # Latest Windows release -## v0.2.0 — 2026-10-28 +## v0.2.1 — 2026-10-28 -- Log failed conversions and suspicious repeated hotkeys with recent attempt context. -- Include bounded text and layout diagnostics to help reproduce conversion failures. -- Add the **Write log** setting and preserve the previous log during rotation. -- View current and previous logs inside the app with Time, Attempt, Event, and Details columns. -- Copy selected rows and clear the selected log from the viewer. -- Fix the log viewer closing on focus; give column headers a distinct background. +- Accept keyboard and mouse input forwarded by Microsoft Mouse Without Borders, including hotkey + releases, repeated presses, recent-word conversion and snippets. Mark KSwitcher's own generated + input to prevent recursive commands and preserve text-operation validation. +- Add **Update** to the tray menu and a non-installing startup check against this repository's + Releases. Verify the x64 ZIP, SHA-256, executable identity and version before closing; restart + automatically and roll back on replacement/startup failure. Keep settings, history and existing dictionaries. +- Fix initial window sizing on high-DPI monitors. Size the frame and controls from the same monitor + DPI, recover windows after display changes, and provide resizing, scrolling and focus visibility + when the available work area is smaller than the contents. -Repeated hotkeys are diagnostic clues, not proof of failure. Successful paste dispatch does not -verify that the target editor applied the replacement. Text diagnostics are enabled by default -and can be disabled with **Write log**. +Verified: 168 native checks, 51 input/layout checks (including hidden Settings at 240 DPI), +24 offline updater checks, live release download/checksum/package validation, and visible Settings +at 250% scaling. Two-laptop Mouse Without Borders use and physically disconnecting a monitor still +require hardware verification. Download **KSwitcher-win-x64.zip**, extract the entire archive to a writable folder, and launch `KSwitcher.exe`. Keep `dictionaries/` next to the executable. The archive contains the application, offline dictionaries, documentation and license notices. It does not contain personal settings or clipboard history. +`SHA256SUMS.txt` contains the archive checksum. -For upgrades, exit KSwitcher first. Back up any edited dictionaries before replacing files. -Keep your existing `data/` directory to retain settings and clipboard history. -`SHA256SUMS.txt` contains the checksum of the archive. +From version 0.2.1 onward, use **Update** in the tray for future releases. Startup checks notify; +installation starts only after confirming Update. Older versions need this one manual upgrade: +exit KSwitcher and replace the application files, retaining `data/` and any customized dictionaries. -This folder holds only the latest compiled version; older versions remain in Git history. - -To refresh it from source: +This folder holds the latest compiled version; older versions remain in Git history and Releases. +To refresh the tracked package from source: ```powershell .\build.ps1 -Stage -Test .\package.ps1 -PublishRelease ``` -Commit the updated ZIP and checksum together with the corresponding source changes. +Commit the ZIP and checksum together with their source changes. Publish both files as assets of +one stable release tagged with the executable version, so the updater can verify it. diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index 8fc6ec1..64e99f9 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -7138fee6aa1e9c5e66aa212798c4cc193a987f088a45ec37a355b46cf9a40d08 KSwitcher-win-x64.zip +e33c372d1401c5500f3b07b38c10aff9667a5b1db9f6e613420e7878202853b3 KSwitcher-win-x64.zip diff --git a/build.ps1 b/build.ps1 index b44d42e..423eb7f 100644 --- a/build.ps1 +++ b/build.ps1 @@ -14,24 +14,30 @@ 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','app','ui','main')) { + foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','app','ui','main')) { & $compiler @common -c "src/$name.cpp" -o "build/$name.o" if ($LASTEXITCODE) { throw "Compilation failed: $name" } } & windres resources/app.rc -O coff -o build/app-res.o if ($LASTEXITCODE) { throw '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/app.o build/ui.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/app.o build/ui.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 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 @libs -static -s -o build/KSwitcherTests.exe + & $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/app.o build/ui.o build/app-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' } + & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath + 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/app.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/app.o build/ui.o build/app-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/resources/app.rc b/resources/app.rc index ab4f6e4..6a79a0f 100644 --- a/resources/app.rc +++ b/resources/app.rc @@ -2,9 +2,10 @@ #include "resource.h" IDI_KSWITCHER ICON "kswitcher.ico" 1 RT_MANIFEST "app.manifest" +201 RCDATA "update.ps1" 1 VERSIONINFO - FILEVERSION 0,2,0,0 - PRODUCTVERSION 0,2,0,0 + FILEVERSION 0,2,1,0 + PRODUCTVERSION 0,2,1,0 FILEOS VOS_NT_WINDOWS32 FILETYPE VFT_APP BEGIN @@ -14,7 +15,7 @@ BEGIN BEGIN VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "ProductName", "KSwitcher" - VALUE "FileVersion", "0.2.0" + VALUE "FileVersion", "0.2.1" END END BLOCK "VarFileInfo" diff --git a/resources/update.ps1 b/resources/update.ps1 new file mode 100644 index 0000000..f30ad31 --- /dev/null +++ b/resources/update.ps1 @@ -0,0 +1,211 @@ +param( + [ValidateSet('Check','Install')][string]$Mode = 'Check', + [string]$CurrentVersion, [string]$ExpectedVersion, + [string]$InstallDirectory, [string]$WorkDirectory, + [int]$ParentProcessId, [string]$ReadyEvent, + [switch]$Library +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Get-StableVersion([string]$Tag) { + if ($Tag -notmatch '^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null } + try { return [version]($Tag -replace '^v','') } catch { return $null } +} +function Assert-ReleaseUrl([string]$Url) { + $uri = [uri]$Url + if (!$uri.IsAbsoluteUri -or $uri.Scheme -ne 'https' -or $uri.Host -ne 'gitea.hrynco.com' -or + !$uri.IsDefaultPort -or $uri.UserInfo -or $uri.Query -or $uri.Fragment -or + !$uri.AbsolutePath.StartsWith('/hrynco/hrynco-kswitcher/releases/download/',[StringComparison]::Ordinal)) { + throw 'Release asset URL is outside the KSwitcher repository.' + } +} +function Receive-File([string]$Url,[string]$Path,[long]$Limit) { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $request = [Net.HttpWebRequest]::Create($Url) + $request.AllowAutoRedirect = $false + $request.Timeout = 15000 + $request.ReadWriteTimeout = 15000 + $request.UserAgent = 'KSwitcher-Updater' + $response = $null; $inputStream = $null; $outputStream = $null + try { + $response = $request.GetResponse() + if ([int]$response.StatusCode -ne 200 -or $response.ContentLength -gt $Limit) { throw 'Invalid or oversized server response.' } + $inputStream = $response.GetResponseStream() + $outputStream = [IO.File]::Create($Path) + $buffer = New-Object byte[] 65536 + $total = 0L + $deadline = [DateTime]::UtcNow.AddSeconds(60) + while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) { + $total += $count + if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' } + $outputStream.Write($buffer,0,$count) + } + } finally { + if ($outputStream) { $outputStream.Dispose() } + if ($inputStream) { $inputStream.Dispose() } + if ($response) { $response.Dispose() } + } +} +function Select-Release($Releases,[version]$Current) { + $candidates = @($Releases | Where-Object { !$_.draft -and !$_.prerelease -and (Get-StableVersion $_.tag_name) } | + Sort-Object { Get-StableVersion $_.tag_name } -Descending) + if (!$candidates.Count -or (Get-StableVersion $candidates[0].tag_name) -le $Current) { return $null } + $release = $candidates[0] + $zip = @($release.assets | Where-Object name -CEQ 'KSwitcher-win-x64.zip') + $sum = @($release.assets | Where-Object name -CEQ 'SHA256SUMS.txt') + if ($zip.Count -ne 1 -or $sum.Count -ne 1) { throw 'The newest release lacks its x64 ZIP or SHA256SUMS.txt.' } + Assert-ReleaseUrl $zip[0].browser_download_url + Assert-ReleaseUrl $sum[0].browser_download_url + return @{ version=$release.tag_name; zip=$zip[0].browser_download_url; checksum=$sum[0].browser_download_url } +} +function Assert-Checksum([string]$Archive,[string]$Checksum) { + $matchesFound = @([IO.File]::ReadAllLines($Checksum) | Where-Object { $_ -cmatch '^([a-fA-F0-9]{64})\s+\*?KSwitcher-win-x64\.zip$' }) + if ($matchesFound.Count -ne 1) { throw 'Missing or ambiguous archive checksum.' } + $expected = ($matchesFound[0] -split '\s+')[0] + if ((Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash -ne $expected) { throw 'Release checksum verification failed.' } +} +function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [IO.Compression.ZipFile]::OpenRead($Archive) + try { + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $total = 0L + foreach ($entry in $zip.Entries) { + $name = $entry.FullName.Replace('\','/') + if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue } + if ($name -cnotmatch '^(KSwitcher\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or + $name.EndsWith('/.') -or $name.EndsWith('/..') -or $name.EndsWith('.') -or + (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($name)) { + throw "Unexpected or unsafe archive entry: $name" + } + $total += $entry.Length + if ($total -gt 100MB -or $zip.Entries.Count -gt 100) { throw 'Release expands beyond allowed limits.' } + $destinationFile = Join-Path $Destination $name + [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destinationFile)) | Out-Null + [IO.Compression.ZipFileExtensions]::ExtractToFile($entry,$destinationFile,$false) + } + foreach ($required in @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md')) { + if (!$seen.Contains($required)) { throw "Incomplete release: $required" } + } + } finally { $zip.Dispose() } + $exe = Join-Path $Destination 'KSwitcher.exe' + $version = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe) + if ($version.ProductName -ne 'KSwitcher' -or (Get-StableVersion $version.FileVersion) -ne $Expected) { + throw 'Executable identity/version does not match the release.' + } + $bytes = [IO.File]::ReadAllBytes($exe) + if ($bytes.Length -lt 64 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) { throw 'Invalid release executable.' } + $pe = [BitConverter]::ToInt32($bytes,60) + if ($pe -lt 64 -or $pe -gt $bytes.Length-6 -or [BitConverter]::ToUInt32($bytes,$pe) -ne 0x4550 -or + [BitConverter]::ToUInt16($bytes,$pe+4) -ne 0x8664) { throw 'Release is not a Windows x64 executable.' } +} +function Assert-PlainDirectory([string]$Directory) { + $item = Get-Item -LiteralPath $Directory -Force + while ($item) { + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Update directory must not contain junctions or symbolic links.' } + $item = $item.Parent + } +} +function Install-Files([string]$Stage,[string]$Target,[string]$Backup,[Collections.Generic.List[string]]$Changed) { + Assert-PlainDirectory $Target + foreach ($file in @(Get-ChildItem -LiteralPath $Stage -File -Recurse)) { + $relative = $file.FullName.Substring($Stage.Length+1) + $destination = Join-Path $Target $relative + if ($relative.StartsWith('dictionaries\') -and (Test-Path -LiteralPath $destination)) { continue } + [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null + Assert-PlainDirectory ([IO.Path]::GetDirectoryName($destination)) + if (Test-Path -LiteralPath $destination) { + if ((Get-Item -LiteralPath $destination -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Cannot replace a linked file.' } + $backupFile = Join-Path $Backup $relative + [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($backupFile)) | Out-Null + Move-Item -LiteralPath $destination -Destination $backupFile + } + $Changed.Add($relative) + Move-Item -LiteralPath $file.FullName -Destination $destination + } +} +function Undo-Files([string]$Target,[string]$Backup,[Collections.Generic.List[string]]$Changed) { + for ($i=$Changed.Count-1; $i -ge 0; --$i) { + $destination = Join-Path $Target $Changed[$i] + if (Test-Path -LiteralPath $destination) { Remove-Item -LiteralPath $destination -Force } + $original = Join-Path $Backup $Changed[$i] + if (Test-Path -LiteralPath $original) { Move-Item -LiteralPath $original -Destination $destination } + } +} +function Write-Result($Value) { + $Value | ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $WorkDirectory 'result.json') -Encoding UTF8 +} +if ($Library) { return } +$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null +$changed = [Collections.Generic.List[string]]::new() +$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null +try { + $current = Get-StableVersion $CurrentVersion + if (!$current) { throw 'Invalid installed version.' } + if ($Mode -eq 'Install') { + $parentProcess = Get-Process -Id $ParentProcessId + if ([IO.Path]::GetFullPath($parentProcess.Path) -ne [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.exe'))) { + throw 'The running executable does not match the update target.' + } + $ready = [Threading.EventWaitHandle]::OpenExisting($ReadyEvent) + } + $metadata = Join-Path $WorkDirectory 'releases.json' + Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' $metadata 2MB + $release = Select-Release (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json) $current + if (!$release) { Write-Result @{state='current'}; exit 0 } + if ($Mode -eq 'Check') { Write-Result @{state='available';version=$release.version}; exit 0 } + if ($ExpectedVersion -ne $release.version) { throw 'Release changed since checking. Please select Update again.' } + Assert-PlainDirectory $InstallDirectory + $transaction = Join-Path $InstallDirectory ('.kswitcher-update-'+[guid]::NewGuid().ToString('N')) + $stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup' + [IO.Directory]::CreateDirectory($stage) | Out-Null + $archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt' + Receive-File $release.zip $archive 50MB + Receive-File $release.checksum $checksum 64KB + Assert-Checksum $archive $checksum + Expand-Release $archive $stage (Get-StableVersion $release.version) + Write-Result @{state='prepared';version=$release.version} + $ready.Set() | Out-Null + if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' } + $parentExited = $true + Install-Files $stage $InstallDirectory $backup $changed + $startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N') + $started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName) + $child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru + if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' } + $success = $true +} catch { + $message = $_.Exception.Message + if ($parentExited) { + try { + if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() } + Undo-Files $InstallDirectory $backup $changed + Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -WorkingDirectory $InstallDirectory -WindowStyle Hidden + $message += "`nThe previous version has been restored and restarted." + } catch { + $preserveBackup = $true + $message += "`nRecovery needs attention: $($_.Exception.Message)`nBackup: $backup" + } + } + Write-Result @{state='error';error=$message} + if ($ready -and $ready.WaitOne(0)) { + Add-Type -AssemblyName System.Windows.Forms + [Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null + } + exit 1 +} finally { + if ($ready) { $ready.Dispose() } + if ($started) { $started.Dispose() } + if ($parentProcess) { $parentProcess.Dispose() } + # Only remove the uniquely created transaction directory directly under the install directory. + if ($transaction -and !$preserveBackup -and [IO.Path]::GetDirectoryName($transaction) -eq [IO.Path]::GetFullPath($InstallDirectory).TrimEnd('\') -and (Test-Path -LiteralPath $transaction)) { + Remove-Item -LiteralPath $transaction -Recurse -Force -ErrorAction SilentlyContinue + } + if ($success) { + # The script is loaded in memory; its unique temporary working directory is no longer needed. + if ((Split-Path -Leaf $WorkDirectory) -like 'KSwitcher-update-*' -and [IO.Path]::GetDirectoryName($WorkDirectory) -eq [IO.Path]::GetTempPath().TrimEnd('\')) { + Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue + } + } +} diff --git a/src/app.cpp b/src/app.cpp index a298968..758b6af 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -256,6 +256,8 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan } App::~App() { + if (updateWorker.joinable()) + updateWorker.join(); if (keyboardHook) UnhookWindowsHookEx(keyboardHook); if (mouseHook) @@ -413,23 +415,15 @@ void App::resetInput() run.reset(); last.reset(); } -void App::keyboard(const KBDLLHOOKSTRUCT &k) +void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods) { - if (k.flags & LLKHF_INJECTED) + if (ownKeyboardInput(k)) return; DWORD vk = k.vkCode; - if (vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LCONTROL || vk == VK_RCONTROL || vk == VK_LMENU || + if (vk == VK_SHIFT || vk == VK_CONTROL || vk == VK_MENU || + vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LCONTROL || vk == VK_RCONTROL || vk == VK_LMENU || vk == VK_RMENU || vk == VK_LWIN || vk == VK_RWIN) return; - UINT mods = 0; - if (GetAsyncKeyState(VK_CONTROL) & 0x8000) - mods |= MOD_CONTROL; - if (GetAsyncKeyState(VK_MENU) & 0x8000) - mods |= MOD_ALT; - if (GetAsyncKeyState(VK_SHIFT) & 0x8000) - mods |= MOD_SHIFT; - if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000) - mods |= MOD_WIN; std::lock_guard lock(stateMutex); if (isHotkey(vk, mods)) return; @@ -448,7 +442,11 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k) return; } bool shift = (mods & MOD_SHIFT) != 0; - wchar_t ch = vk == VK_SPACE ? L' ' : physicalKey(k.scanCode, shift); + // VK_PACKET carries UTF-16 in scanCode, not a physical key position. + DWORD scan = vk == VK_PACKET ? 0 : k.scanCode; + if (!scan && vk != VK_PACKET) + scan = MapVirtualKeyExW(vk, MAPVK_VK_TO_VSC_EX, layout); + wchar_t ch = vk == VK_SPACE ? L' ' : physicalKey(scan, shift); if (ch && letter(ch) && (GetKeyState(VK_CAPITAL) & 1)) ch = shift ? lower(std::wstring(1, ch))[0] : upper(std::wstring(1, ch))[0]; if (!ch) @@ -492,85 +490,83 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k) } } } -LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp) +bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods) { - if (code == HC_ACTION && (((KBDLLHOOKSTRUCT *)lp)->flags & LLKHF_INJECTED) && - ((KBDLLHOOKSTRUCT *)lp)->vkCode == 0xE8) - return CallNextHookEx(nullptr, code, wp, lp); // Our shell-mask key is never recorded. - if (code == HC_ACTION && current && current->hotkeyCapture && - GetForegroundWindow() == GetAncestor(current->hotkeyCapture, GA_ROOT)) + // Filter before capture, hotkey latching and typing history. External injected + // down AND up events use exactly the same path as physical keyboard input. + if (ownKeyboardInput(key) || installingUpdate) + return false; + if (hotkeyCapture && GetForegroundWindow() == GetAncestor(hotkeyCapture, GA_ROOT)) { - auto &key = *(KBDLLHOOKSTRUCT *)lp; - bool modifier = key.vkCode == VK_LCONTROL || key.vkCode == VK_RCONTROL || key.vkCode == VK_LSHIFT || + bool modifier = key.vkCode == VK_CONTROL || key.vkCode == VK_SHIFT || key.vkCode == VK_MENU || + key.vkCode == VK_LCONTROL || key.vkCode == VK_RCONTROL || key.vkCode == VK_LSHIFT || key.vkCode == VK_RSHIFT || key.vkCode == VK_LMENU || key.vkCode == VK_RMENU || key.vkCode == VK_LWIN || key.vkCode == VK_RWIN; if (!modifier) { - if (wp == WM_KEYUP || wp == WM_SYSKEYUP) - current->hotkeyHook.update(key.vkCode, false, 0, {}); - if (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN) + if (!down) + hotkeyHook.update(key.vkCode, false, 0, {}); + if (down) { - UINT mods = 0; - if (GetAsyncKeyState(VK_CONTROL) & 0x8000) - mods |= MOD_CONTROL; - if (GetAsyncKeyState(VK_MENU) & 0x8000) - mods |= MOD_ALT; - if (GetAsyncKeyState(VK_SHIFT) & 0x8000) - mods |= MOD_SHIFT; - if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000) - mods |= MOD_WIN; if (mods & MOD_WIN) chord(0xE8); - PostMessageW(current->hotkeyCapture, CaptureHotkeyMessage, key.vkCode, mods); + PostMessageW(hotkeyCapture, CaptureHotkeyMessage, key.vkCode, mods); } return 1; // Record the chord without executing a global shortcut or typing into an editor. } } - if (code == HC_ACTION && (((KBDLLHOOKSTRUCT *)lp)->flags & LLKHF_INJECTED)) - return CallNextHookEx(nullptr, code, wp, lp); - if (code == HC_ACTION && (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN) && current) + if (down) { - auto &key = *(KBDLLHOOKSTRUCT *)lp; - UINT mods = 0; - if (GetAsyncKeyState(VK_CONTROL) & 0x8000) - mods |= MOD_CONTROL; - if (GetAsyncKeyState(VK_MENU) & 0x8000) - mods |= MOD_ALT; - if (GetAsyncKeyState(VK_SHIFT) & 0x8000) - mods |= MOD_SHIFT; - if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000) - mods |= MOD_WIN; - auto action = current->hotkeyHook.update(key.vkCode, true, mods, current->hookBindings); + auto action = hotkeyHook.update(key.vkCode, true, mods, hookBindings); if (action > 0) { // Tell the shell that Win participated in a chord, preventing Start // from opening on Win-up after we consumed V. 0xE8 is unassigned. if (mods & (MOD_WIN | MOD_ALT)) chord(0xE8); - PostMessageW(current->window, WM_HOTKEY, action, 0); + PostMessageW(window, WM_HOTKEY, action, 0); } if (action != 0) return 1; - current->keyboard(*(KBDLLHOOKSTRUCT *)lp); + keyboard(key, mods); + } + else if (hotkeyHook.update(key.vkCode, false, 0, hookBindings) != 0) + return true; + return false; +} +LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp) +{ + if (code == HC_ACTION && current && + (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN || wp == WM_KEYUP || wp == WM_SYSKEYUP)) + { + // The current event has not updated asynchronous state yet; the preceding + // modifier events (including MWB input) have. Own events must still reach Windows. + UINT mods = 0; + if (GetAsyncKeyState(VK_CONTROL) & 0x8000) mods |= MOD_CONTROL; + if (GetAsyncKeyState(VK_MENU) & 0x8000) mods |= MOD_ALT; + if (GetAsyncKeyState(VK_SHIFT) & 0x8000) mods |= MOD_SHIFT; + if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000) mods |= MOD_WIN; + if (current->handleKeyboard(*(KBDLLHOOKSTRUCT *)lp, + wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN, mods)) + return 1; } - if (code == HC_ACTION && (wp == WM_KEYUP || wp == WM_SYSKEYUP) && current && - current->hotkeyHook.update(((KBDLLHOOKSTRUCT *)lp)->vkCode, false, 0, current->hookBindings) != 0) - return 1; return CallNextHookEx(nullptr, code, wp, lp); } LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp) { if (code == HC_ACTION && current) { - auto *e = (MSLLHOOKSTRUCT *)lp; - if (!(e->flags & LLMHF_INJECTED) && - (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL)) + // KSwitcher generates no mouse input. Remote clicks invalidate a typing + // range just like local clicks, including while a transaction is pending. + if (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL) current->resetInput(); } return CallNextHookEx(nullptr, code, wp, lp); } void App::dispatch(int command) { + if (installingUpdate) + return; try { if (command == ShowHistory) @@ -602,6 +598,7 @@ void App::dispatch(int command) } void App::transform(int command, int snippet) { + if (installingUpdate) return; const auto attempt = ++transformAttempt; const auto requestedAt = GetTickCount64(); HWND target = GetForegroundWindow(); @@ -957,6 +954,7 @@ void App::transform(int command, int snippet) } void App::pasteEntry(std::wstring id, bool plain) { + if (installingUpdate) return; if (!history || busy.exchange(true)) return; HWND target = pasteTarget; @@ -1095,6 +1093,9 @@ void App::trayMenu() AppendMenuW(menu, MF_POPUP, (UINT_PTR)pause, L"Pause clipboard history"); AppendMenuW(menu, MF_STRING, 15, L"Open data folder"); AppendMenuW(menu, MF_STRING, 18, L"View log…"); + AppendMenuW(menu, MF_STRING | (updateRunning ? MF_GRAYED : 0), 19, + updateRunning ? L"Update (checking / preparing…)" : + availableUpdate.empty() ? L"Update" : L"Update (new version available)"); AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); AppendMenuW(menu, MF_STRING, 16, L"About KSwitcher"); AppendMenuW(menu, MF_STRING, 17, L"Exit"); @@ -1133,7 +1134,7 @@ void App::trayMenu() break; case 16: MessageBoxW(window, - L"KSwitcher 0.2.0\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on " + L"KSwitcher 0.2.1\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on " L"Typlo by bitcodepro. MIT license.\nHistory is encrypted for your Windows " L"account.\nNo .NET, WebView or graphics framework.", L"About KSwitcher", MB_OK); @@ -1144,6 +1145,9 @@ void App::trayMenu() case 18: showLogs(*this); break; + case 19: + checkUpdate(true); + break; case 20: pauseUntil = 0; break; @@ -1163,6 +1167,27 @@ void App::trayMenu() report(wide(e.what())); } } +void App::checkUpdate(bool interactive, bool install, const std::wstring &version) +{ + if (updateRunning) return; + if (install && busy) + { + report(L"Wait for the current text operation to finish, then select Update again."); + return; + } + if (updateWorker.joinable()) updateWorker.join(); + updateRunning = true; + installingUpdate = install; + updateWorker = std::thread([this, interactive, install, version] + { + auto result = std::make_unique(); + try { *result = runUpdate(install, version); } + catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); } + result->interactive = interactive; + result->install = install; + if (PostMessageW(window, UpdateMessage, 0, reinterpret_cast(result.get()))) result.release(); + }); +} LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { auto *app = current; @@ -1175,6 +1200,50 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) } switch (msg) { + case UpdateMessage: + { + std::unique_ptr result(reinterpret_cast(lp)); + if (app->updateWorker.joinable()) app->updateWorker.join(); + app->updateRunning = false; + app->installingUpdate = false; + if (result->state == L"prepared") + { + // The helper has verified and staged the package; it waits for process exit. + DestroyWindow(hwnd); + } + else if (result->state == L"available") + { + app->availableUpdate = result->version; + if (result->interactive) + { + auto prompt = L"Install KSwitcher " + result->version + + L"?\n\nThe program will close and restart. Settings, history and existing dictionaries are preserved."; + if (MessageBoxW(hwnd, prompt.c_str(), L"KSwitcher Update", MB_YESNO | MB_ICONQUESTION) == IDYES) + app->checkUpdate(true, true, result->version); + } + else + { + NOTIFYICONDATAW icon{sizeof(icon)}; + icon.hWnd = hwnd; icon.uID = 1; icon.uFlags = NIF_INFO; icon.dwInfoFlags = NIIF_INFO; + wcscpy_s(icon.szInfoTitle, L"KSwitcher update available"); + auto text = result->version + L" is available. Select Update in the tray menu to install."; + wcsncpy_s(icon.szInfo, text.c_str(), _TRUNCATE); + Shell_NotifyIconW(NIM_MODIFY, &icon); + } + } + else if (result->state == L"current") + { + app->availableUpdate.clear(); + if (result->interactive) + MessageBoxW(hwnd, (std::wstring(L"No newer stable release is available.\nInstalled version: ") + AppVersion).c_str(), + L"KSwitcher Update", MB_OK | MB_ICONINFORMATION); + } + else if (result->interactive) + MessageBoxW(hwnd, (L"Update failed.\n\n" + result->error).c_str(), L"KSwitcher Update", MB_OK | MB_ICONERROR); + else + diagnosticEvent(L"Startup update check failed: " + result->error); + return 0; + } case WM_HOTKEY: app->dispatch((int)wp); return 0; @@ -1192,10 +1261,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) app->captureHistory(); return 0; case AutoCorrectMessage: + if (app->installingUpdate) return 0; app->pendingAutoVersion = (uint64_t)wp; SetTimer(hwnd, 3, 250, nullptr); return 0; case SnippetMessage: + if (app->installingUpdate) return 0; app->transform(0, (int)wp); return 0; case HistoryMessage: @@ -1227,6 +1298,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) return TRUE; } case WM_TIMER: + if (wp == 5) + { + KillTimer(hwnd, 5); + app->checkUpdate(false); + return 0; + } if (wp == 4) { if (GetAsyncKeyState(VK_LBUTTON) & 0x8000) @@ -1238,7 +1315,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) if (wp == 3) { KillTimer(hwnd, 3); - if (app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion) + if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion) app->transform(AutoCorrect); return 0; } @@ -1255,6 +1332,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) } return 0; case WM_CLOSE: + if (app->updateRunning) + { + app->report(L"Wait for the update check or preparation to finish before exiting."); + return 0; + } if (app->busy) { app->report(L"Wait for the current text operation to finish before exiting."); @@ -1324,6 +1406,10 @@ int App::execute() report(registrationWarning); if (smoke) SetTimer(window, 2, 10000, nullptr); + else + SetTimer(window, 5, 5000, nullptr); + if (keyboardHook && mouseHook) + confirmUpdatedStartup(updateStartupEvent); if (startCommand) PostMessageW(window, WM_HOTKEY, startCommand, 0); MSG msg; diff --git a/src/app.h b/src/app.h index ca84eff..f5838a9 100644 --- a/src/app.h +++ b/src/app.h @@ -1,6 +1,7 @@ #pragma once #include "autocorrect.h" #include "storage.h" +#include "update.h" #include #include #include @@ -49,6 +50,7 @@ class App std::wstring registrationWarning; bool smoke{}; int startCommand{}; + std::wstring updateStartupEvent; App(HINSTANCE, bool = false, const std::filesystem::path &dataRoot = {}); ~App(); int execute(); @@ -65,16 +67,22 @@ class App void applySettings(const Settings &); void reloadLayouts(); bool isHotkey(DWORD, UINT); - void keyboard(const KBDLLHOOKSTRUCT &); + void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers); + bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers); void resetInput(); + void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {}); static App *current; private: + friend struct InputRegressionAccess; HHOOK keyboardHook{}, mouseHook{}; std::mutex jobsMutex; std::condition_variable jobsReady; std::deque> jobs; std::thread worker; + std::thread updateWorker; + bool updateRunning{}, installingUpdate{}; + std::wstring availableUpdate; bool stop{}; UINT taskbarCreated{}; uint64_t pendingAutoVersion{}; @@ -90,7 +98,7 @@ class App }; void showHistory(App &); void refreshHistory(App &); -void showSettings(App &); +void showSettings(App &, bool visible = true); void showLogs(App &); void showSnippets(App &); void showShortcuts(App &); diff --git a/src/main.cpp b/src/main.cpp index bb97cd6..c895798 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -67,7 +67,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int) try { ks::App app(instance, arg == L"--smoke-test"); - if (!arg.empty() && arg != L"--smoke-test") + if (arg.starts_with(L"--update-ready-event=")) + app.updateStartupEvent = arg.substr(std::wstring(L"--update-ready-event=").size()); + else if (!arg.empty() && arg != L"--smoke-test") app.startCommand = cmd; result = app.execute(); } diff --git a/src/platform.cpp b/src/platform.cpp index d9e9e29..93f4c06 100644 --- a/src/platform.cpp +++ b/src/platform.cpp @@ -347,15 +347,30 @@ size_t Clip::size() const n += f.data.size(); return n; } +ULONG_PTR inputMarker() +{ + static const char marker{}; + return reinterpret_cast(&marker); +} +bool ownKeyboardInput(const KBDLLHOOKSTRUCT &key) +{ + return (key.flags & LLKHF_INJECTED) && key.dwExtraInfo == inputMarker(); +} +INPUT keyboardInput(WORD key, DWORD flags) +{ + INPUT input{}; + input.type = INPUT_KEYBOARD; + input.ki.wVk = key; + input.ki.dwFlags = flags; + input.ki.dwExtraInfo = inputMarker(); + return input; +} bool chord(WORD key, UINT mods) { std::vector inputs; auto add = [&](WORD k, bool up) { - INPUT i{}; - i.type = INPUT_KEYBOARD; - i.ki.wVk = k; - i.ki.dwFlags = up ? KEYEVENTF_KEYUP : 0; + INPUT i = keyboardInput(k, up ? KEYEVENTF_KEYUP : 0); if (k == VK_LEFT || k == VK_RIGHT || k == VK_INSERT) i.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY; inputs.push_back(i); @@ -406,10 +421,7 @@ bool selectPrevious(size_t count, const std::function &valid) std::vector inputs; auto add = [&](WORD key, DWORD flags) { - INPUT i{}; - i.type = INPUT_KEYBOARD; - i.ki.wVk = key; - i.ki.dwFlags = flags; + INPUT i = keyboardInput(key, flags); inputs.push_back(i); }; add(VK_SHIFT, 0); @@ -437,10 +449,7 @@ void releaseModifiers() for (int k : {VK_LCONTROL, VK_RCONTROL, VK_LMENU, VK_RMENU, VK_LSHIFT, VK_RSHIFT, VK_LWIN, VK_RWIN}) if (GetAsyncKeyState(k) & 0x8000) { - INPUT i{}; - i.type = INPUT_KEYBOARD; - i.ki.wVk = (WORD)k; - i.ki.dwFlags = KEYEVENTF_KEYUP; + INPUT i = keyboardInput((WORD)k, KEYEVENTF_KEYUP); inputs.push_back(i); } if (!inputs.empty()) diff --git a/src/platform.h b/src/platform.h index becade6..f34e563 100644 --- a/src/platform.h +++ b/src/platform.h @@ -36,6 +36,10 @@ Clip readClipboard(HWND, size_t limit = 32 * 1024 * 1024); DWORD writeClipboard(HWND, const Clip &); Clip textClip(const std::wstring &); bool chord(WORD key, UINT modifiers = 0); +// Process-local marker, shared by every KSwitcher input producer (not an authentication token). +ULONG_PTR inputMarker(); +bool ownKeyboardInput(const KBDLLHOOKSTRUCT &); +INPUT keyboardInput(WORD key, DWORD flags = 0); bool needsSelectionDelay(HWND); bool selectPrevious(size_t count, const std::function &valid); bool modifiersReleased(); diff --git a/src/ui.cpp b/src/ui.cpp index 056ce99..baa1dc3 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -2,6 +2,7 @@ #include "app.h" #include "ocr.h" #include "richtext.h" +#include "ui_geometry.h" #include #include #include @@ -26,6 +27,129 @@ HFONT font(HWND w) OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH, L"Segoe UI"); } +struct WindowLayout +{ + int width{}, height{}, x{}, y{}; + bool scrolling{}, arranging{}; + int wheelRemainder{}, horizontalWheelRemainder{}; + std::vector> controls; // Original 96-DPI geometry; never rescale rounded pixels. +}; +WindowLayout *windowLayoutState(HWND w) +{ + return reinterpret_cast(GetPropW(w, L"WindowLayout")); +} +RECT workArea(RECT rect) +{ + MONITORINFO monitor{sizeof(monitor)}; + GetMonitorInfoW(MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST), &monitor); + return monitor.rcWork; +} +void fitToMonitor(HWND w, RECT rect) +{ + rect = fitWindowRect(rect, workArea(rect)); + SetWindowPos(w, nullptr, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, + SWP_NOACTIVATE | SWP_NOZORDER); +} +void arrangeWindow(HWND w) +{ + auto *layout = windowLayoutState(w); + if (!layout || !layout->scrolling || layout->arranging) return; + layout->arranging = true; + // Showing either scrollbar can make the other necessary. Let the client size settle. + for (int pass = 0; pass < 3; ++pass) + { + RECT client{}; + GetClientRect(w, &client); + SCROLLINFO horizontal{sizeof(horizontal), SIF_RANGE | SIF_PAGE | SIF_POS, + 0, scale(w, layout->width) - 1, (UINT)client.right, layout->x}; + SCROLLINFO vertical{sizeof(vertical), SIF_RANGE | SIF_PAGE | SIF_POS, + 0, scale(w, layout->height) - 1, (UINT)client.bottom, layout->y}; + layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE); + layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE); + } + for (const auto &[child, r] : layout->controls) + SetWindowPos(child, nullptr, scale(w, r.left) - layout->x, scale(w, r.top) - layout->y, + scale(w, r.right - r.left), scale(w, r.bottom - r.top), SWP_NOACTIVATE | SWP_NOZORDER); + layout->arranging = false; + InvalidateRect(w, nullptr, TRUE); +} +void revealControl(HWND child) +{ + HWND parent = GetParent(child); + auto *layout = windowLayoutState(parent); + if (!layout || !layout->scrolling) return; + RECT r{}, client{}; + GetWindowRect(child, &r); + MapWindowPoints(nullptr, parent, reinterpret_cast(&r), 2); + GetClientRect(parent, &client); + if (r.left < 0) layout->x += r.left; + else if (r.right > client.right) layout->x += r.right - client.right; + if (r.top < 0) layout->y += r.top; + else if (r.bottom > client.bottom) layout->y += r.bottom - client.bottom; + arrangeWindow(parent); +} +LRESULT CALLBACK scrollChildProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR) +{ + if (msg == WM_SETFOCUS) revealControl(w); + if (msg == WM_NCDESTROY) RemoveWindowSubclass(w, scrollChildProc, 2); + return DefSubclassProc(w, msg, wp, lp); +} +void resizeForDpi(HWND, WPARAM, LPARAM); +LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR) +{ + auto *layout = windowLayoutState(w); + if (!layout) return DefSubclassProc(w, msg, wp, lp); + if (msg == WM_DPICHANGED) + { + resizeForDpi(w, wp, lp); + return 0; + } + if (msg == WM_SIZE) arrangeWindow(w); + if (msg == WM_DISPLAYCHANGE || msg == WM_SETTINGCHANGE || (msg == WM_SHOWWINDOW && wp)) + { + RECT rect{}; + GetWindowRect(w, &rect); + fitToMonitor(w, rect); + arrangeWindow(w); + } + if (layout->scrolling && (msg == WM_VSCROLL || msg == WM_HSCROLL)) + { + bool vertical = msg == WM_VSCROLL; + SCROLLINFO info{sizeof(info), SIF_ALL}; + GetScrollInfo(w, vertical ? SB_VERT : SB_HORZ, &info); + int &position = vertical ? layout->y : layout->x; + switch (LOWORD(wp)) + { + case SB_LINEUP: position -= scale(w, 32); break; + case SB_LINEDOWN: position += scale(w, 32); break; + case SB_PAGEUP: position -= (int)info.nPage; break; + case SB_PAGEDOWN: position += (int)info.nPage; break; + case SB_TOP: position = 0; break; + case SB_BOTTOM: position = info.nMax; break; + case SB_THUMBTRACK: case SB_THUMBPOSITION: position = info.nTrackPos; break; + } + arrangeWindow(w); + return 0; + } + if (layout->scrolling && (msg == WM_MOUSEWHEEL || msg == WM_MOUSEHWHEEL)) + { + bool horizontal = msg == WM_MOUSEHWHEEL || (GET_KEYSTATE_WPARAM(wp) & MK_SHIFT); + int &remainder = horizontal ? layout->horizontalWheelRemainder : layout->wheelRemainder; + remainder += GET_WHEEL_DELTA_WPARAM(wp); + int steps = remainder / WHEEL_DELTA; + remainder %= WHEEL_DELTA; + (horizontal ? layout->x : layout->y) += steps * scale(w, 48) * (msg == WM_MOUSEHWHEEL ? 1 : -1); + arrangeWindow(w); + return 0; + } + if (msg == WM_NCDESTROY) + { + RemovePropW(w, L"WindowLayout"); + RemoveWindowSubclass(w, layoutProc, 2); + delete layout; + } + return DefSubclassProc(w, msg, wp, lp); +} HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style, int id, int x, int y, int width, int height) { @@ -34,6 +158,12 @@ HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style, scale(parent, width), scale(parent, height), parent, (HMENU)(INT_PTR)id, GetModuleHandleW(nullptr), nullptr); SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE); + if (auto *layout = windowLayoutState(parent); layout && layout->scrolling) + { + layout->controls.push_back({w, {x, y, x + width, y + height}}); + SetWindowSubclass(w, scrollChildProc, 2, 0); + arrangeWindow(parent); + } return w; } void label(HWND w, const wchar_t *text, int x, int y, int width = 200) @@ -136,13 +266,31 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc RegisterClassW(&wc); UINT dpi = GetDpiForSystem(); RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)}; - DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | extra; + DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_THICKFRAME | WS_MAXIMIZEBOX | extra; AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi); + POINT cursor{}; + GetCursorPos(&cursor); + MONITORINFO monitor{sizeof(monitor)}; + GetMonitorInfoW(MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST), &monitor); HWND w = - CreateWindowExW(WS_EX_APPWINDOW, cls, title, style, CW_USEDEFAULT, CW_USEDEFAULT, + CreateWindowExW(WS_EX_APPWINDOW, cls, title, style, monitor.rcWork.left, monitor.rcWork.top, rect.right - rect.left, rect.bottom - rect.top, nullptr, nullptr, app.instance, &app); if (!w) throw std::runtime_error(utf8(lastError(L"Cannot create native window"))); + // The system DPI may still describe the old primary display. The HWND tells + // us the actual destination DPI, and must size the frame AND its controls. + dpi = GetDpiForWindow(w); + rect = {0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)}; + AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi); + int outerWidth = rect.right - rect.left, outerHeight = rect.bottom - rect.top; + int left = monitor.rcWork.left + std::max(0L, (monitor.rcWork.right - monitor.rcWork.left - outerWidth) / 2); + int top = monitor.rcWork.top + std::max(0L, (monitor.rcWork.bottom - monitor.rcWork.top - outerHeight) / 2); + SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)}); + SetWindowSubclass(w, layoutProc, 2, 0); + RECT initial = fitWindowRect({left, top, left + outerWidth, top + outerHeight}, monitor.rcWork); + SetWindowPos(w, nullptr, initial.left, initial.top, initial.right - initial.left, initial.bottom - initial.top, + SWP_NOACTIVATE | SWP_NOZORDER); + arrangeWindow(w); SetWindowTextW(w, title); auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON, GetSystemMetricsForDpi(SM_CXSMICON, dpi), @@ -154,6 +302,12 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc } void resizeForDpi(HWND w, WPARAM wp, LPARAM lp) { + // Creation may send WM_DPICHANGED before controls and the original font exist. + if (!GetPropW(w, L"Dpi")) + { + fitToMonitor(w, *(RECT *)lp); + return; + } int previous = (int)(UINT_PTR)GetPropW(w, L"Dpi"); int next = LOWORD(wp); if (!previous) @@ -166,7 +320,8 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp) int previous, next; HFONT font; } state{w, previous, next, replacement}; - EnumChildWindows( + auto *layout = windowLayoutState(w); + if (!layout || !layout->scrolling) EnumChildWindows( w, [](HWND child, LPARAM data) -> BOOL { @@ -183,12 +338,19 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp) return TRUE; }, (LPARAM)&state); + else + { + layout->x = MulDiv(layout->x, next, previous); + layout->y = MulDiv(layout->y, next, previous); + for (auto &[child, rect] : layout->controls) + SendMessageW(child, WM_SETFONT, (WPARAM)replacement, TRUE); + } SetPropW(w, L"Font", replacement); SetPropW(w, L"Dpi", (HANDLE)(UINT_PTR)next); if (old) DeleteObject(old); - auto *r = (RECT *)lp; - SetWindowPos(w, nullptr, r->left, r->top, r->right - r->left, r->bottom - r->top, SWP_NOZORDER); + fitToMonitor(w, *(RECT *)lp); + arrangeWindow(w); } std::filesystem::path pickFile(HWND owner, bool save) { @@ -1447,7 +1609,7 @@ void showLogs(App &app) showRequestedWindow(w); activateWindow(w); } -void showSettings(App &app) +void showSettings(App &app, bool visible) { if (app.settingsWindow) { @@ -1505,8 +1667,11 @@ void showSettings(App &app) button(w, L"Save", IDOK, 548, 606, 100); button(w, L"Cancel", IDCANCEL, 660, 606, 100); fillSettings(w, (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA)); - showRequestedWindow(w); - activateWindow(w); + if (visible) + { + showRequestedWindow(w); + activateWindow(w); + } } void showSnippets(App &app) { diff --git a/src/ui_geometry.h b/src/ui_geometry.h new file mode 100644 index 0000000..9f169f8 --- /dev/null +++ b/src/ui_geometry.h @@ -0,0 +1,14 @@ +#pragma once +#include "core.h" +#include +namespace ks +{ +inline RECT fitWindowRect(RECT desired, RECT work) +{ + LONG width = std::clamp(desired.right - desired.left, 1, std::max(1, work.right - work.left)); + LONG height = std::clamp(desired.bottom - desired.top, 1, std::max(1, work.bottom - work.top)); + LONG x = std::clamp(desired.left, work.left, work.right - width); + LONG y = std::clamp(desired.top, work.top, work.bottom - height); + return {x, y, x + width, y + height}; +} +} diff --git a/src/update.cpp b/src/update.cpp new file mode 100644 index 0000000..1a3a3a8 --- /dev/null +++ b/src/update.cpp @@ -0,0 +1,103 @@ +#include "update.h" +#include "json.hpp" + +namespace ks +{ +namespace +{ +struct Handle +{ + HANDLE value{}; + ~Handle() { if (value) CloseHandle(value); } +}; +std::wstring quoteArgument(const std::wstring &value) +{ + // Windows argv escaping, including a path ending with a backslash. + std::wstring out = L"\""; + size_t slashes = 0; + for (auto ch : value) + { + if (ch == L'\\') { ++slashes; continue; } + out.append(ch == L'"' ? slashes * 2 + 1 : slashes, L'\\'); + slashes = 0; + out += ch; + } + out.append(slashes * 2, L'\\'); + return out + L'"'; +} +} +UpdateResult runUpdate(bool install, const std::wstring &expectedVersion) +{ + UpdateResult result; + GUID guid{}; + if (FAILED(CoCreateGuid(&guid))) throw std::runtime_error("Cannot create update identifier"); + wchar_t id[40]{}; + StringFromGUID2(guid, id, 40); + auto work = std::filesystem::temp_directory_path() / (L"KSwitcher-update-" + std::wstring(id)); + std::filesystem::create_directory(work); + bool detached = false; + Handle child; + try + { + HRSRC resource = FindResourceW(nullptr, MAKEINTRESOURCEW(201), RT_RCDATA); + HGLOBAL loaded = resource ? LoadResource(nullptr, resource) : nullptr; + auto bytes = loaded ? static_cast(LockResource(loaded)) : nullptr; + if (!bytes) throw std::runtime_error("The updater resource is missing"); + atomicWrite(work / L"update.ps1", Bytes(bytes, bytes + SizeofResource(nullptr, resource))); + std::wstring readyName = L"Local\\KSwitcher.Update.Ready." + std::wstring(id); + Handle ready{CreateEventW(nullptr, TRUE, FALSE, readyName.c_str())}; + if (!ready.value) throw std::runtime_error("Cannot create updater handshake"); + wchar_t system[MAX_PATH]{}; + if (!GetSystemDirectoryW(system, MAX_PATH)) throw std::runtime_error("Cannot locate Windows PowerShell"); + auto powershell = std::filesystem::path(system) / L"WindowsPowerShell/v1.0/powershell.exe"; + std::wstring command = quoteArgument(powershell.wstring()) + L" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " + + quoteArgument((work / L"update.ps1").wstring()) + L" -Mode " + (install ? L"Install" : L"Check") + + L" -CurrentVersion " + quoteArgument(AppVersion) + L" -WorkDirectory " + quoteArgument(work.wstring()) + + L" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) + + L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName); + if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion); + STARTUPINFOW startup{sizeof(startup)}; + PROCESS_INFORMATION process{}; + if (!CreateProcessW(powershell.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, + nullptr, work.c_str(), &startup, &process)) + throw std::runtime_error("Cannot start Windows PowerShell updater"); + child.value = process.hProcess; + Handle thread{process.hThread}; + HANDLE waits[]{ready.value, child.value}; + DWORD waited = WaitForMultipleObjects(2, waits, FALSE, 240000); + if (waited != WAIT_OBJECT_0 && waited != WAIT_OBJECT_0 + 1) + { + // The helper cannot replace files while this process is alive. + TerminateProcess(child.value, 1); + WaitForSingleObject(child.value, 5000); + throw std::runtime_error("Update preparation timed out; the installed application was not changed"); + } + auto data = readFile(work / L"result.json", 64 * 1024); + auto json = nlohmann::json::parse(data.begin(), data.end()); + result.state = wide(json.at("state").get()); + result.version = wide(json.value("version", std::string{})); + result.error = wide(json.value("error", std::string{})); + detached = install && waited == WAIT_OBJECT_0 && result.state == L"prepared"; + if (result.state == L"prepared" && !detached) + throw std::runtime_error("Update helper exited before installation"); + if (!detached) std::filesystem::remove_all(work); + return result; + } + catch (...) + { + if (!detached && child.value && WaitForSingleObject(child.value, 0) == WAIT_TIMEOUT) + { + TerminateProcess(child.value, 1); + WaitForSingleObject(child.value, 5000); + } + if (!detached) { std::error_code ignored; std::filesystem::remove_all(work, ignored); } + throw; + } +} +void confirmUpdatedStartup(const std::wstring &eventName) +{ + if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return; + Handle event{OpenEventW(EVENT_MODIFY_STATE, FALSE, eventName.c_str())}; + if (event.value) SetEvent(event.value); +} +} diff --git a/src/update.h b/src/update.h new file mode 100644 index 0000000..f60d18e --- /dev/null +++ b/src/update.h @@ -0,0 +1,14 @@ +#pragma once +#include "platform.h" +namespace ks +{ +inline constexpr wchar_t AppVersion[] = L"0.2.1"; +inline constexpr UINT UpdateMessage = WM_APP + 7; +struct UpdateResult +{ + std::wstring state, version, error; + bool interactive{}, install{}; +}; +UpdateResult runUpdate(bool install, const std::wstring &expectedVersion); +void confirmUpdatedStartup(const std::wstring &eventName); +} diff --git a/tests/input.cpp b/tests/input.cpp new file mode 100644 index 0000000..2ac6225 --- /dev/null +++ b/tests/input.cpp @@ -0,0 +1,122 @@ +#include "app.h" +#include "ui_geometry.h" +#include +#include +namespace ks { +struct InputRegressionAccess { + static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; } + static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); } +}; +} +int main(int argc, char **argv) { + if (argc == 2 && std::string(argv[1]) == "--update-check") { + try { + auto result = ks::runUpdate(false, {}); + std::cout << "Live update check: " << ks::utf8(result.state) << " " << ks::utf8(result.version) + << " " << ks::utf8(result.error) << '\n'; + return result.state == L"current" || result.state == L"available" ? 0 : 1; + } catch (const std::exception &e) { std::cerr << e.what() << '\n'; return 1; } + } + auto temp=std::filesystem::temp_directory_path()/(L"KSwitcher-input-tests-"+std::to_wstring(GetCurrentProcessId())); + int checks=0; + auto require=[&](bool ok,const char* text) { if(!ok) throw std::runtime_error(text); ++checks; }; + try { + INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_LISTVIEW_CLASSES}; + InitCommonControlsEx(&controls); + { + ks::App app(GetModuleHandleW(nullptr),true,temp); + ks::InputRegressionAccess::setup(app); + // Message-only window: no hooks, synthetic input, clipboard changes or visible editor. + app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr); + require(app.window!=nullptr,"message-only test window"); + auto event=[](DWORD vk,DWORD scan=0,bool own=false) { + KBDLLHOOKSTRUCT k{}; k.vkCode=vk; k.scanCode=scan; + k.flags=LLKHF_INJECTED|LLKHF_LOWER_IL_INJECTED; + k.dwExtraInfo=own?ks::inputMarker():123; return k; + }; + auto commands=[&] { MSG m{}; int n=0; while(PeekMessageW(&m,app.window,WM_HOTKEY,WM_HOTKEY,PM_REMOVE)) ++n; return n; }; + auto f12=event(VK_F12); + require(!app.handleKeyboard(event(VK_LCONTROL),true,0),"remote modifier passes through"); + require(app.handleKeyboard(f12,true,MOD_CONTROL),"remote Ctrl+F12 handled"); + require(app.handleKeyboard(f12,true,MOD_CONTROL),"remote repeat suppressed"); + require(commands()==1,"one command per press"); + require(!app.handleKeyboard(event(VK_F12,0,true),false,0),"own key-up bypasses latch"); + require(app.handleKeyboard(f12,true,MOD_CONTROL),"own key-up leaves remote latch intact"); + require(commands()==0,"no repeat after own key-up"); + require(app.handleKeyboard(f12,false,0),"remote release consumed after modifier release"); + require(app.handleKeyboard(f12,true,MOD_CONTROL),"second remote press handled"); + require(commands()==1,"second remote press dispatches"); + app.handleKeyboard(f12,false,0); + require(app.handleKeyboard(event('U'),true,MOD_CONTROL|MOD_SHIFT),"combined modifiers"); + app.handleKeyboard(event('U'),false,0); + require(!app.handleKeyboard(f12,true,MOD_CONTROL|MOD_SHIFT),"exact modifiers only"); + app.resetInput(); + app.handleKeyboard(event('A',0x1e),true,0); + require(app.run.keys==L"a"||app.run.keys==L"A","external scan recorded with current CapsLock"); + auto keys=app.run.keys; auto version=app.inputVersion.load(); + for(WORD key:{WORD('V'),WORD(VK_SHIFT),WORD(VK_LEFT),WORD(VK_F15),WORD(0xE8)}) + for(bool up:{false,true}) { + auto i=ks::keyboardInput(key,up?KEYEVENTF_KEYUP:0); + require(i.ki.dwExtraInfo==ks::inputMarker(),"all generated events tagged"); + require(!app.handleKeyboard(event(key,0,true),!up,MOD_CONTROL),"own events bypass handling"); + } + require(app.run.keys==keys&&app.inputVersion==version,"own input preserves transaction and run"); + app.handleKeyboard(event(VK_BACK),true,0); + require(app.run.keys.empty(),"remote backspace recorded"); + app.handleKeyboard(event('B'),true,MOD_SHIFT); + require(!app.run.keys.empty(),"VK-only remote input resolves scan code"); + require(app.inputVersion>version,"remote input invalidates pending transaction"); + app.handleKeyboard(event(VK_PACKET,0x1e),true,0); + require(app.run.keys.empty(),"Unicode packet is not a physical scan code"); + app.handleKeyboard(event('A',0x1e),true,0); + ks::InputRegressionAccess::click(); + require(app.run.keys.empty(),"remote mouse resets recorded input"); + auto physical=f12; physical.flags=0; physical.dwExtraInfo=ks::inputMarker(); + require(app.handleKeyboard(physical,true,MOD_CONTROL),"physical hotkey still works"); + app.handleKeyboard(physical,false,0); + auto fitted=ks::fitWindowRect({-2500,-800,-500,1200},{0,0,1200,800}); + require(fitted.left==0&&fitted.top==0&&fitted.right==1200&&fitted.bottom==800,"oversized disconnected-monitor window fits work area"); + fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080}); + require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported"); + ks::showSettings(app,false); + auto settings=app.settingsWindow; + require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus"); + RECT frame{},client{},cancel{}; + GetWindowRect(settings,&frame); + MONITORINFO monitor{sizeof(monitor)}; + GetMonitorInfoW(MonitorFromWindow(settings,MONITOR_DEFAULTTONEAREST),&monitor); + require(frame.left>=monitor.rcWork.left&&frame.top>=monitor.rcWork.top&&frame.right<=monitor.rcWork.right&&frame.bottom<=monitor.rcWork.bottom,"initial settings frame fits active work area"); + UINT dpi=GetDpiForWindow(settings); + std::cout << "Hidden Settings layout checked at " << dpi << " DPI\n"; + GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel); + require(cancel.right-cancel.left==MulDiv(100,dpi,96),"controls use actual window DPI"); + SetWindowPos(settings,nullptr,0,0,480,360,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE); + SCROLLINFO scroll{sizeof(scroll),SIF_ALL}; + GetScrollInfo(settings,SB_VERT,&scroll); + require(scroll.nMax+1>(int)scroll.nPage,"small settings window has vertical scrolling"); + GetScrollInfo(settings,SB_HORZ,&scroll); + require(scroll.nMax+1>(int)scroll.nPage,"small settings window has horizontal scrolling"); + SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0); + SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0); + GetClientRect(settings,&client); + GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel); + MapWindowPoints(nullptr,settings,(POINT*)&cancel,2); + require(cancel.left>=0&&cancel.top>=0&&cancel.right<=client.right&&cancel.bottom<=client.bottom,"bottom actions reachable by scrolling"); + SendMessageW(settings,WM_VSCROLL,SB_TOP,0); + SendMessageW(settings,WM_HSCROLL,SB_LEFT,0); + SendMessageW(GetDlgItem(settings,IDCANCEL),WM_SETFOCUS,0,0); + GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel); + MapWindowPoints(nullptr,settings,(POINT*)&cancel,2); + require(cancel.left>=0&&cancel.top>=0&&cancel.right<=client.right&&cancel.bottom<=client.bottom,"keyboard focus reveals off-screen action"); + SetWindowPos(settings,nullptr,-20000,-20000,0,0,SWP_NOSIZE|SWP_NOZORDER|SWP_NOACTIVATE); + SendMessageW(settings,WM_DISPLAYCHANGE,0,0); + GetWindowRect(settings,&frame); + GetMonitorInfoW(MonitorFromWindow(settings,MONITOR_DEFAULTTONEAREST),&monitor); + require(frame.left>=monitor.rcWork.left&&frame.top>=monitor.rcWork.top&&frame.right<=monitor.rcWork.right&&frame.bottom<=monitor.rcWork.bottom,"display change recovers off-screen window"); + DestroyWindow(settings); + DestroyWindow(app.window); app.window=nullptr; + } + std::filesystem::remove_all(temp); + std::cout<<"Passed "<