Compare commits

...

2 Commits

22 changed files with 629 additions and 61 deletions
+13 -6
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.2.2 LANGUAGES CXX RC) project(KSwitcher VERSION 0.3.0 LANGUAGES CXX RC)
if(NOT WIN32) if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs") message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif() endif()
@@ -9,22 +9,29 @@ add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorre
target_include_directories(kscore PUBLIC src PRIVATE vendor) target_include_directories(kscore PUBLIC src PRIVATE vendor)
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00) 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) target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/ui.cpp resources/app.rc) add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
target_link_libraries(KSwitcher PRIVATE kscore) target_link_libraries(KSwitcher PRIVATE kscore)
add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
set_target_properties(KSwitcherConfig PROPERTIES OUTPUT_NAME "KSwitcher.Config")
target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
target_link_libraries(KSwitcherConfig PRIVATE kscore)
add_executable(KSwitcherTests tests/tests.cpp) add_executable(KSwitcherTests tests/tests.cpp)
target_link_libraries(KSwitcherTests PRIVATE kscore) target_link_libraries(KSwitcherTests PRIVATE kscore)
add_executable(KSwitcherIntegration tests/integration.cpp src/app.cpp src/ui.cpp resources/app.rc) add_executable(KSwitcherIntegration tests/integration.cpp src/app.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherIntegration PRIVATE kscore) target_link_libraries(KSwitcherIntegration PRIVATE kscore)
add_executable(KSwitcherInputTests tests/input.cpp src/app.cpp src/ui.cpp resources/app.rc) add_executable(KSwitcherInputTests tests/input.cpp src/app.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherInputTests PRIVATE kscore) target_link_libraries(KSwitcherInputTests PRIVATE kscore)
add_executable(KSwitcherOcrTests tests/ocr.cpp) add_executable(KSwitcherOcrTests tests/ocr.cpp)
target_link_libraries(KSwitcherOcrTests PRIVATE kscore) target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
if(MSVC) if(MSVC)
set_property(TARGET kscore KSwitcher KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>") set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
target_compile_options(kscore PRIVATE /W4 /utf-8) target_compile_options(kscore PRIVATE /W4 /utf-8)
target_compile_options(KSwitcher PRIVATE /W4 /utf-8) target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
else() else()
target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++) target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherTests PRIVATE -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(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
@@ -35,7 +42,7 @@ add_test(NAME native-regressions COMMAND KSwitcherTests)
add_test(NAME input-regressions COMMAND KSwitcherInputTests) 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>") 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}") set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
install(TARGETS KSwitcher RUNTIME DESTINATION .) install(TARGETS KSwitcher KSwitcherConfig RUNTIME DESTINATION .)
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .) install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries") add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
+15 -3
View File
@@ -12,6 +12,16 @@ Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the e
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding. The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
The application lives in the notification area. Right-click its icon for Settings, Snippets and history. The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
Double-click the tray icon to open Settings, the bold default menu item. Double-click the tray icon to open Settings, the bold default menu item.
Settings and the snippet editor run on demand in **KSwitcher.Config.exe**, beside the main executable.
Repeated launches activate the existing configurator; closing its last window exits that process.
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
load clipboard history, dictionaries or global input hooks. Clipboard history and the log viewer remain
in the resident app. Keep both executables together when copying or updating the application.
If settings change in the tray while the configurator is open, a conflicting Save is rejected; reopen
the configurator to load the latest settings.
The first upgrade from v0.2.3 or earlier to this two-executable build requires manual extraction:
the older updater rejects additional executable files. Exit KSwitcher, replace both executables,
and retain `data/` and customized dictionaries. Subsequent updates support both executables.
In Settings, enable **Start with Windows** and **Start with Windows as administrator** In Settings, enable **Start with Windows** and **Start with Windows as administrator**
to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing
the elevated task requests UAC approval if the app is not already elevated. No password is stored; the elevated task requests UAC approval if the app is not already elevated. No password is stored;
@@ -112,7 +122,7 @@ only bounded previews/metadata stay in memory. Image data is loaded on demand an
the history popup is hidden. Settings **Write log** (`writeLog`, enabled by default) controls all the history popup is hidden. Settings **Write log** (`writeLog`, enabled by default) controls all
log writes. Disabling it also clears diagnostic context from memory; existing log files remain. log writes. Disabling it also clears diagnostic context from memory; existing log files remain.
Successful operations stay in a memory buffer of at most 40 diagnostic events for 30 seconds. Successful operations stay in a memory buffer of at most 40 diagnostic events for 30 seconds.
Only failures or suspected repeated commands flush this context to `kswitcher.log`; flushed Only failures flush this context to `kswitcher.log`; flushed
context is discarded. Ordinary application errors are also logged when logging is enabled. context is discarded. Ordinary application errors are also logged when logging is enabled.
Incident context contains tracked physical input, the expected word, copied text, conversion result, Incident context contains tracked physical input, the expected word, copied text, conversion result,
and layout IDs, correlated by request ID even when verification fails. Text fields use quoted, and layout IDs, correlated by request ID even when verification fails. Text fields use quoted,
@@ -121,8 +131,8 @@ original length and a truncation flag; each field is limited to 512 UTF-16 code
Manual conversion attempts in `kswitcher.log` have request IDs and explicit outcomes, including Manual conversion attempts in `kswitcher.log` have request IDs and explicit outcomes, including
busy, empty copy, verification mismatch, unchanged conversion, and cancelled paste. Requests busy, empty copy, verification mismatch, unchanged conversion, and cancelled paste. Requests
record the process, window, input version, and repeated attempts within ten seconds without new record the process, window, input version, and repeated attempts within ten seconds without new
input. A second repeated manual command triggers a suspicion, not proof of failure; intentional input. Successful repeated commands do not flush the log; intentional layout cycling is normal.
layout cycling can also trigger it. An unchanged result containing no letters, or a direct command An unchanged result containing no letters, or a direct command
to the detected current layout, is treated as expected. `paste-sent-unverified` means Windows to the detected current layout, is treated as expected. `paste-sent-unverified` means Windows
accepted the paste shortcut; it does not prove that the editor applied the replacement. accepted the paste shortcut; it does not prove that the editor applied the replacement.
The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates. The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates.
@@ -227,6 +237,7 @@ Run `./package.ps1 -PublishRelease` after building and testing to refresh the tr
.\build.ps1 -Test # Pure/native regression checks; does not touch the user's clipboard .\build.ps1 -Test # Pure/native regression checks; does not touch the user's clipboard
.\build.ps1 -Ocr # Render a synthetic image, decode it with WIC and recognize it with Windows OCR .\build.ps1 -Ocr # Render a synthetic image, decode it with WIC and recognize it with Windows OCR
.\build.ps1 -Integration # Interactive, isolated test editor; avoid input for about 10 seconds .\build.ps1 -Integration # Interactive, isolated test editor; avoid input for about 10 seconds
.\tests\configuration.ps1 # Separate-process settings tests; close KSwitcher first
``` ```
`-Test` also checks remote/own input separation, hidden Settings layout and scrolling, and updater `-Test` also checks remote/own input separation, hidden Settings layout and scrolling, and updater
@@ -255,6 +266,7 @@ It still uses the normal instance lock; close a running KSwitcher first if measu
- `platform.*`: Win32 layouts, clipboard, input, ownership, DPAPI and file primitives. - `platform.*`: Win32 layouts, clipboard, input, ownership, DPAPI and file primitives.
- `storage.*`: settings and encrypted, disk-backed history. - `storage.*`: settings and encrypted, disk-backed history.
- `app.*`: message loop, hooks, background work and guarded text transactions. - `app.*`: message loop, hooks, background work and guarded text transactions.
- `config_main.cpp`, `configuration.*`: single-instance configurator and validated settings IPC.
- `ui.cpp`: native windows and shortcut recording. - `ui.cpp`: native windows and shortcut recording.
- `ocr.*`: Windows WIC and OCR via the native Windows Runtime ABI. - `ocr.*`: Windows WIC and OCR via the native Windows Runtime ABI.
- `tests/`: regression checks, real editor integration and OCR fixture. - `tests/`: regression checks, real editor integration and OCR fixture.
Binary file not shown.
+15 -14
View File
@@ -1,24 +1,25 @@
# Latest Windows release # Latest Windows release
## v0.2.2 — 2026-10-28 ## v0.3.0 — 2026-10-28
- Fix the garbled dash in the executable description shown by Task Manager and Windows file properties. - Move Settings and the snippet editor into the on-demand `KSwitcher.Config.exe` process.
- Explicitly compile the UTF-8 Windows resource source as UTF-8, both in the resource file and the portable build command. - Repeated launches activate the existing configurator. Closing its last window releases the process and its memory.
- Add regression checks against the compiled executable's version and exact Unicode description. - Apply settings immediately through validated IPC with the resident process; reject conflicting saves instead of overwriting newer settings.
- Keep shortcut capture in the resident hook, with no extra global hooks in the configurator.
- Support packaging, validation and rollback of both executables. About now displays the actual application version.
Verified: 168 native checks, 51 input/layout checks, and 26 updater/metadata checks. Verified: 170 native checks, 51 input/layout checks, 28 updater/metadata checks and 21 configurator process checks.
The executable description is `KSwitcher — native keyboard and clipboard utility`. A local before/after measurement after opening and closing Settings reduced resident working set from about 21.0 MiB to 14.7 MiB. Results vary; total memory is higher while the separate configurator is open.
Download **KSwitcher-win-x64.zip**, extract the entire archive to a writable folder, **Upgrade from v0.2.3 or earlier: install this release manually once.** The old updater rejects the additional EXE.
and launch `KSwitcher.exe`. Keep `dictionaries/` next to the executable. Exit KSwitcher, extract the entire ZIP into the installation folder, and retain `data/` and any customized dictionaries.
The archive contains the application, offline dictionaries, documentation and license notices. Keep `KSwitcher.exe`, `KSwitcher.Config.exe` and `dictionaries/` together. Launch `KSwitcher.exe`.
It does not contain personal settings or clipboard history. Subsequent updates support both executables through the tray Update menu.
Download **KSwitcher-win-x64.zip**. The archive contains both applications, offline dictionaries,
documentation and license notices. It does not contain personal settings or clipboard history.
`SHA256SUMS.txt` contains the archive checksum. `SHA256SUMS.txt` contains the archive checksum.
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 the latest compiled version; older versions remain in Git history and Releases. This folder holds the latest compiled version; older versions remain in Git history and Releases.
To refresh the tracked package from source: To refresh the tracked package from source:
+1 -1
View File
@@ -1 +1 @@
9705cdb0c0a732eeae10b88e42101f9ff6714399ea8779e154fecc79269059a0 KSwitcher-win-x64.zip 104c79b64bb30a2161e3ec2f5108a731da0d274e015d53d8835ea7e64f431ee1 KSwitcher-win-x64.zip
+12 -4
View File
@@ -14,22 +14,30 @@ Push-Location $root
try { 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') $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') $libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','app','ui','main')) { foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','app','ui','main','configuration','config_main')) {
& $compiler @common -c "src/$name.cpp" -o "build/$name.o" & $compiler @common -c "src/$name.cpp" -o "build/$name.o"
if ($LASTEXITCODE) { throw "Compilation failed: $name" } if ($LASTEXITCODE) { throw "Compilation failed: $name" }
} }
& $compiler @common -DKS_RESIDENT_APP -c src/ui.cpp -o build/ui-resident.o
if ($LASTEXITCODE) { throw 'Resident UI compilation failed' }
& $compiler @common -DKS_CONFIG_APP -c src/ui.cpp -o build/ui-config.o
if ($LASTEXITCODE) { throw 'Configurator UI compilation failed' }
& windres --codepage=65001 resources/app.rc -O coff -o build/app-res.o & windres --codepage=65001 resources/app.rc -O coff -o build/app-res.o
if ($LASTEXITCODE) { throw 'Resource compilation failed' } if ($LASTEXITCODE) { throw 'Resource compilation failed' }
New-Item -ItemType Directory -Force build/release | Out-Null New-Item -ItemType Directory -Force build/release | Out-Null
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/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/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
if ($LASTEXITCODE) { throw 'Link failed' } if ($LASTEXITCODE) { throw 'Link failed' }
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
if ($LASTEXITCODE) { throw 'Configurator link failed' }
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
if ($Test) { if ($Test) {
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe & $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' } if ($LASTEXITCODE) { throw 'Test build failed' }
& .\build\KSwitcherTests.exe & .\build\KSwitcherTests.exe
if ($LASTEXITCODE) { throw 'Tests failed' } 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 & $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/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input test build failed' } if ($LASTEXITCODE) { throw 'Input test build failed' }
& .\build\KSwitcherInputTests.exe & .\build\KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input tests failed' } if ($LASTEXITCODE) { throw 'Input tests failed' }
@@ -37,7 +45,7 @@ try {
if ($LASTEXITCODE) { throw 'Updater tests failed' } if ($LASTEXITCODE) { throw 'Updater tests failed' }
} }
if ($Integration) { if ($Integration) {
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/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/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration test build failed' } if ($LASTEXITCODE) { throw 'Integration test build failed' }
& .\build\KSwitcherIntegration.exe & .\build\KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration tests failed' } if ($LASTEXITCODE) { throw 'Integration tests failed' }
+2 -1
View File
@@ -4,12 +4,13 @@ $root = $PSScriptRoot
$destination = Join-Path $root 'dist\KSwitcher' $destination = Join-Path $root 'dist\KSwitcher'
New-Item -ItemType Directory -Force $destination | Out-Null New-Item -ItemType Directory -Force $destination | Out-Null
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
# Enumerate the distributable files explicitly; do not include a user's data directory. # Enumerate the distributable files explicitly; do not include a user's data directory.
$files = @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ } $files = @('KSwitcher.exe','KSwitcher.Config.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
$archive = Join-Path $root 'dist\KSwitcher-win-x64.zip' $archive = Join-Path $root 'dist\KSwitcher-win-x64.zip'
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
Get-FileHash -LiteralPath $archive -Algorithm SHA256 Get-FileHash -LiteralPath $archive -Algorithm SHA256
+3 -3
View File
@@ -5,8 +5,8 @@ IDI_KSWITCHER ICON "kswitcher.ico"
1 RT_MANIFEST "app.manifest" 1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1" 201 RCDATA "update.ps1"
1 VERSIONINFO 1 VERSIONINFO
FILEVERSION 0,2,2,0 FILEVERSION 0,3,0,0
PRODUCTVERSION 0,2,2,0 PRODUCTVERSION 0,3,0,0
FILEOS VOS_NT_WINDOWS32 FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP FILETYPE VFT_APP
BEGIN BEGIN
@@ -16,7 +16,7 @@ BEGIN
BEGIN BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher" VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.2.2" VALUE "FileVersion", "0.3.0"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
+8 -2
View File
@@ -74,7 +74,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
foreach ($entry in $zip.Entries) { foreach ($entry in $zip.Entries) {
$name = $entry.FullName.Replace('\','/') $name = $entry.FullName.Replace('\','/')
if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue } 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 if ($name -cnotmatch '^(KSwitcher(?:\.Config)?\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or
$name.EndsWith('/.') -or $name.EndsWith('/..') -or $name.EndsWith('.') -or $name.EndsWith('/.') -or $name.EndsWith('/..') -or $name.EndsWith('.') -or
(($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($name)) { (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($name)) {
throw "Unexpected or unsafe archive entry: $name" throw "Unexpected or unsafe archive entry: $name"
@@ -88,8 +88,13 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
foreach ($required in @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md')) { foreach ($required in @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md')) {
if (!$seen.Contains($required)) { throw "Incomplete release: $required" } if (!$seen.Contains($required)) { throw "Incomplete release: $required" }
} }
if ($Expected -gt [version]'0.2.3' -and !$seen.Contains('KSwitcher.Config.exe')) {
throw 'Incomplete release: KSwitcher.Config.exe'
}
} finally { $zip.Dispose() } } finally { $zip.Dispose() }
$exe = Join-Path $Destination 'KSwitcher.exe' foreach ($binary in @('KSwitcher.exe','KSwitcher.Config.exe')) {
$exe = Join-Path $Destination $binary
if (!(Test-Path -LiteralPath $exe)) { continue }
$version = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe) $version = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe)
if ($version.ProductName -ne 'KSwitcher' -or (Get-StableVersion $version.FileVersion) -ne $Expected) { if ($version.ProductName -ne 'KSwitcher' -or (Get-StableVersion $version.FileVersion) -ne $Expected) {
throw 'Executable identity/version does not match the release.' throw 'Executable identity/version does not match the release.'
@@ -99,6 +104,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
$pe = [BitConverter]::ToInt32($bytes,60) $pe = [BitConverter]::ToInt32($bytes,60)
if ($pe -lt 64 -or $pe -gt $bytes.Length-6 -or [BitConverter]::ToUInt32($bytes,$pe) -ne 0x4550 -or 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.' } [BitConverter]::ToUInt16($bytes,$pe+4) -ne 0x8664) { throw 'Release is not a Windows x64 executable.' }
}
} }
function Assert-PlainDirectory([string]$Directory) { function Assert-PlainDirectory([string]$Directory) {
$item = Get-Item -LiteralPath $Directory -Force $item = Get-Item -LiteralPath $Directory -Force
+41 -11
View File
@@ -1,6 +1,7 @@
#include "app.h" #include "app.h"
#include "../resources/resource.h" #include "../resources/resource.h"
#include "ocr.h" #include "ocr.h"
#include "configuration.h"
#include <algorithm> #include <algorithm>
#include <commctrl.h> #include <commctrl.h>
#include <set> #include <set>
@@ -398,6 +399,18 @@ void App::reloadLayouts()
} }
registerHotkeys(settings); registerHotkeys(settings);
} }
void App::clearHistory()
{
if (busy) throw std::runtime_error("Wait for the current operation to finish.");
if (history) history->clear();
if (historyWindow) refreshHistory(*this);
}
void App::setHotkeyCapture(HWND field)
{
hotkeyCapture = field;
hotkeyCaptureProcess = 0;
if (field) GetWindowThreadProcessId(field, &hotkeyCaptureProcess);
}
bool App::isHotkey(DWORD key, UINT mods) bool App::isHotkey(DWORD key, UINT mods)
{ {
for (auto &[id, text] : shortcuts(settings)) for (auto &[id, text] : shortcuts(settings))
@@ -496,6 +509,10 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
// down AND up events use exactly the same path as physical keyboard input. // down AND up events use exactly the same path as physical keyboard input.
if (ownKeyboardInput(key) || installingUpdate) if (ownKeyboardInput(key) || installingUpdate)
return false; return false;
DWORD captureProcess{};
if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess);
if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess))
setHotkeyCapture(nullptr);
if (hotkeyCapture && GetForegroundWindow() == GetAncestor(hotkeyCapture, GA_ROOT)) if (hotkeyCapture && GetForegroundWindow() == GetAncestor(hotkeyCapture, GA_ROOT))
{ {
bool modifier = key.vkCode == VK_CONTROL || key.vkCode == VK_SHIFT || key.vkCode == VK_MENU || bool modifier = key.vkCode == VK_CONTROL || key.vkCode == VK_SHIFT || key.vkCode == VK_MENU ||
@@ -579,6 +596,11 @@ void App::dispatch(int command)
showSettings(*this); showSettings(*this);
return; return;
} }
if (command == 22)
{
showLogs(*this);
return;
}
if (command == 21) if (command == 21)
{ {
showSnippets(*this); showSnippets(*this);
@@ -619,9 +641,6 @@ void App::transform(int command, int snippet)
L" window=" + std::to_wstring(reinterpret_cast<uintptr_t>(target)) + L" window=" + std::to_wstring(reinterpret_cast<uintptr_t>(target)) +
L" version=" + std::to_wstring(currentVersion) + L" version=" + std::to_wstring(currentVersion) +
L" repeat=" + std::to_wstring(repeatAttempts)); L" repeat=" + std::to_wstring(repeatAttempts));
if (repeatAttempts >= 2)
diagnosticEvent(L"Transform suspicion id=" + std::to_wstring(attempt) +
L" reason=repeated-command-without-input (may be intentional cycling)");
} }
if (busy.exchange(true)) if (busy.exchange(true))
{ {
@@ -643,8 +662,7 @@ void App::transform(int command, int snippet)
version = inputVersion; version = inputVersion;
} }
enqueue( enqueue(
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt, [this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt]
suspicious = manual && repeatAttempts >= 2]
{ {
struct BusyReset struct BusyReset
{ {
@@ -679,7 +697,6 @@ void App::transform(int command, int snippet)
uint64_t version, attempt; uint64_t version, attempt;
ULONGLONG start; ULONGLONG start;
bool enabled; bool enabled;
bool suspicious;
std::wstring &reason; std::wstring &reason;
std::wstring &text, &result, &expected; std::wstring &text, &result, &expected;
int &hint, &to; int &hint, &to;
@@ -693,10 +710,10 @@ void App::transform(int command, int snippet)
L" source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) + L" source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
L" expected=" + diagnosticText(expected) + L" copied=" + diagnosticText(text) + L" expected=" + diagnosticText(expected) + L" copied=" + diagnosticText(text) +
L" result=" + diagnosticText(result), L" result=" + diagnosticText(result),
suspicious || (reason != L"paste-sent-unverified" && reason != L"layout-only" && reason != L"paste-sent-unverified" && reason != L"layout-only" &&
reason != L"unchanged-expected")); reason != L"unchanged-expected");
} }
} audit{*this, target, version, attempt, requestedAt, manual, suspicious, reason, text, result, expected, hint, to}; } audit{*this, target, version, attempt, requestedAt, manual, reason, text, result, expected, hint, to};
try try
{ {
auto valid = [&] auto valid = [&]
@@ -1134,9 +1151,9 @@ void App::trayMenu()
break; break;
case 16: case 16:
MessageBoxW(window, MessageBoxW(window,
L"KSwitcher 0.2.2\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on " (std::wstring(L"KSwitcher ") + AppVersion + L"\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on "
L"Typlo by bitcodepro. MIT license.\nHistory is encrypted for your Windows " L"Typlo by bitcodepro. MIT license.\nHistory is encrypted for your Windows "
L"account.\nNo .NET, WebView or graphics framework.", L"account.\nNo .NET, WebView or graphics framework.").c_str(),
L"About KSwitcher", MB_OK); L"About KSwitcher", MB_OK);
break; break;
case 17: case 17:
@@ -1170,6 +1187,11 @@ void App::trayMenu()
void App::checkUpdate(bool interactive, bool install, const std::wstring &version) void App::checkUpdate(bool interactive, bool install, const std::wstring &version)
{ {
if (updateRunning) return; if (updateRunning) return;
if (install && FindWindowW(L"KSwitcher.Config", nullptr))
{
report(L"Close the configurator before installing an update.");
return;
}
if (install && busy) if (install && busy)
{ {
report(L"Wait for the current text operation to finish, then select Update again."); report(L"Wait for the current text operation to finish, then select Update again.");
@@ -1289,6 +1311,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
case WM_COPYDATA: case WM_COPYDATA:
{ {
auto *data = (COPYDATASTRUCT *)lp; auto *data = (COPYDATASTRUCT *)lp;
if (data && data->dwData >= ConfigRead && data->dwData < ConfigReply)
{
if (app->installingUpdate) return FALSE;
return configurationRequest(*app, (HWND)wp, *data);
}
if (data && data->dwData == 1 && data->cbData == sizeof(int)) if (data && data->dwData == 1 && data->cbData == sizeof(int))
{ {
int cmd; int cmd;
@@ -1345,6 +1372,9 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
DestroyWindow(hwnd); DestroyWindow(hwnd);
return 0; return 0;
case WM_DESTROY: case WM_DESTROY:
if (auto config = FindWindowW(L"KSwitcher.Config", nullptr);
config && configurationPeer(config, L"KSwitcher.Config.exe"))
PostMessageW(config, WM_CLOSE, 0, 0);
PostQuitMessage(0); PostQuitMessage(0);
return 0; return 0;
default: default:
+3
View File
@@ -43,6 +43,7 @@ class App
std::atomic<ULONGLONG> pauseUntil{0}; std::atomic<ULONGLONG> pauseUntil{0};
HWND pasteTarget{}; HWND pasteTarget{};
HWND hotkeyCapture{}; HWND hotkeyCapture{};
DWORD hotkeyCaptureProcess{};
HWND logWindow{}; HWND logWindow{};
std::mutex stateMutex; std::mutex stateMutex;
Run run; Run run;
@@ -66,6 +67,8 @@ class App
bool registerHotkeys(const Settings &, bool rollback = false); bool registerHotkeys(const Settings &, bool rollback = false);
void applySettings(const Settings &); void applySettings(const Settings &);
void reloadLayouts(); void reloadLayouts();
void clearHistory();
void setHotkeyCapture(HWND);
bool isHotkey(DWORD, UINT); bool isHotkey(DWORD, UINT);
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers); void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers); bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
+205
View File
@@ -0,0 +1,205 @@
#include "configuration.h"
#include <commctrl.h>
#include <cstring>
namespace ks
{
App *App::current = nullptr;
namespace
{
HWND service{};
Bytes response;
bool replied{};
Bytes request(App &app, ULONG_PTR command, const Bytes &payload = {})
{
if (!service || !configurationPeer(service, L"KSwitcher.exe"))
throw std::runtime_error("KSwitcher has stopped. Close and reopen the configurator.");
if (payload.size() > ConfigLimit) throw std::runtime_error("Settings are too large.");
response.clear(); replied = false;
COPYDATASTRUCT message{command, (DWORD)payload.size(), const_cast<unsigned char *>(payload.data())};
DWORD_PTR result{};
SetLastError(ERROR_SUCCESS);
if (!SendMessageTimeoutW(service, WM_COPYDATA, (WPARAM)app.window, (LPARAM)&message,
SMTO_ABORTIFHUNG | SMTO_ERRORONEXIT, 15000, &result) || !replied)
{
if (GetLastError() == ERROR_ACCESS_DENIED)
throw std::runtime_error("Open Settings from the KSwitcher tray menu so both processes use the same privileges.");
throw std::runtime_error("KSwitcher did not respond. The operation may still finish; reopen Settings to check.");
}
if (result != 1)
throw std::runtime_error(response.empty() ? "KSwitcher rejected the request." :
std::string(response.begin(), response.end()));
return response;
}
LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
{
auto *app = App::current;
if (!app) return DefWindowProcW(window, message, wp, lp);
if (message == WM_COPYDATA)
{
auto *data = reinterpret_cast<COPYDATASTRUCT *>(lp);
if ((HWND)wp == service && data && data->dwData == ConfigReply && data->cbData <= ConfigLimit &&
(!data->cbData || data->lpData))
{
response.clear();
if (data->cbData)
{
auto first = static_cast<const unsigned char *>(data->lpData);
response.assign(first, first + data->cbData);
}
replied = true;
return TRUE;
}
return FALSE;
}
if (message == WM_APP + 20)
{
try
{
if (wp == 21) showSnippets(*app);
else showSettings(*app);
}
catch (const std::exception &e)
{
MessageBoxW(app->settingsWindow, wide(e.what()).c_str(), L"KSwitcher configurator", MB_OK | MB_ICONERROR);
}
return 0;
}
if (message == WM_CLOSE)
{
if (app->settingsWindow) DestroyWindow(app->settingsWindow);
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
PostQuitMessage(0);
return 0;
}
return DefWindowProcW(window, message, wp, lp);
}
}
// The configurator owns no history store, dictionaries, worker or input hooks.
// All mutations are validated and committed by the resident process.
App::App(HINSTANCE h, bool, const std::filesystem::path &) : instance(h)
{
current = this;
layouts = readLayouts();
}
App::~App()
{
if (settingsWindow) DestroyWindow(settingsWindow);
if (snippetsWindow) DestroyWindow(snippetsWindow);
if (window) DestroyWindow(window);
current = nullptr;
}
void App::applySettings(const Settings &next)
{
validateSettings(next);
auto before = encodeSettings(settings), after = encodeSettings(next);
DWORD length = (DWORD)before.size();
Bytes payload(sizeof(length));
memcpy(payload.data(), &length, sizeof(length));
payload.insert(payload.end(), before.begin(), before.end());
payload.insert(payload.end(), after.begin(), after.end());
settings = decodeSettings(request(*this, ConfigSave, payload), layouts);
}
void App::reloadLayouts()
{
auto bytes = request(*this, ConfigLayouts);
layouts = readLayouts();
settings = decodeSettings(bytes, layouts);
}
void App::clearHistory() { request(*this, ConfigClearHistory); }
void App::setHotkeyCapture(HWND field)
{
hotkeyCapture = field;
Bytes payload(sizeof(field));
memcpy(payload.data(), &field, sizeof(field));
// Focus notifications must not let an exception escape a window callback.
try { request(*this, ConfigCapture, payload); }
catch (const std::exception &e)
{
hotkeyCapture = nullptr;
if (field) MessageBoxW(settingsWindow, wide(e.what()).c_str(), L"Shortcut capture unavailable", MB_OK | MB_ICONWARNING);
}
}
void showLogs(App &app) { request(app, ConfigLogs); }
int runConfigurator(HINSTANCE instance, bool snippets)
{
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock.");
const bool existing = GetLastError() == ERROR_ALREADY_EXISTS;
struct Close { HANDLE handle; ~Close() { CloseHandle(handle); } } close{singleton};
if (existing)
{
HWND window{};
for (int i = 0; i < 100 && !window; ++i)
{
window = FindWindowW(L"KSwitcher.Config", nullptr);
if (!window) Sleep(50);
}
if (!window || !configurationPeer(window, L"KSwitcher.Config.exe"))
throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly.");
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
AllowSetForegroundWindow(pid);
PostMessageW(window, WM_APP + 20, snippets ? 21 : 20, 0);
return 0;
}
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
if (!service)
{
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.exe";
auto command = L"\"" + path.wstring() + L"\"";
STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{};
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
path.parent_path().c_str(), &startup, &process))
throw std::runtime_error("Cannot start KSwitcher beside the configurator.");
CloseHandle(process.hThread); CloseHandle(process.hProcess);
for (int i = 0; i < 100 && !service; ++i)
{
Sleep(50); service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
}
}
if (!configurationPeer(service, L"KSwitcher.exe"))
throw std::runtime_error("Run the configurator from the active KSwitcher installation.");
INITCOMMONCONTROLSEX controls{sizeof(controls), ICC_STANDARD_CLASSES};
InitCommonControlsEx(&controls);
App app(instance);
WNDCLASSW cls{}; cls.hInstance = instance; cls.lpfnWndProc = controller; cls.lpszClassName = L"KSwitcher.Config";
RegisterClassW(&cls);
app.window = CreateWindowExW(WS_EX_TOOLWINDOW, cls.lpszClassName, L"KSwitcher configurator", WS_POPUP,
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
app.settings = decodeSettings(request(app, ConfigRead), app.layouts);
if (snippets) showSnippets(app); else showSettings(app);
MSG message;
while ((app.settingsWindow || app.snippetsWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
{
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
{
HWND popup = GetLastActivePopup(app.settingsWindow);
if (popup != app.settingsWindow && IsDialogMessageW(popup, &message)) continue;
}
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
TranslateMessage(&message); DispatchMessageW(&message);
}
return 0;
}
}
int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
{
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
int result = 1;
try
{
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
LocalFree(args);
result = ks::runConfigurator(instance, snippets);
}
catch (const std::exception &e)
{
MessageBoxW(nullptr, ks::wide(e.what()).c_str(), L"KSwitcher configurator", MB_OK | MB_ICONERROR);
}
CoUninitialize();
return result;
}
+98
View File
@@ -0,0 +1,98 @@
#include "configuration.h"
#include <cstring>
namespace ks
{
bool configurationPeer(HWND window, const wchar_t *filename)
{
DWORD pid{};
GetWindowThreadProcessId(window, &pid);
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
if (!process) return false;
wchar_t path[32768]; DWORD count = std::size(path);
bool ok = QueryFullProcessImageNameW(process, 0, path, &count) != 0;
CloseHandle(process);
auto expected = std::filesystem::path(executable()).parent_path() / filename;
return ok && _wcsicmp(path, expected.c_str()) == 0;
}
void launchConfiguration(App &app, bool snippets)
{
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
if (!std::filesystem::exists(path))
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package.");
auto command = L"\"" + path.wstring() + L"\"" + (snippets ? L" --snippets" : L"");
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
path.parent_path().c_str(), &startup, &process))
throw std::runtime_error(utf8(lastError(L"Cannot start configurator")));
AllowSetForegroundWindow(process.dwProcessId);
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
(void)app;
}
LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &message)
{
if (!configurationPeer(sender, L"KSwitcher.Config.exe") || message.cbData > ConfigLimit ||
(message.cbData && !message.lpData)) return FALSE;
Bytes reply;
bool ok = false;
try
{
switch (message.dwData)
{
case ConfigSave:
{
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
if (message.cbData < sizeof(DWORD)) throw std::runtime_error("Invalid settings request.");
DWORD length{};
memcpy(&length, message.lpData, sizeof(length));
if (length > message.cbData - sizeof(length)) throw std::runtime_error("Invalid settings snapshot.");
auto begin = static_cast<const unsigned char *>(message.lpData) + sizeof(length);
if (Bytes(begin, begin + length) != encodeSettings(app.settings))
throw std::runtime_error("Settings changed in KSwitcher. Close and reopen the configurator before saving.");
Bytes next(begin + length, static_cast<const unsigned char *>(message.lpData) + message.cbData);
if (next.empty()) throw std::runtime_error("Empty settings request.");
app.applySettings(decodeSettings(next, app.layouts));
reply = encodeSettings(app.settings);
break;
}
case ConfigLayouts:
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
app.reloadLayouts();
[[fallthrough]];
case ConfigRead:
reply = encodeSettings(app.settings);
break;
case ConfigClearHistory:
app.clearHistory();
break;
case ConfigCapture:
{
if (message.cbData != sizeof(HWND)) throw std::runtime_error("Invalid shortcut field.");
HWND field{}; memcpy(&field, message.lpData, sizeof(field));
DWORD fieldPid{}, senderPid{};
GetWindowThreadProcessId(field, &fieldPid);
GetWindowThreadProcessId(sender, &senderPid);
if (field && fieldPid != senderPid) throw std::runtime_error("Invalid shortcut field owner.");
app.setHotkeyCapture(field);
break;
}
case ConfigLogs:
PostMessageW(app.window, WM_HOTKEY, 22, 0);
break;
default: return FALSE;
}
ok = true;
}
catch (const std::exception &error)
{
std::string text = error.what();
reply.assign(text.begin(), text.end());
}
COPYDATASTRUCT response{ConfigReply, (DWORD)reply.size(), reply.data()};
DWORD_PTR received{};
if (!SendMessageTimeoutW(sender, WM_COPYDATA, (WPARAM)app.window, (LPARAM)&response,
SMTO_ABORTIFHUNG, 2000, &received)) return FALSE;
return ok ? 1 : 2;
}
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include "app.h"
namespace ks
{
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
ConfigReply = ConfigRead + 6;
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
bool configurationPeer(HWND, const wchar_t *filename);
void launchConfiguration(App &, bool snippets = false);
LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
}
+11 -3
View File
@@ -102,6 +102,10 @@ void validateSettings(const Settings &s)
} }
} }
Settings loadSettings(const std::filesystem::path &path, const std::vector<Layout> &layouts) Settings loadSettings(const std::filesystem::path &path, const std::vector<Layout> &layouts)
{
return decodeSettings(std::filesystem::exists(path) ? readFile(path, 4 * 1024 * 1024) : Bytes{}, layouts);
}
Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
{ {
Settings s; Settings s;
for (size_t i = 0; i < layouts.size(); ++i) for (size_t i = 0; i < layouts.size(); ++i)
@@ -111,9 +115,9 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
s.direct.push_back(i < 3 ? L"Ctrl+Alt+Shift+" + std::to_wstring(i + 1) : L""); s.direct.push_back(i < 3 ? L"Ctrl+Alt+Shift+" + std::to_wstring(i + 1) : L"");
s.directIds.push_back(layouts[i].id); s.directIds.push_back(layouts[i].id);
} }
if (!std::filesystem::exists(path)) if (bytes.empty())
return s; return s;
auto j = parse(readFile(path, 4 * 1024 * 1024)); auto j = parse(bytes);
auto format = j.value("format", std::string()); auto format = j.value("format", std::string());
if (format != "kswitcher-settings" && format != "typlo-settings") if (format != "kswitcher-settings" && format != "typlo-settings")
throw std::runtime_error("Not a KSwitcher or Typlo settings file"); throw std::runtime_error("Not a KSwitcher or Typlo settings file");
@@ -177,6 +181,10 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
return s; return s;
} }
void saveSettings(const std::filesystem::path &path, const Settings &s) void saveSettings(const std::filesystem::path &path, const Settings &s)
{
atomicWrite(path, encodeSettings(s));
}
Bytes encodeSettings(const Settings &s)
{ {
validateSettings(s); validateSettings(s);
Json j = {{"format", "kswitcher-settings"}, Json j = {{"format", "kswitcher-settings"},
@@ -221,7 +229,7 @@ void saveSettings(const std::filesystem::path &path, const Settings &s)
j["excludedApps"] = Json::array(); j["excludedApps"] = Json::array();
for (auto &name : s.excluded) for (auto &name : s.excluded)
j["excludedApps"].push_back(utf8(name)); j["excludedApps"].push_back(utf8(name));
atomicWrite(path, jsonBytes(j)); return jsonBytes(j);
} }
Bytes encodeClip(const Clip &clip) Bytes encodeClip(const Clip &clip)
{ {
+2
View File
@@ -4,6 +4,8 @@
namespace ks namespace ks
{ {
Settings loadSettings(const std::filesystem::path &, const std::vector<Layout> &); Settings loadSettings(const std::filesystem::path &, const std::vector<Layout> &);
Settings decodeSettings(const Bytes &, const std::vector<Layout> &);
Bytes encodeSettings(const Settings &);
void saveSettings(const std::filesystem::path &, const Settings &); void saveSettings(const std::filesystem::path &, const Settings &);
void validateSettings(const Settings &); void validateSettings(const Settings &);
struct Entry struct Entry
+28 -12
View File
@@ -1,5 +1,6 @@
#include "../resources/resource.h" #include "../resources/resource.h"
#include "app.h" #include "app.h"
#include "configuration.h"
#include "ocr.h" #include "ocr.h"
#include "richtext.h" #include "richtext.h"
#include "ui_geometry.h" #include "ui_geometry.h"
@@ -185,13 +186,13 @@ LRESULT CALLBACK hotkeyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
auto *app = (App *)data; auto *app = (App *)data;
if (msg == WM_SETFOCUS) if (msg == WM_SETFOCUS)
{ {
app->hotkeyCapture = w; app->setHotkeyCapture(w);
SendMessageW(w, EM_SETSEL, 0, -1); SendMessageW(w, EM_SETSEL, 0, -1);
} }
if (msg == WM_KILLFOCUS || msg == WM_NCDESTROY) if (msg == WM_KILLFOCUS || msg == WM_NCDESTROY)
{ {
if (app->hotkeyCapture == w) if (app->hotkeyCapture == w)
app->hotkeyCapture = nullptr; app->setHotkeyCapture(nullptr);
} }
if (msg == CaptureHotkeyMessage) if (msg == CaptureHotkeyMessage)
{ {
@@ -218,13 +219,13 @@ LRESULT CALLBACK hotkeyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
return 0; return 0;
return DefSubclassProc(w, msg, wp, lp); return DefSubclassProc(w, msg, wp, lp);
} }
void hotkeyEdit(App &app, HWND w, int id, int x, int y, int width) [[maybe_unused]] void hotkeyEdit(App &app, HWND w, int id, int x, int y, int width)
{ {
HWND field = edit(w, id, x, y, width, 25, ES_AUTOHSCROLL | ES_READONLY); HWND field = edit(w, id, x, y, width, 25, ES_AUTOHSCROLL | ES_READONLY);
SendMessageW(field, EM_SETCUEBANNER, FALSE, (LPARAM)L"Click, then press a shortcut"); SendMessageW(field, EM_SETCUEBANNER, FALSE, (LPARAM)L"Click, then press a shortcut");
SetWindowSubclass(field, hotkeyProc, 1, (DWORD_PTR)&app); SetWindowSubclass(field, hotkeyProc, 1, (DWORD_PTR)&app);
} }
void checkbox(HWND w, const wchar_t *text, int id, int x, int y, int width = 300) [[maybe_unused]] void checkbox(HWND w, const wchar_t *text, int id, int x, int y, int width = 300)
{ {
control(w, L"BUTTON", text, WS_TABSTOP | BS_AUTOCHECKBOX, id, x, y, width, 24); control(w, L"BUTTON", text, WS_TABSTOP | BS_AUTOCHECKBOX, id, x, y, width, 24);
} }
@@ -241,11 +242,11 @@ void value(HWND w, int id, const std::wstring &text)
{ {
SetDlgItemTextW(w, id, text.c_str()); SetDlgItemTextW(w, id, text.c_str());
} }
bool checked(HWND w, int id) [[maybe_unused]] bool checked(HWND w, int id)
{ {
return IsDlgButtonChecked(w, id) == BST_CHECKED; return IsDlgButtonChecked(w, id) == BST_CHECKED;
} }
void check(HWND w, int id, bool yes) [[maybe_unused]] void check(HWND w, int id, bool yes)
{ {
CheckDlgButton(w, id, yes ? BST_CHECKED : BST_UNCHECKED); CheckDlgButton(w, id, yes ? BST_CHECKED : BST_UNCHECKED);
} }
@@ -352,7 +353,7 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
fitToMonitor(w, *(RECT *)lp); fitToMonitor(w, *(RECT *)lp);
arrangeWindow(w); arrangeWindow(w);
} }
std::filesystem::path pickFile(HWND owner, bool save) [[maybe_unused]] std::filesystem::path pickFile(HWND owner, bool save)
{ {
wchar_t path[32768]{}; wchar_t path[32768]{};
OPENFILENAMEW ofn{sizeof(ofn)}; OPENFILENAMEW ofn{sizeof(ofn)};
@@ -372,6 +373,7 @@ void disposeFont(HWND w)
DeleteObject(h); DeleteObject(h);
} }
#ifndef KS_CONFIG_APP
struct SearchResult struct SearchResult
{ {
uint64_t generation; uint64_t generation;
@@ -755,6 +757,8 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
return DefWindowProcW(w, msg, wp, lp); return DefWindowProcW(w, msg, wp, lp);
} }
#endif
#ifndef KS_RESIDENT_APP
struct SettingsView struct SettingsView
{ {
App *app{}; App *app{};
@@ -1146,11 +1150,11 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
fillSettings(w, v); fillSettings(w, v);
return 0; return 0;
} }
if (id == 254 && v->app->history) if (id == 254)
{ {
if (MessageBoxW(w, L"Delete all clipboard history, including pinned items?", L"Clear history", if (MessageBoxW(w, L"Delete all clipboard history, including pinned items?", L"Clear history",
MB_YESNO | MB_ICONWARNING) == IDYES) MB_YESNO | MB_ICONWARNING) == IDYES)
v->app->history->clear(); v->app->clearHistory();
return 0; return 0;
} }
break; break;
@@ -1294,6 +1298,8 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
} }
return DefWindowProcW(w, msg, wp, lp); return DefWindowProcW(w, msg, wp, lp);
} }
#endif
#ifndef KS_CONFIG_APP
LRESULT CALLBACK entryProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) LRESULT CALLBACK entryProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{ {
if (msg == WM_SIZE) if (msg == WM_SIZE)
@@ -1305,7 +1311,9 @@ LRESULT CALLBACK entryProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
disposeFont(w); disposeFont(w);
return DefWindowProcW(w, msg, wp, lp); return DefWindowProcW(w, msg, wp, lp);
} }
#endif
} // namespace } // namespace
#ifndef KS_CONFIG_APP
void refreshHistory(App &app) void refreshHistory(App &app)
{ {
if (app.historyWindow) if (app.historyWindow)
@@ -1498,7 +1506,7 @@ void refreshLogView(HWND w, App &app)
InvalidateRect(list, nullptr, TRUE); InvalidateRect(list, nullptr, TRUE);
if (row) if (row)
ListView_EnsureVisible(list, row - 1, FALSE); ListView_EnsureVisible(list, row - 1, FALSE);
value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: problems and suspicious repeats only." value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: failed operations only."
: L"Logging disabled. Showing previously saved entries."); : L"Logging disabled. Showing previously saved entries.");
} }
LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
@@ -1609,6 +1617,8 @@ void showLogs(App &app)
showRequestedWindow(w); showRequestedWindow(w);
activateWindow(w); activateWindow(w);
} }
#endif
#ifndef KS_RESIDENT_APP
void showSettings(App &app, bool visible) void showSettings(App &app, bool visible)
{ {
if (app.settingsWindow) if (app.settingsWindow)
@@ -1623,8 +1633,8 @@ void showSettings(App &app, bool visible)
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 780, 660); HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 780, 660);
app.settingsWindow = w; app.settingsWindow = w;
label(w, L"Text conversion", 18, 16); label(w, L"Text conversion", 18, 16);
checkbox(w, L"Convert recently typed text when nothing is selected", 201, 18, 43, 440); checkbox(w, L"Convert recently typed text when nothing is selected", 201, 18, 43, 390);
checkbox(w, L"Switch Windows input layout after conversion", 202, 18, 70, 440); checkbox(w, L"Switch Windows input layout after conversion", 202, 18, 70, 390);
checkbox(w, L"Record clipboard history", 203, 18, 106); checkbox(w, L"Record clipboard history", 203, 18, 106);
checkbox(w, L"Start with Windows", 204, 18, 133); checkbox(w, L"Start with Windows", 204, 18, 133);
checkbox(w, L"Start with Windows as administrator", 206, 18, 160, 395); checkbox(w, L"Start with Windows as administrator", 206, 18, 160, 395);
@@ -1699,6 +1709,11 @@ void showSnippets(App &app)
showRequestedWindow(w); showRequestedWindow(w);
SetForegroundWindow(w); SetForegroundWindow(w);
} }
#else
void showSettings(App &app, bool) { launchConfiguration(app); }
void showSnippets(App &app) { launchConfiguration(app, true); }
#endif
#ifndef KS_CONFIG_APP
void showShortcuts(App &app) void showShortcuts(App &app)
{ {
auto &s = app.settings; auto &s = app.settings;
@@ -1736,4 +1751,5 @@ void showEntry(App &app, const std::wstring &id)
error(app.historyWindow, e); error(app.historyWindow, e);
} }
} }
#endif
} // namespace ks } // namespace ks
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h" #include "platform.h"
namespace ks namespace ks
{ {
inline constexpr wchar_t AppVersion[] = L"0.2.2"; inline constexpr wchar_t AppVersion[] = L"0.3.0";
inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateMessage = WM_APP + 7;
struct UpdateResult struct UpdateResult
{ {
+143
View File
@@ -0,0 +1,143 @@
param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'))
$ErrorActionPreference = 'Stop'
if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) {
throw 'Close KSwitcher and its configurator before this isolated process test.'
}
Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class ConfigurationTestUi {
[StructLayout(LayoutKind.Explicit, Size=40)] public struct Input {
[FieldOffset(0)] public uint type;
[FieldOffset(8)] public ushort key;
[FieldOffset(12)] public uint flags;
}
[DllImport("user32.dll")] public static extern uint SendInput(uint count,Input[] input,int size);
public static uint PressF24() { return SendInput(2,new Input[]{new Input{type=1,key=0x87},new Input{type=1,key=0x87,flags=2}},40); }
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr GetText(IntPtr w,uint msg,IntPtr count,System.Text.StringBuilder text);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string cls,string title);
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr w,int id);
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr SetText(IntPtr w,uint msg,IntPtr wp,string text);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr w,out uint pid);
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a,uint b,bool attach);
[DllImport("user32.dll")] public static extern IntPtr SetFocus(IntPtr w);
public static bool FocusControl(IntPtr w) {
uint pid; uint target=GetWindowThreadProcessId(w,out pid), current=GetCurrentThreadId();
if(!AttachThreadInput(current,target,true)) return false;
try { SetFocus(w); return true; } finally { AttachThreadInput(current,target,false); }
}
}
'@
$root=Join-Path ([IO.Path]::GetTempPath()) ('KSwitcher-config-tests-'+[guid]::NewGuid().ToString('N'))
$processes=[Collections.Generic.List[Diagnostics.Process]]::new()
$checks=0
function Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ }
function Window($class) {
for($i=0;$i -lt 100;$i++) {
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'}}
$window=[ConfigurationTestUi]::FindWindow($class,$title)
if($window -ne [IntPtr]::Zero){return $window}
Start-Sleep -Milliseconds 50
}
foreach($process in $processes){$process.Refresh(); Write-Host ("Diagnostic process {0}: exited={1} title={2}" -f $process.Id,$process.HasExited,$process.MainWindowTitle)}
throw "Window did not appear: $class"
}
function Launch($file,$argument) {
$options=@{FilePath=(Join-Path $root $file);PassThru=$true;WindowStyle='Hidden'}
if($argument){$options.ArgumentList=$argument}
$process=Start-Process @options
$processes.Add($process)
return $process
}
function Click($window,$id) {
$null=[ConfigurationTestUi]::PostMessage($window,0x111,[IntPtr]$id,[IntPtr]::Zero)
}
try {
New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null
foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) {
Copy-Item -LiteralPath (Join-Path $BuildDirectory $file) -Destination $root
}
# No clipboard history capture, auto-correction, autostart or user data in the fixture.
$settingsPath=Join-Path $root 'data/settings.json'
@{format='kswitcher-settings';schemaVersion=1;historyEnabled=$false;autoCorrectLayout=$false;writeLog=$false} |
ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8
$resident=Launch 'KSwitcher.exe' ''
$main=Window 'KSwitcher.Main'
$config=Launch 'KSwitcher.Config.exe' ''
$settings=Window 'KSwitcher.Settings'
[uint32]$ownerId=0
$null=[ConfigurationTestUi]::GetWindowThreadProcessId($settings,[ref]$ownerId)
Assert ($ownerId -eq $config.Id -and $ownerId -ne $resident.Id) 'settings live in the separate process'
$second=Launch 'KSwitcher.Config.exe' ''
Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'second launch forwards to existing instance'
Assert ((Window 'KSwitcher.Settings') -eq $settings) 'existing settings window reused'
# Only inject into the isolated fixture window, never another foreground app.
Assert ([ConfigurationTestUi]::GetForegroundWindow() -eq $settings) 'fixture owns foreground before shortcut capture test'
$field=[ConfigurationTestUi]::GetDlgItem($settings,210)
Assert ([ConfigurationTestUi]::FocusControl($field)) 'shortcut field receives actual focus'
Assert ([ConfigurationTestUi]::PressF24() -eq 2) 'test F24 events accepted'
$text=[Text.StringBuilder]::new(128)
for($i=0;$i -lt 100;$i++) {
$null=$text.Clear()
$null=[ConfigurationTestUi]::GetText($field,0x0d,[IntPtr]128,$text)
if($text.ToString() -eq 'F24'){break}
Start-Sleep -Milliseconds 20
}
Assert ($text.ToString() -eq 'F24') 'resident hook forwards shortcut capture to configurator'
$null=[ConfigurationTestUi]::SetText([ConfigurationTestUi]::GetDlgItem($settings,240),0x0c,[IntPtr]::Zero,'321')
Click $settings 1
Assert ($config.WaitForExit(6000) -and $config.ExitCode -eq 0) 'save exits configurator'
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'save committed through resident process'
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).toggleHotkey -eq 'F24') 'captured shortcut persisted by resident'
$resident.Refresh(); Assert (!$resident.HasExited) 'resident survives settings save'
$config=Launch 'KSwitcher.Config.exe' ''
$settings=Window 'KSwitcher.Settings'
$null=[ConfigurationTestUi]::SetText([ConfigurationTestUi]::GetDlgItem($settings,240),0x0c,[IntPtr]::Zero,'456')
Click $settings 2
Assert ($config.WaitForExit(6000)) 'cancel exits configurator'
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'cancel preserves saved settings'
$config=Launch 'KSwitcher.Config.exe' '--snippets'
$snippets=Window 'KSwitcher.Snippets'
$second=Launch 'KSwitcher.Config.exe' ''
Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'settings and snippets share a single process'
$settings=Window 'KSwitcher.Settings'
Click $settings 2
Start-Sleep -Milliseconds 100
$config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive'
Click $snippets 2
Assert ($config.WaitForExit(6000)) 'closing last window exits configurator'
$config=Launch 'KSwitcher.Config.exe' ''
$null=Window 'KSwitcher.Settings'
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
Assert ($resident.WaitForExit(10000)) 'resident shuts down normally'
Assert ($config.WaitForExit(6000)) 'resident exit closes configurator'
$config=Launch 'KSwitcher.Config.exe' ''
$main=Window 'KSwitcher.Main'
[uint32]$bootstrappedId=0
$null=[ConfigurationTestUi]::GetWindowThreadProcessId($main,[ref]$bootstrappedId)
$bootstrapped=Get-Process -Id $bootstrappedId
$processes.Add($bootstrapped)
$settings=Window 'KSwitcher.Settings'
Assert ($bootstrappedId -ne $resident.Id) 'standalone configurator starts its resident sibling'
Click $settings 2
Assert ($config.WaitForExit(6000)) 'standalone configurator exits after cancel'
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
Assert ($bootstrapped.WaitForExit(10000)) 'bootstrapped resident shuts down normally'
"Passed $checks configurator process checks"
} finally {
foreach($process in $processes) {
$process.Refresh()
if(!$process.HasExited) { Stop-Process -Id $process.Id -Force }
$process.Dispose()
}
# The generated fixture is the only recursive deletion target.
$resolved=[IO.Path]::GetFullPath($root)
$temp=[IO.Path]::GetFullPath([IO.Path]::GetTempPath())
if(!$resolved.StartsWith($temp,[StringComparison]::OrdinalIgnoreCase) -or
[IO.Path]::GetFileName($resolved) -notlike 'KSwitcher-config-tests-*') { throw 'Unexpected fixture path' }
Remove-Item -LiteralPath $resolved -Recurse -Force
}
+4
View File
@@ -389,6 +389,10 @@ int main(int argc, char **argv)
settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}}; settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}};
ks::saveSettings(temp / L"settings.json", settings); ks::saveSettings(temp / L"settings.json", settings);
auto restored = ks::loadSettings(temp / L"settings.json", {}); auto restored = ks::loadSettings(temp / L"settings.json", {});
require(ks::encodeSettings(ks::decodeSettings(ks::encodeSettings(settings), {})) == ks::encodeSettings(settings),
"configurator snapshot survives serialization without changing the concurrency token");
rejects([&] { ks::decodeSettings(ks::Bytes{'{', '}'}, {}); },
"configurator rejects missing settings format");
require(!restored.writeLog, "logging setting persists when disabled"); require(!restored.writeLog, "logging setting persists when disabled");
require(ks::loadSettings(temp / L"defaults.json", {}).writeLog, require(ks::loadSettings(temp / L"defaults.json", {}).writeLog,
"logging defaults on for existing diagnostic installations"); "logging defaults on for existing diagnostic installations");
+8
View File
@@ -30,6 +30,10 @@ try {
} }
$source=Join-Path $root 'source'; [IO.Directory]::CreateDirectory($source) | Out-Null $source=Join-Path $root 'source'; [IO.Directory]::CreateDirectory($source) | Out-Null
Copy-Item -LiteralPath $Executable -Destination (Join-Path $source 'KSwitcher.exe') Copy-Item -LiteralPath $Executable -Destination (Join-Path $source 'KSwitcher.exe')
$configurator = Join-Path (Split-Path $Executable) 'KSwitcher.Config.exe'
if (Test-Path -LiteralPath $configurator) {
Copy-Item -LiteralPath $configurator -Destination (Join-Path $source 'KSwitcher.Config.exe')
}
foreach ($name in @('LICENSE','json-LICENSE.txt','README.md')) { [IO.File]::WriteAllText((Join-Path $source $name),'fixture') } 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.Directory]::CreateDirectory((Join-Path $source 'dictionaries')) | Out-Null
[IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary') [IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary')
@@ -57,11 +61,15 @@ try {
$changed=[Collections.Generic.List[string]]::new() $changed=[Collections.Generic.List[string]]::new()
Install-Files $stage $target $backup $changed 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 ((Get-FileHash -LiteralPath (Join-Path $target 'KSwitcher.exe')).Hash -eq (Get-FileHash -LiteralPath $Executable).Hash) 'replacement installed'
if (Test-Path -LiteralPath $configurator) {
Assert ((Get-FileHash -LiteralPath (Join-Path $target 'KSwitcher.Config.exe')).Hash -eq (Get-FileHash -LiteralPath $configurator).Hash) 'configurator installed'
}
Assert ([IO.File]::ReadAllText((Join-Path $target 'data/settings.json')) -eq 'personal settings') 'settings preserved' 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' Assert ([IO.File]::ReadAllText((Join-Path $target 'dictionaries/en.txt')) -eq 'personal dictionary') 'dictionary preserved'
Undo-Files $target $backup $changed Undo-Files $target $backup $changed
Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'rollback restores original' Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'rollback restores original'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'README.md'))) 'rollback removes newly introduced files' Assert (!(Test-Path -LiteralPath (Join-Path $target 'README.md'))) 'rollback removes newly introduced files'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Config.exe'))) 'rollback removes newly introduced configurator'
# Fail after the original exe has moved to backup, as if the staged file vanished. # 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 $stage2=Join-Path $root 'stage2'; [IO.Directory]::CreateDirectory($stage2) | Out-Null
[IO.File]::WriteAllText((Join-Path $stage2 'KSwitcher.exe'),'new binary fixture') [IO.File]::WriteAllText((Join-Path $stage2 'KSwitcher.exe'),'new binary fixture')
+3
View File
@@ -6,6 +6,9 @@ try {
$stop = Start-Process -FilePath $exe -ArgumentList '--exit' -WindowStyle Hidden -PassThru $stop = Start-Process -FilePath $exe -ArgumentList '--exit' -WindowStyle Hidden -PassThru
if (-not $stop.WaitForExit(5000)) { throw 'Exit command timed out.' } if (-not $stop.WaitForExit(5000)) { throw 'Exit command timed out.' }
foreach ($process in $running) { if (-not $process.WaitForExit(10000)) { throw 'KSwitcher did not stop.' } } foreach ($process in $running) { if (-not $process.WaitForExit(10000)) { throw 'KSwitcher did not stop.' } }
$config = @(Get-Process 'KSwitcher.Config' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq (Join-Path $root 'build\KSwitcher.Config.exe') })
foreach ($process in $config) { if (-not $process.WaitForExit(10000)) { throw 'Close the configurator before updating.' } }
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.Config.exe') -Destination (Join-Path $root 'build\KSwitcher.Config.exe') -Force
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.exe') -Destination $exe -Force Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.exe') -Destination $exe -Force
Start-Process -FilePath $exe -WindowStyle Hidden Start-Process -FilePath $exe -WindowStyle Hidden
'OK' | Set-Content -LiteralPath (Join-Path $root 'build\update-result.txt') 'OK' | Set-Content -LiteralPath (Join-Path $root 'build\update-result.txt')