diff --git a/CMakeLists.txt b/CMakeLists.txt index bf3dfe7..1600c32 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.2.3 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.3.0 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") 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_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00) target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore) -add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/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) +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) 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) -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) add_executable(KSwitcherOcrTests tests/ocr.cpp) target_link_libraries(KSwitcherOcrTests PRIVATE kscore) if(MSVC) - set_property(TARGET kscore KSwitcher KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") target_compile_options(kscore PRIVATE /W4 /utf-8) target_compile_options(KSwitcher PRIVATE /W4 /utf-8) + target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8) else() target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++) + target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++) target_link_options(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++) @@ -35,7 +42,7 @@ add_test(NAME native-regressions COMMAND KSwitcherTests) add_test(NAME input-regressions COMMAND KSwitcherInputTests) add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$") set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") -install(TARGETS KSwitcher RUNTIME DESTINATION .) +install(TARGETS KSwitcher KSwitcherConfig RUNTIME DESTINATION .) install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .) add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$/dictionaries") diff --git a/README.md b/README.md index 4b17c8e..039c7cb 100644 --- a/README.md +++ b/README.md @@ -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 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. +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** 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; @@ -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 -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 +.\tests\configuration.ps1 # Separate-process settings tests; close KSwitcher first ``` `-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. - `storage.*`: settings and encrypted, disk-backed history. - `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. - `ocr.*`: Windows WIC and OCR via the native Windows Runtime ABI. - `tests/`: regression checks, real editor integration and OCR fixture. diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index 8d1cead..bb05305 100644 Binary files a/Release/KSwitcher-win-x64.zip and b/Release/KSwitcher-win-x64.zip differ diff --git a/Release/README.md b/Release/README.md index e6b0d76..7c9aeef 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,22 +1,25 @@ # Latest Windows release -## v0.2.3 — 2026-10-28 +## v0.3.0 — 2026-10-28 -- Fix overlapping Settings controls that obscured the Next layout and Change case labels. -- Stop treating successful repeated conversions as diagnostic incidents. Failed operations still flush their diagnostic context to the log. +- Move Settings and the snippet editor into the on-demand `KSwitcher.Config.exe` process. +- Repeated launches activate the existing configurator. Closing its last window releases the process and its memory. +- 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. +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, -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. +**Upgrade from v0.2.3 or earlier: install this release manually once.** The old updater rejects the additional EXE. +Exit KSwitcher, extract the entire ZIP into the installation folder, and retain `data/` and any customized dictionaries. +Keep `KSwitcher.exe`, `KSwitcher.Config.exe` and `dictionaries/` together. Launch `KSwitcher.exe`. +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. -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. To refresh the tracked package from source: diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index 9f2e98b..f75da34 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -ef3f21574f02edeb63661109056480506041372f1fa4ed40b07cdd2f544d79b5 KSwitcher-win-x64.zip +104c79b64bb30a2161e3ec2f5108a731da0d274e015d53d8835ea7e64f431ee1 KSwitcher-win-x64.zip diff --git a/build.ps1 b/build.ps1 index f47c409..006f171 100644 --- a/build.ps1 +++ b/build.ps1 @@ -14,22 +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','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" 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 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/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' } 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) { & $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 + & $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' } & .\build\KSwitcherInputTests.exe if ($LASTEXITCODE) { throw 'Input tests failed' } @@ -37,7 +45,7 @@ try { if ($LASTEXITCODE) { throw 'Updater tests failed' } } if ($Integration) { - & $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/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' } & .\build\KSwitcherIntegration.exe if ($LASTEXITCODE) { throw 'Integration tests failed' } diff --git a/package.ps1 b/package.ps1 index 9ce1f0e..767d097 100644 --- a/package.ps1 +++ b/package.ps1 @@ -4,12 +4,13 @@ $root = $PSScriptRoot $destination = Join-Path $root 'dist\KSwitcher' New-Item -ItemType Directory -Force $destination | Out-Null Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination +Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force # Enumerate the distributable files explicitly; do not include a user's data directory. -$files = @('KSwitcher.exe','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' Compress-Archive -LiteralPath $files -DestinationPath $archive -Force Get-FileHash -LiteralPath $archive -Algorithm SHA256 diff --git a/resources/app.rc b/resources/app.rc index 66485dd..3d357d6 100644 --- a/resources/app.rc +++ b/resources/app.rc @@ -5,8 +5,8 @@ IDI_KSWITCHER ICON "kswitcher.ico" 1 RT_MANIFEST "app.manifest" 201 RCDATA "update.ps1" 1 VERSIONINFO - FILEVERSION 0,2,3,0 - PRODUCTVERSION 0,2,3,0 + FILEVERSION 0,3,0,0 + PRODUCTVERSION 0,3,0,0 FILEOS VOS_NT_WINDOWS32 FILETYPE VFT_APP BEGIN @@ -16,7 +16,7 @@ BEGIN BEGIN VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "ProductName", "KSwitcher" - VALUE "FileVersion", "0.2.3" + VALUE "FileVersion", "0.3.0" END END BLOCK "VarFileInfo" diff --git a/resources/update.ps1 b/resources/update.ps1 index f30ad31..47e7173 100644 --- a/resources/update.ps1 +++ b/resources/update.ps1 @@ -74,7 +74,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected foreach ($entry in $zip.Entries) { $name = $entry.FullName.Replace('\','/') if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue } - if ($name -cnotmatch '^(KSwitcher\.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 (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($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')) { 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() } - $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) if ($version.ProductName -ne 'KSwitcher' -or (Get-StableVersion $version.FileVersion) -ne $Expected) { 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) 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 diff --git a/src/app.cpp b/src/app.cpp index 19296d3..ca04a15 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1,6 +1,7 @@ #include "app.h" #include "../resources/resource.h" #include "ocr.h" +#include "configuration.h" #include #include #include @@ -398,6 +399,18 @@ void App::reloadLayouts() } 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) { 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. if (ownKeyboardInput(key) || installingUpdate) return false; + DWORD captureProcess{}; + if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess); + if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess)) + setHotkeyCapture(nullptr); if (hotkeyCapture && GetForegroundWindow() == GetAncestor(hotkeyCapture, GA_ROOT)) { 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); return; } + if (command == 22) + { + showLogs(*this); + return; + } if (command == 21) { showSnippets(*this); @@ -1129,9 +1151,9 @@ void App::trayMenu() break; case 16: 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"account.\nNo .NET, WebView or graphics framework.", + L"account.\nNo .NET, WebView or graphics framework.").c_str(), L"About KSwitcher", MB_OK); break; case 17: @@ -1165,6 +1187,11 @@ void App::trayMenu() void App::checkUpdate(bool interactive, bool install, const std::wstring &version) { if (updateRunning) return; + if (install && FindWindowW(L"KSwitcher.Config", nullptr)) + { + report(L"Close the configurator before installing an update."); + return; + } if (install && busy) { report(L"Wait for the current text operation to finish, then select Update again."); @@ -1284,6 +1311,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) case WM_COPYDATA: { 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)) { int cmd; @@ -1340,6 +1372,9 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) DestroyWindow(hwnd); return 0; 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); return 0; default: diff --git a/src/app.h b/src/app.h index f5838a9..d9c40bd 100644 --- a/src/app.h +++ b/src/app.h @@ -43,6 +43,7 @@ class App std::atomic pauseUntil{0}; HWND pasteTarget{}; HWND hotkeyCapture{}; + DWORD hotkeyCaptureProcess{}; HWND logWindow{}; std::mutex stateMutex; Run run; @@ -66,6 +67,8 @@ class App bool registerHotkeys(const Settings &, bool rollback = false); void applySettings(const Settings &); void reloadLayouts(); + void clearHistory(); + void setHotkeyCapture(HWND); bool isHotkey(DWORD, UINT); void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers); bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers); diff --git a/src/config_main.cpp b/src/config_main.cpp new file mode 100644 index 0000000..8b213d3 --- /dev/null +++ b/src/config_main.cpp @@ -0,0 +1,205 @@ +#include "configuration.h" +#include +#include + +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(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(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(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; +} diff --git a/src/configuration.cpp b/src/configuration.cpp new file mode 100644 index 0000000..6be196f --- /dev/null +++ b/src/configuration.cpp @@ -0,0 +1,98 @@ +#include "configuration.h" +#include +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(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(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; +} +} diff --git a/src/configuration.h b/src/configuration.h new file mode 100644 index 0000000..2a6c491 --- /dev/null +++ b/src/configuration.h @@ -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 &); +} diff --git a/src/storage.cpp b/src/storage.cpp index 67f458f..7428d07 100644 --- a/src/storage.cpp +++ b/src/storage.cpp @@ -102,6 +102,10 @@ void validateSettings(const Settings &s) } } Settings loadSettings(const std::filesystem::path &path, const std::vector &layouts) +{ + return decodeSettings(std::filesystem::exists(path) ? readFile(path, 4 * 1024 * 1024) : Bytes{}, layouts); +} +Settings decodeSettings(const Bytes &bytes, const std::vector &layouts) { Settings s; for (size_t i = 0; i < layouts.size(); ++i) @@ -111,9 +115,9 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector &); +Settings decodeSettings(const Bytes &, const std::vector &); +Bytes encodeSettings(const Settings &); void saveSettings(const std::filesystem::path &, const Settings &); void validateSettings(const Settings &); struct Entry diff --git a/src/ui.cpp b/src/ui.cpp index ebf769f..0af7216 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -1,5 +1,6 @@ #include "../resources/resource.h" #include "app.h" +#include "configuration.h" #include "ocr.h" #include "richtext.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; if (msg == WM_SETFOCUS) { - app->hotkeyCapture = w; + app->setHotkeyCapture(w); SendMessageW(w, EM_SETSEL, 0, -1); } if (msg == WM_KILLFOCUS || msg == WM_NCDESTROY) { if (app->hotkeyCapture == w) - app->hotkeyCapture = nullptr; + app->setHotkeyCapture(nullptr); } if (msg == CaptureHotkeyMessage) { @@ -218,13 +219,13 @@ LRESULT CALLBACK hotkeyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW return 0; 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); SendMessageW(field, EM_SETCUEBANNER, FALSE, (LPARAM)L"Click, then press a shortcut"); 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); } @@ -241,11 +242,11 @@ void value(HWND w, int id, const std::wstring &text) { 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; } -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); } @@ -352,7 +353,7 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp) fitToMonitor(w, *(RECT *)lp); arrangeWindow(w); } -std::filesystem::path pickFile(HWND owner, bool save) +[[maybe_unused]] std::filesystem::path pickFile(HWND owner, bool save) { wchar_t path[32768]{}; OPENFILENAMEW ofn{sizeof(ofn)}; @@ -372,6 +373,7 @@ void disposeFont(HWND w) DeleteObject(h); } +#ifndef KS_CONFIG_APP struct SearchResult { uint64_t generation; @@ -755,6 +757,8 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) return DefWindowProcW(w, msg, wp, lp); } +#endif +#ifndef KS_RESIDENT_APP struct SettingsView { App *app{}; @@ -1146,11 +1150,11 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) fillSettings(w, v); 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", MB_YESNO | MB_ICONWARNING) == IDYES) - v->app->history->clear(); + v->app->clearHistory(); return 0; } break; @@ -1294,6 +1298,8 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) } return DefWindowProcW(w, msg, wp, lp); } +#endif +#ifndef KS_CONFIG_APP LRESULT CALLBACK entryProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) { if (msg == WM_SIZE) @@ -1305,7 +1311,9 @@ LRESULT CALLBACK entryProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) disposeFont(w); return DefWindowProcW(w, msg, wp, lp); } +#endif } // namespace +#ifndef KS_CONFIG_APP void refreshHistory(App &app) { if (app.historyWindow) @@ -1498,7 +1506,7 @@ void refreshLogView(HWND w, App &app) InvalidateRect(list, nullptr, TRUE); if (row) 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."); } LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) @@ -1609,6 +1617,8 @@ void showLogs(App &app) showRequestedWindow(w); activateWindow(w); } +#endif +#ifndef KS_RESIDENT_APP void showSettings(App &app, bool visible) { if (app.settingsWindow) @@ -1699,6 +1709,11 @@ void showSnippets(App &app) showRequestedWindow(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) { auto &s = app.settings; @@ -1736,4 +1751,5 @@ void showEntry(App &app, const std::wstring &id) error(app.historyWindow, e); } } +#endif } // namespace ks diff --git a/src/update.h b/src/update.h index e437ac4..24446b7 100644 --- a/src/update.h +++ b/src/update.h @@ -2,7 +2,7 @@ #include "platform.h" namespace ks { -inline constexpr wchar_t AppVersion[] = L"0.2.3"; +inline constexpr wchar_t AppVersion[] = L"0.3.0"; inline constexpr UINT UpdateMessage = WM_APP + 7; struct UpdateResult { diff --git a/tests/configuration.ps1 b/tests/configuration.ps1 new file mode 100644 index 0000000..292ede9 --- /dev/null +++ b/tests/configuration.ps1 @@ -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 +} diff --git a/tests/tests.cpp b/tests/tests.cpp index 44e4950..3112463 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -389,6 +389,10 @@ int main(int argc, char **argv) settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}}; ks::saveSettings(temp / L"settings.json", settings); 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(ks::loadSettings(temp / L"defaults.json", {}).writeLog, "logging defaults on for existing diagnostic installations"); diff --git a/tests/update.ps1 b/tests/update.ps1 index 717901d..0a5f3aa 100644 --- a/tests/update.ps1 +++ b/tests/update.ps1 @@ -30,6 +30,10 @@ try { } $source=Join-Path $root 'source'; [IO.Directory]::CreateDirectory($source) | Out-Null 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') } [IO.Directory]::CreateDirectory((Join-Path $source 'dictionaries')) | Out-Null [IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary') @@ -57,11 +61,15 @@ try { $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' + 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 'dictionaries/en.txt')) -eq 'personal dictionary') 'dictionary preserved' Undo-Files $target $backup $changed Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'rollback restores original' Assert (!(Test-Path -LiteralPath (Join-Path $target 'README.md'))) 'rollback removes newly introduced files' + Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Config.exe'))) 'rollback removes newly introduced configurator' # 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') diff --git a/tools/update-running-build.ps1 b/tools/update-running-build.ps1 index c63d8ba..fca74ae 100644 --- a/tools/update-running-build.ps1 +++ b/tools/update-running-build.ps1 @@ -6,6 +6,9 @@ try { $stop = Start-Process -FilePath $exe -ArgumentList '--exit' -WindowStyle Hidden -PassThru if (-not $stop.WaitForExit(5000)) { throw 'Exit command timed out.' } 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 Start-Process -FilePath $exe -WindowStyle Hidden 'OK' | Set-Content -LiteralPath (Join-Path $root 'build\update-result.txt')