Release v0.2.1: support remote input, safe updates and high-DPI windows

This commit is contained in:
2026-10-28 11:02:15 +02:00
parent cf18a0a3c5
commit c8fedfff1c
19 changed files with 994 additions and 112 deletions
+8 -3
View File
@@ -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$<$<CONFIG:Debug>:Debug>")
set_property(TARGET kscore KSwitcher KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>: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 "$<TARGET_FILE:KSwitcher>")
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 .)
+43 -3
View File
@@ -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
Binary file not shown.
+22 -17
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
7138fee6aa1e9c5e66aa212798c4cc193a987f088a45ec37a355b46cf9a40d08 KSwitcher-win-x64.zip
e33c372d1401c5500f3b07b38c10aff9667a5b1db9f6e613420e7878202853b3 KSwitcher-win-x64.zip
+10 -4
View File
@@ -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' }
+4 -3
View File
@@ -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"
+211
View File
@@ -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
}
}
}
+144 -58
View File
@@ -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<UpdateResult>();
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<LPARAM>(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<UpdateResult> result(reinterpret_cast<UpdateResult *>(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;
+10 -2
View File
@@ -1,6 +1,7 @@
#pragma once
#include "autocorrect.h"
#include "storage.h"
#include "update.h"
#include <atomic>
#include <condition_variable>
#include <deque>
@@ -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<std::function<void()>> 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 &);
+3 -1
View File
@@ -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();
}
+21 -12
View File
@@ -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<ULONG_PTR>(&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<INPUT> 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<bool()> &valid)
std::vector<INPUT> 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())
+4
View File
@@ -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<bool()> &valid);
bool modifiersReleased();
+173 -8
View File
@@ -2,6 +2,7 @@
#include "app.h"
#include "ocr.h"
#include "richtext.h"
#include "ui_geometry.h"
#include <algorithm>
#include <commctrl.h>
#include <commdlg.h>
@@ -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<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
};
WindowLayout *windowLayoutState(HWND w)
{
return reinterpret_cast<WindowLayout *>(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<POINT *>(&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)
{
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include "core.h"
#include <algorithm>
namespace ks
{
inline RECT fitWindowRect(RECT desired, RECT work)
{
LONG width = std::clamp<LONG>(desired.right - desired.left, 1, std::max<LONG>(1, work.right - work.left));
LONG height = std::clamp<LONG>(desired.bottom - desired.top, 1, std::max<LONG>(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};
}
}
+103
View File
@@ -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<const unsigned char *>(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<std::string>());
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);
}
}
+14
View File
@@ -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);
}
+122
View File
@@ -0,0 +1,122 @@
#include "app.h"
#include "ui_geometry.h"
#include <commctrl.h>
#include <iostream>
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 "<<checks<<" input regression checks\n"; return 0;
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; return 1; }
}
+87
View File
@@ -0,0 +1,87 @@
param([string]$Executable = (Join-Path $PSScriptRoot '../build/KSwitcher.exe'), [switch]$Network)
$ErrorActionPreference='Stop'
. (Join-Path $PSScriptRoot '../resources/update.ps1') -Library
$script:checks=0
function Assert($Value,[string]$Name) { if (!$Value) { throw $Name }; ++$script:checks }
function Reject([scriptblock]$Action,[string]$Name) { $rejected=$false; try { & $Action } catch { $rejected=$true }; Assert $rejected $Name }
$root=Join-Path ([IO.Path]::GetTempPath()) ('KSwitcher-update-tests-'+[guid]::NewGuid().ToString('N'))
[IO.Directory]::CreateDirectory($root) | Out-Null
try {
Assert ((Get-StableVersion 'v0.10.0') -gt (Get-StableVersion 'v0.2.9')) 'numeric versions'
Assert ($null -eq (Get-StableVersion 'v1.0.0-beta')) 'prerelease rejected'
Assert ($null -eq (Get-StableVersion 'v1.0.0;whoami')) 'invalid version rejected'
$assets=@(
@{name='KSwitcher-win-x64.zip';browser_download_url='https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/v0.2.1/KSwitcher-win-x64.zip'},
@{name='SHA256SUMS.txt';browser_download_url='https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/v0.2.1/SHA256SUMS.txt'}
)
$release=@{draft=$false;prerelease=$false;tag_name='v0.2.1';assets=$assets}
Assert ((Select-Release @($release) ([version]'0.2.0')).version -eq 'v0.2.1') 'newer release'
Assert ($null -eq (Select-Release @($release) ([version]'0.2.1'))) 'same version'
Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade'
$draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets}
Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored'
Reject { Select-Release @(@{draft=$false;prerelease=$false;tag_name='v2.0.0';assets=@()}) ([version]'0.2.0') } 'incomplete release rejected'
foreach ($url in @('http://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/v1/a','https://evil.example/a','https://gitea.hrynco.com/other/a','https://gitea.hrynco.com:8443/hrynco/hrynco-kswitcher/releases/download/v1/a')) {
Reject { Assert-ReleaseUrl $url } 'untrusted URL rejected'
}
$source=Join-Path $root 'source'; [IO.Directory]::CreateDirectory($source) | Out-Null
Copy-Item -LiteralPath $Executable -Destination (Join-Path $source 'KSwitcher.exe')
foreach ($name in @('LICENSE','json-LICENSE.txt','README.md')) { [IO.File]::WriteAllText((Join-Path $source $name),'fixture') }
[IO.Directory]::CreateDirectory((Join-Path $source 'dictionaries')) | Out-Null
[IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary')
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive=Join-Path $root 'release.zip'
[IO.Compression.ZipFile]::CreateFromDirectory($source,$archive)
$sum=Join-Path $root 'SHA256SUMS.txt'
((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash+' KSwitcher-win-x64.zip') | Set-Content -LiteralPath $sum
Assert-Checksum $archive $sum; ++$script:checks
$stage=Join-Path $root 'stage'
Expand-Release $archive $stage ([version]'0.2.1'); ++$script:checks
Reject { Expand-Release $archive (Join-Path $root 'wrong-version') ([version]'9.0.0') } 'version mismatch rejected'
('0'*64+' KSwitcher-win-x64.zip') | Set-Content -LiteralPath $sum
Reject { Assert-Checksum $archive $sum } 'corrupt package rejected'
$bad=Join-Path $root 'bad.zip'
$z=[IO.Compression.ZipFile]::Open($bad,[IO.Compression.ZipArchiveMode]::Create)
$null=$z.CreateEntry('../escape.exe'); $z.Dispose()
Reject { Expand-Release $bad (Join-Path $root 'badstage') ([version]'0.2.1') } 'zip traversal rejected'
$target=Join-Path $root 'install'; $backup=Join-Path $root 'backup'
[IO.Directory]::CreateDirectory((Join-Path $target 'data')) | Out-Null
[IO.Directory]::CreateDirectory((Join-Path $target 'dictionaries')) | Out-Null
[IO.File]::WriteAllText((Join-Path $target 'data/settings.json'),'personal settings')
[IO.File]::WriteAllText((Join-Path $target 'dictionaries/en.txt'),'personal dictionary')
[IO.File]::WriteAllText((Join-Path $target 'KSwitcher.exe'),'old binary fixture')
$changed=[Collections.Generic.List[string]]::new()
Install-Files $stage $target $backup $changed
Assert ((Get-FileHash -LiteralPath (Join-Path $target 'KSwitcher.exe')).Hash -eq (Get-FileHash -LiteralPath $Executable).Hash) 'replacement installed'
Assert ([IO.File]::ReadAllText((Join-Path $target 'data/settings.json')) -eq 'personal settings') 'settings preserved'
Assert ([IO.File]::ReadAllText((Join-Path $target 'dictionaries/en.txt')) -eq 'personal dictionary') 'dictionary preserved'
Undo-Files $target $backup $changed
Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'rollback restores original'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'README.md'))) 'rollback removes newly introduced files'
# Fail after the original exe has moved to backup, as if the staged file vanished.
$stage2=Join-Path $root 'stage2'; [IO.Directory]::CreateDirectory($stage2) | Out-Null
[IO.File]::WriteAllText((Join-Path $stage2 'KSwitcher.exe'),'new binary fixture')
$locked=[IO.File]::Open((Join-Path $stage2 'KSwitcher.exe'),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None)
$changed2=[Collections.Generic.List[string]]::new()
try { Reject { Install-Files $stage2 $target $backup $changed2 } 'locked staged file fails safely' } finally { $locked.Dispose() }
Undo-Files $target $backup $changed2
Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'partial install rollback'
if ($Network) {
$metadata=Join-Path $root 'live-releases.json'
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' $metadata 2MB
$live=Select-Release (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json) ([version]'0.0.0')
Assert ($null -ne $live) 'live repository exposes a complete stable release'
$liveZip=Join-Path $root 'live.zip'; $liveSum=Join-Path $root 'live-sums.txt'
Receive-File $live.zip $liveZip 50MB
Receive-File $live.checksum $liveSum 64KB
Assert-Checksum $liveZip $liveSum; ++$script:checks
Expand-Release $liveZip (Join-Path $root 'live-stage') (Get-StableVersion $live.version); ++$script:checks
Reject { Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/kswitcher-updater-missing-test-repository' (Join-Path $root 'missing') 2MB } 'server failure reported'
Write-Output "Downloaded and validated $($live.version) in a disposable test directory; no application was updated."
}
Write-Output "Passed $script:checks updater regression checks"
} finally {
if ([IO.Path]::GetDirectoryName($root) -eq [IO.Path]::GetTempPath().TrimEnd('\') -and (Split-Path -Leaf $root) -like 'KSwitcher-update-tests-*') {
Remove-Item -LiteralPath $root -Recurse -Force
}
}