Compare commits

..

7 Commits

30 changed files with 2256 additions and 156 deletions
+18 -6
View File
@@ -1,36 +1,48 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.1.0 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.3.0 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp)
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp)
target_include_directories(kscore PUBLIC src PRIVATE vendor)
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
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/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 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(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++)
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
enable_testing()
add_test(NAME native-regressions COMMAND KSwitcherTests)
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
install(TARGETS KSwitcher RUNTIME DESTINATION .)
install(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" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
+90 -14
View File
@@ -1,8 +1,10 @@
# KSwitcher
Native C++20 / Win32 rewrite of the Windows functionality of Typlo. Windows 10/11 x64.
Native C++20 / Win32 rewrite of the Windows functionality of [Typlo](https://github.com/bitcodepro/Typlo). Windows 10/11 x64.
No .NET, Avalonia, WebView, Qt, external graphics engine, or separately installed C++ runtime.
The original Typlo project and its data remain separate.
The original [Typlo](https://github.com/bitcodepro/Typlo) project and its data remain separate.
**[User guide / Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki)** — setup, features, shortcuts, dictionaries, screenshots and troubleshooting (Ukrainian).
## Run
@@ -10,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;
@@ -17,7 +29,7 @@ the task runs in the signed-in user's interactive session, including on battery
Disabling the administrator option restores ordinary per-user startup; disabling startup stops
future automatic launches. Saving unrelated settings does not recreate startup entries.
`KSwitcher.exe --settings` opens Settings, including when the app is already running.
Exit Typlo when using overlapping shortcuts to avoid competing keyboard hooks.
Exit [Typlo](https://github.com/bitcodepro/Typlo) when using overlapping shortcuts to avoid competing keyboard hooks.
### Change a shortcut
@@ -34,6 +46,36 @@ combinations are intercepted. Unassigned shortcuts and simulated copy/paste pass
Choose another shortcut or exit KSwitcher to restore the original action. Windows protects
Ctrl+Alt+Delete and Win+L; these cannot be assigned.
Keyboard and mouse input forwarded by Microsoft Mouse Without Borders follows the same input
path as a local keyboard/mouse, including shortcuts, recent-word conversion and snippets.
KSwitcher marks its own generated input so copy/paste, selection, modifier releases and shell-mask
keys cannot trigger recursive commands or overwrite the recorded typing run. Other injected input
is accepted, including key-up events and VK-only keyboard events. Unicode packets do not provide
physical key positions and therefore reset recent-word tracking; selection conversion still works.
### Updates
Select **Update** in the tray menu to check the stable Releases of
[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases).
A startup check also reports an available version, but never installs it automatically.
Confirm the offered version to download and install it. The updater uses the existing
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
before the application exits. If replacement or startup fails, it restores the previous files;
if recovery itself fails, it reports the retained backup directory.
Saved settings and clipboard history in `data/` or the local-app-data fallback are preserved.
Existing dictionary files are preserved too; missing dictionary files are installed. Save settings
edits before updating. The installation directory must be writable at KSwitcher's current privilege
level. A blocked network request, incomplete release or checksum mismatch leaves the running version
intact and is reported when Update is selected. Startup network failures remain non-interactive.
The helper uses Windows' built-in Windows PowerShell 5.1; no additional runtime is installed.
Settings and the other utility windows use the destination monitor's DPI for their initial frame,
controls and fonts. They can be resized, stay within a remaining monitor's work area after display
changes, and provide scrollbars when their content does not fit. Tab navigation scrolls the focused
field into view; Shift+wheel scrolls horizontally.
## Implemented
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
@@ -45,7 +87,7 @@ Ctrl+Alt+Delete and Win+L; these cannot be assigned.
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
`{{cursor}}` inserts the literal marker. Snippets are stored as plaintext settings, as in Typlo.
`{{cursor}}` inserts the literal marker. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
- Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste;
full-text search, pinning, deletion, full text viewing and image previews.
- Image thumbnails and text/file icons appear directly in the history list. Only visible rows
@@ -65,7 +107,7 @@ Ctrl+Alt+Delete and Win+L; these cannot be assigned.
- Single instance and `--history`, `--convert`, `--case`, `--transliterate`, `--settings`,
`--snippets`, `--exit` commands. `--convert` cycles layouts.
Default shortcuts match Typlo: Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
Default shortcuts match [Typlo](https://github.com/bitcodepro/Typlo): Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
Z (undo), 1/2/3 (first layouts), and Ctrl+Shift+V (history).
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
@@ -77,23 +119,43 @@ Pressing the history shortcut a second time pastes the second-most-recent item.
Data is in `data/` beside the executable, falling back to `%LOCALAPPDATA%/KSwitcher` when
the executable's directory is not writable. Each clipboard payload is an encrypted disk file;
only bounded previews/metadata stay in memory. Image data is loaded on demand and released when
the history popup is hidden. The log contains operational messages, never copied text or keystrokes.
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.
Successful operations stay in a memory buffer of at most 40 diagnostic events for 30 seconds.
Only failures flush this context to `kswitcher.log`; flushed
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,
and layout IDs, correlated by request ID even when verification fails. Text fields use quoted,
escaped UTF-16 (`\uXXXX` for non-ASCII and control characters) for exact reproduction, with
original length and a truncation flag; each field is limited to 512 UTF-16 code units.
Manual conversion attempts in `kswitcher.log` have request IDs and explicit outcomes, including
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
input. Successful repeated commands do not flush the log; intentional layout cycling is normal.
An unchanged result containing no letters, or a direct command
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.
The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates.
Open **View log…** from Settings or the tray menu to read logs inside KSwitcher.
Choose the current or previous log and press **Refresh** to reload. The ListView shows Time,
Attempt, Event, and Details columns. Select rows and use **Copy selected** or Ctrl+C to copy
tab-separated entries; Ctrl+A selects all rows. Viewing saved logs also works when logging is disabled.
**Clear log** clears only the selected current or previous file and refreshes the table.
Clearing the current log also discards buffered diagnostic context; new problems can still be logged.
Settings **Import** accepts a Typlo `settings.json`, preserving supported Windows layout IDs,
hotkeys, snippets, exclusions and limits. Import is staged until Save. Existing Typlo files are
never modified. Typlo's encrypted SQLite history is **not migrated** to the new DPAPI file format.
Settings **Import** accepts a [Typlo](https://github.com/bitcodepro/Typlo) `settings.json`, preserving supported Windows layout IDs,
hotkeys, snippets, exclusions and limits. Import is staged until Save. Existing [Typlo](https://github.com/bitcodepro/Typlo) files are
never modified. [Typlo](https://github.com/bitcodepro/Typlo)'s encrypted SQLite history is **not migrated** to the new DPAPI file format.
DPAPI data belongs to the Windows account/machine; copying the portable folder is not a cross-account backup.
## Differences still to port
This is a working native implementation, not a claim of complete UI/release parity:
- UI currently uses English and the native Windows control appearance. Typlo's ten-language UI and
- UI currently uses English and the native Windows control appearance. [Typlo](https://github.com/bitcodepro/Typlo)'s ten-language UI and
explicit light/dark theme selector are not yet ported.
- Automatic release download/replacement is not enabled. KSwitcher has no configured release feed;
using Typlo's feed would install the wrong application. Updates are manual.
- No import of old encrypted clipboard history. Export/import applies to settings.
- The clipboard popup is fixed-size with per-monitor DPI scaling; PNG/DIB previews use Windows WIC/GDI.
- The clipboard popup uses per-monitor DPI scaling and scrolls when space is limited; PNG/DIB previews use Windows WIC/GDI.
The Windows focus/injection rules still apply: an unelevated process cannot edit an elevated app.
Conversion is cancelled rather than pasting into a different window or replacing unverified recent text.
@@ -175,8 +237,21 @@ 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
package validation, file preservation and rollback in temporary fixture directories. It does not
replace a running installation. Optional read-only network checks:
```powershell
.\build\KSwitcherInputTests.exe --update-check
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\tests\update.ps1 -Network
```
These validate the live release metadata/download/checksum in a temporary directory. They do not
prove hardware interoperability between two laptops or a physical monitor-disconnection sequence.
The interactive test requires installed English-US and Russian layouts. It opens its own editor,
tests real copy/paste, undo, repeat conversion, recent-input verification and snippet cursor placement,
then restores the clipboard. Test data is stored in its own temporary directory. It never sends messages
@@ -191,9 +266,10 @@ 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.
Application code is MIT licensed; preserves the original Typlo attribution in `LICENSE`.
Application code is MIT licensed; preserves the original [Typlo](https://github.com/bitcodepro/Typlo) attribution in `LICENSE`.
Dictionary data has its own CC BY-SA 4.0 license as described above.
Binary file not shown.
+21 -10
View File
@@ -1,21 +1,32 @@
# Latest Windows release
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.
## v0.3.0 — 2026-10-28
For upgrades, exit KSwitcher first. Back up any edited dictionaries before replacing files.
Keep your existing `data/` directory to retain settings and clipboard history.
`SHA256SUMS.txt` contains the checksum of the archive.
- 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.
This folder holds only the latest compiled version; older versions remain in Git history.
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.
To refresh it from source:
**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.
This folder holds the latest compiled version; older versions remain in Git history and Releases.
To refresh the tracked package from source:
```powershell
.\build.ps1 -Stage -Test
.\package.ps1 -PublishRelease
```
Commit the updated ZIP and checksum together with the corresponding source changes.
Commit the ZIP and checksum together with their source changes. Publish both files as assets of
one stable release tagged with the executable version, so the updater can verify it.
+1 -1
View File
@@ -1 +1 @@
906de70d92045cc6ff50802841cc357c6b47536fa0cbbb63301d779b33179469 KSwitcher-win-x64.zip
104c79b64bb30a2161e3ec2f5108a731da0d274e015d53d8835ea7e64f431ee1 KSwitcher-win-x64.zip
+19 -5
View File
@@ -14,24 +14,38 @@ Push-Location $root
try {
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
$libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','app','ui','main')) {
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','app','ui','main','configuration','config_main')) {
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
}
& windres resources/app.rc -O coff -o build/app-res.o
& $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/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 @libs -static -s -o build/KSwitcherTests.exe
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
if ($LASTEXITCODE) { throw 'Test build failed' }
& .\build\KSwitcherTests.exe
if ($LASTEXITCODE) { throw 'Tests failed' }
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/app.o build/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' }
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
if ($LASTEXITCODE) { throw 'Updater tests failed' }
}
if ($Integration) {
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/app.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherIntegration.exe
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/app.o build/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' }
+2 -1
View File
@@ -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
+5 -3
View File
@@ -1,10 +1,12 @@
#pragma code_page(65001)
#include <windows.h>
#include "resource.h"
IDI_KSWITCHER ICON "kswitcher.ico"
1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1"
1 VERSIONINFO
FILEVERSION 0,1,0,0
PRODUCTVERSION 0,1,0,0
FILEVERSION 0,3,0,0
PRODUCTVERSION 0,3,0,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
@@ -14,7 +16,7 @@ BEGIN
BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.1.0"
VALUE "FileVersion", "0.3.0"
END
END
BLOCK "VarFileInfo"
+217
View File
@@ -0,0 +1,217 @@
param(
[ValidateSet('Check','Install')][string]$Mode = 'Check',
[string]$CurrentVersion, [string]$ExpectedVersion,
[string]$InstallDirectory, [string]$WorkDirectory,
[int]$ParentProcessId, [string]$ReadyEvent,
[switch]$Library
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function Get-StableVersion([string]$Tag) {
if ($Tag -notmatch '^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
try { return [version]($Tag -replace '^v','') } catch { return $null }
}
function Assert-ReleaseUrl([string]$Url) {
$uri = [uri]$Url
if (!$uri.IsAbsoluteUri -or $uri.Scheme -ne 'https' -or $uri.Host -ne 'gitea.hrynco.com' -or
!$uri.IsDefaultPort -or $uri.UserInfo -or $uri.Query -or $uri.Fragment -or
!$uri.AbsolutePath.StartsWith('/hrynco/hrynco-kswitcher/releases/download/',[StringComparison]::Ordinal)) {
throw 'Release asset URL is outside the KSwitcher repository.'
}
}
function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$request = [Net.HttpWebRequest]::Create($Url)
$request.AllowAutoRedirect = $false
$request.Timeout = 15000
$request.ReadWriteTimeout = 15000
$request.UserAgent = 'KSwitcher-Updater'
$response = $null; $inputStream = $null; $outputStream = $null
try {
$response = $request.GetResponse()
if ([int]$response.StatusCode -ne 200 -or $response.ContentLength -gt $Limit) { throw 'Invalid or oversized server response.' }
$inputStream = $response.GetResponseStream()
$outputStream = [IO.File]::Create($Path)
$buffer = New-Object byte[] 65536
$total = 0L
$deadline = [DateTime]::UtcNow.AddSeconds(60)
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
$total += $count
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
$outputStream.Write($buffer,0,$count)
}
} finally {
if ($outputStream) { $outputStream.Dispose() }
if ($inputStream) { $inputStream.Dispose() }
if ($response) { $response.Dispose() }
}
}
function Select-Release($Releases,[version]$Current) {
$candidates = @($Releases | Where-Object { !$_.draft -and !$_.prerelease -and (Get-StableVersion $_.tag_name) } |
Sort-Object { Get-StableVersion $_.tag_name } -Descending)
if (!$candidates.Count -or (Get-StableVersion $candidates[0].tag_name) -le $Current) { return $null }
$release = $candidates[0]
$zip = @($release.assets | Where-Object name -CEQ 'KSwitcher-win-x64.zip')
$sum = @($release.assets | Where-Object name -CEQ 'SHA256SUMS.txt')
if ($zip.Count -ne 1 -or $sum.Count -ne 1) { throw 'The newest release lacks its x64 ZIP or SHA256SUMS.txt.' }
Assert-ReleaseUrl $zip[0].browser_download_url
Assert-ReleaseUrl $sum[0].browser_download_url
return @{ version=$release.tag_name; zip=$zip[0].browser_download_url; checksum=$sum[0].browser_download_url }
}
function Assert-Checksum([string]$Archive,[string]$Checksum) {
$matchesFound = @([IO.File]::ReadAllLines($Checksum) | Where-Object { $_ -cmatch '^([a-fA-F0-9]{64})\s+\*?KSwitcher-win-x64\.zip$' })
if ($matchesFound.Count -ne 1) { throw 'Missing or ambiguous archive checksum.' }
$expected = ($matchesFound[0] -split '\s+')[0]
if ((Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash -ne $expected) { throw 'Release checksum verification failed.' }
}
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
try {
$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$total = 0L
foreach ($entry in $zip.Entries) {
$name = $entry.FullName.Replace('\','/')
if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue }
if ($name -cnotmatch '^(KSwitcher(?:\.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"
}
$total += $entry.Length
if ($total -gt 100MB -or $zip.Entries.Count -gt 100) { throw 'Release expands beyond allowed limits.' }
$destinationFile = Join-Path $Destination $name
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destinationFile)) | Out-Null
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry,$destinationFile,$false)
}
foreach ($required in @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md')) {
if (!$seen.Contains($required)) { throw "Incomplete release: $required" }
}
if ($Expected -gt [version]'0.2.3' -and !$seen.Contains('KSwitcher.Config.exe')) {
throw 'Incomplete release: KSwitcher.Config.exe'
}
} finally { $zip.Dispose() }
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.'
}
$bytes = [IO.File]::ReadAllBytes($exe)
if ($bytes.Length -lt 64 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) { throw 'Invalid release executable.' }
$pe = [BitConverter]::ToInt32($bytes,60)
if ($pe -lt 64 -or $pe -gt $bytes.Length-6 -or [BitConverter]::ToUInt32($bytes,$pe) -ne 0x4550 -or
[BitConverter]::ToUInt16($bytes,$pe+4) -ne 0x8664) { throw 'Release is not a Windows x64 executable.' }
}
}
function Assert-PlainDirectory([string]$Directory) {
$item = Get-Item -LiteralPath $Directory -Force
while ($item) {
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Update directory must not contain junctions or symbolic links.' }
$item = $item.Parent
}
}
function Install-Files([string]$Stage,[string]$Target,[string]$Backup,[Collections.Generic.List[string]]$Changed) {
Assert-PlainDirectory $Target
foreach ($file in @(Get-ChildItem -LiteralPath $Stage -File -Recurse)) {
$relative = $file.FullName.Substring($Stage.Length+1)
$destination = Join-Path $Target $relative
if ($relative.StartsWith('dictionaries\') -and (Test-Path -LiteralPath $destination)) { continue }
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($destination))
if (Test-Path -LiteralPath $destination) {
if ((Get-Item -LiteralPath $destination -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Cannot replace a linked file.' }
$backupFile = Join-Path $Backup $relative
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($backupFile)) | Out-Null
Move-Item -LiteralPath $destination -Destination $backupFile
}
$Changed.Add($relative)
Move-Item -LiteralPath $file.FullName -Destination $destination
}
}
function Undo-Files([string]$Target,[string]$Backup,[Collections.Generic.List[string]]$Changed) {
for ($i=$Changed.Count-1; $i -ge 0; --$i) {
$destination = Join-Path $Target $Changed[$i]
if (Test-Path -LiteralPath $destination) { Remove-Item -LiteralPath $destination -Force }
$original = Join-Path $Backup $Changed[$i]
if (Test-Path -LiteralPath $original) { Move-Item -LiteralPath $original -Destination $destination }
}
}
function Write-Result($Value) {
$Value | ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $WorkDirectory 'result.json') -Encoding UTF8
}
if ($Library) { return }
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
$changed = [Collections.Generic.List[string]]::new()
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null
try {
$current = Get-StableVersion $CurrentVersion
if (!$current) { throw 'Invalid installed version.' }
if ($Mode -eq 'Install') {
$parentProcess = Get-Process -Id $ParentProcessId
if ([IO.Path]::GetFullPath($parentProcess.Path) -ne [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.exe'))) {
throw 'The running executable does not match the update target.'
}
$ready = [Threading.EventWaitHandle]::OpenExisting($ReadyEvent)
}
$metadata = Join-Path $WorkDirectory 'releases.json'
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' $metadata 2MB
$release = Select-Release (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json) $current
if (!$release) { Write-Result @{state='current'}; exit 0 }
if ($Mode -eq 'Check') { Write-Result @{state='available';version=$release.version}; exit 0 }
if ($ExpectedVersion -ne $release.version) { throw 'Release changed since checking. Please select Update again.' }
Assert-PlainDirectory $InstallDirectory
$transaction = Join-Path $InstallDirectory ('.kswitcher-update-'+[guid]::NewGuid().ToString('N'))
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
[IO.Directory]::CreateDirectory($stage) | Out-Null
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt'
Receive-File $release.zip $archive 50MB
Receive-File $release.checksum $checksum 64KB
Assert-Checksum $archive $checksum
Expand-Release $archive $stage (Get-StableVersion $release.version)
Write-Result @{state='prepared';version=$release.version}
$ready.Set() | Out-Null
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
$parentExited = $true
Install-Files $stage $InstallDirectory $backup $changed
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
$success = $true
} catch {
$message = $_.Exception.Message
if ($parentExited) {
try {
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
Undo-Files $InstallDirectory $backup $changed
Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -WorkingDirectory $InstallDirectory -WindowStyle Hidden
$message += "`nThe previous version has been restored and restarted."
} catch {
$preserveBackup = $true
$message += "`nRecovery needs attention: $($_.Exception.Message)`nBackup: $backup"
}
}
Write-Result @{state='error';error=$message}
if ($ready -and $ready.WaitOne(0)) {
Add-Type -AssemblyName System.Windows.Forms
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
}
exit 1
} finally {
if ($ready) { $ready.Dispose() }
if ($started) { $started.Dispose() }
if ($parentProcess) { $parentProcess.Dispose() }
# Only remove the uniquely created transaction directory directly under the install directory.
if ($transaction -and !$preserveBackup -and [IO.Path]::GetDirectoryName($transaction) -eq [IO.Path]::GetFullPath($InstallDirectory).TrimEnd('\') -and (Test-Path -LiteralPath $transaction)) {
Remove-Item -LiteralPath $transaction -Recurse -Force -ErrorAction SilentlyContinue
}
if ($success) {
# The script is loaded in memory; its unique temporary working directory is no longer needed.
if ((Split-Path -Leaf $WorkDirectory) -like 'KSwitcher-update-*' -and [IO.Path]::GetDirectoryName($WorkDirectory) -eq [IO.Path]::GetTempPath().TrimEnd('\')) {
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
+305 -73
View File
@@ -1,6 +1,7 @@
#include "app.h"
#include "../resources/resource.h"
#include "ocr.h"
#include "configuration.h"
#include <algorithm>
#include <commctrl.h>
#include <set>
@@ -125,7 +126,10 @@ class Transaction
write(textClip(L"\x2063KSwitcher-" + std::to_wstring(GetTickCount64())));
DWORD before = expected;
if (!valid() || !chord(L'C', MOD_CONTROL))
{
diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked");
return {};
}
ULONGLONG deadline = GetTickCount64() + 2000;
DWORD changed = before;
ULONGLONG stable = 0;
@@ -146,7 +150,11 @@ class Transaction
Sleep(10);
}
if (changed == before || !valid())
{
diagnosticEvent(changed == before ? L"Copy failed: clipboard timeout (2000 ms)" :
L"Copy failed: context changed");
return {};
}
if ((GetClipboardOwner() ? !ownsClipboard(target) : changedAgain) ||
changed != GetClipboardSequenceNumber())
throw std::runtime_error("Operation cancelled: copy did not belong to the target application");
@@ -159,13 +167,19 @@ class Transaction
{
if (f.name == L"VisualStudioEditorOperationsLineCutCopyClipboardTag" ||
f.name == L"MSDEVLineSelect")
{
diagnosticEvent(L"Copy rejected: editor copied line without selection");
return {};
}
if (f.name == L"vscode-editor-data")
{
std::string json(f.data.begin(), f.data.end());
if (json.find("\"isFromEmptySelection\":true") != json.npos ||
json.find("\"isFromEmptySelection\": true") != json.npos)
{
diagnosticEvent(L"Copy rejected: editor reported empty selection");
return {};
}
}
}
return text;
@@ -188,7 +202,7 @@ class Transaction
auto text = copy();
if (valid() && matches(text))
{
log(L"Selection: verified word boundary");
diagnosticEvent(L"Selection: verified word boundary");
return text;
}
if (!valid() || !chord(VK_RIGHT))
@@ -230,6 +244,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
settings = loadSettings(data / L"missing-defaults.json", layouts);
registrationWarning = L"Settings were not loaded: " + wide(e.what());
}
setLoggingEnabled(settings.writeLog);
try
{
history = std::make_unique<History>(data / L"history");
@@ -242,6 +257,8 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
}
App::~App()
{
if (updateWorker.joinable())
updateWorker.join();
if (keyboardHook)
UnhookWindowsHookEx(keyboardHook);
if (mouseHook)
@@ -349,6 +366,7 @@ void App::applySettings(const Settings &s)
{
std::lock_guard lock(stateMutex);
settings = s;
setLoggingEnabled(s.writeLog);
run.reset();
last.reset();
++inputVersion;
@@ -381,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))
@@ -398,23 +428,15 @@ void App::resetInput()
run.reset();
last.reset();
}
void App::keyboard(const KBDLLHOOKSTRUCT &k)
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
{
if (k.flags & LLKHF_INJECTED)
if (ownKeyboardInput(k))
return;
DWORD vk = k.vkCode;
if (vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LCONTROL || vk == VK_RCONTROL || vk == VK_LMENU ||
if (vk == VK_SHIFT || vk == VK_CONTROL || vk == VK_MENU ||
vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LCONTROL || vk == VK_RCONTROL || vk == VK_LMENU ||
vk == VK_RMENU || vk == VK_LWIN || vk == VK_RWIN)
return;
UINT mods = 0;
if (GetAsyncKeyState(VK_CONTROL) & 0x8000)
mods |= MOD_CONTROL;
if (GetAsyncKeyState(VK_MENU) & 0x8000)
mods |= MOD_ALT;
if (GetAsyncKeyState(VK_SHIFT) & 0x8000)
mods |= MOD_SHIFT;
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000)
mods |= MOD_WIN;
std::lock_guard lock(stateMutex);
if (isHotkey(vk, mods))
return;
@@ -433,7 +455,11 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k)
return;
}
bool shift = (mods & MOD_SHIFT) != 0;
wchar_t ch = vk == VK_SPACE ? L' ' : physicalKey(k.scanCode, shift);
// VK_PACKET carries UTF-16 in scanCode, not a physical key position.
DWORD scan = vk == VK_PACKET ? 0 : k.scanCode;
if (!scan && vk != VK_PACKET)
scan = MapVirtualKeyExW(vk, MAPVK_VK_TO_VSC_EX, layout);
wchar_t ch = vk == VK_SPACE ? L' ' : physicalKey(scan, shift);
if (ch && letter(ch) && (GetKeyState(VK_CAPITAL) & 1))
ch = shift ? lower(std::wstring(1, ch))[0] : upper(std::wstring(1, ch))[0];
if (!ch)
@@ -477,85 +503,87 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k)
}
}
}
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
{
if (code == HC_ACTION && (((KBDLLHOOKSTRUCT *)lp)->flags & LLKHF_INJECTED) &&
((KBDLLHOOKSTRUCT *)lp)->vkCode == 0xE8)
return CallNextHookEx(nullptr, code, wp, lp); // Our shell-mask key is never recorded.
if (code == HC_ACTION && current && current->hotkeyCapture &&
GetForegroundWindow() == GetAncestor(current->hotkeyCapture, GA_ROOT))
// Filter before capture, hotkey latching and typing history. External injected
// down AND up events use exactly the same path as physical keyboard input.
if (ownKeyboardInput(key) || installingUpdate)
return false;
DWORD captureProcess{};
if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess);
if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess))
setHotkeyCapture(nullptr);
if (hotkeyCapture && GetForegroundWindow() == GetAncestor(hotkeyCapture, GA_ROOT))
{
auto &key = *(KBDLLHOOKSTRUCT *)lp;
bool modifier = key.vkCode == VK_LCONTROL || key.vkCode == VK_RCONTROL || key.vkCode == VK_LSHIFT ||
bool modifier = key.vkCode == VK_CONTROL || key.vkCode == VK_SHIFT || key.vkCode == VK_MENU ||
key.vkCode == VK_LCONTROL || key.vkCode == VK_RCONTROL || key.vkCode == VK_LSHIFT ||
key.vkCode == VK_RSHIFT || key.vkCode == VK_LMENU || key.vkCode == VK_RMENU ||
key.vkCode == VK_LWIN || key.vkCode == VK_RWIN;
if (!modifier)
{
if (wp == WM_KEYUP || wp == WM_SYSKEYUP)
current->hotkeyHook.update(key.vkCode, false, 0, {});
if (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN)
if (!down)
hotkeyHook.update(key.vkCode, false, 0, {});
if (down)
{
UINT mods = 0;
if (GetAsyncKeyState(VK_CONTROL) & 0x8000)
mods |= MOD_CONTROL;
if (GetAsyncKeyState(VK_MENU) & 0x8000)
mods |= MOD_ALT;
if (GetAsyncKeyState(VK_SHIFT) & 0x8000)
mods |= MOD_SHIFT;
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000)
mods |= MOD_WIN;
if (mods & MOD_WIN)
chord(0xE8);
PostMessageW(current->hotkeyCapture, CaptureHotkeyMessage, key.vkCode, mods);
PostMessageW(hotkeyCapture, CaptureHotkeyMessage, key.vkCode, mods);
}
return 1; // Record the chord without executing a global shortcut or typing into an editor.
}
}
if (code == HC_ACTION && (((KBDLLHOOKSTRUCT *)lp)->flags & LLKHF_INJECTED))
return CallNextHookEx(nullptr, code, wp, lp);
if (code == HC_ACTION && (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN) && current)
if (down)
{
auto &key = *(KBDLLHOOKSTRUCT *)lp;
UINT mods = 0;
if (GetAsyncKeyState(VK_CONTROL) & 0x8000)
mods |= MOD_CONTROL;
if (GetAsyncKeyState(VK_MENU) & 0x8000)
mods |= MOD_ALT;
if (GetAsyncKeyState(VK_SHIFT) & 0x8000)
mods |= MOD_SHIFT;
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000)
mods |= MOD_WIN;
auto action = current->hotkeyHook.update(key.vkCode, true, mods, current->hookBindings);
auto action = hotkeyHook.update(key.vkCode, true, mods, hookBindings);
if (action > 0)
{
// Tell the shell that Win participated in a chord, preventing Start
// from opening on Win-up after we consumed V. 0xE8 is unassigned.
if (mods & (MOD_WIN | MOD_ALT))
chord(0xE8);
PostMessageW(current->window, WM_HOTKEY, action, 0);
PostMessageW(window, WM_HOTKEY, action, 0);
}
if (action != 0)
return 1;
current->keyboard(*(KBDLLHOOKSTRUCT *)lp);
keyboard(key, mods);
}
else if (hotkeyHook.update(key.vkCode, false, 0, hookBindings) != 0)
return true;
return false;
}
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
{
if (code == HC_ACTION && current &&
(wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN || wp == WM_KEYUP || wp == WM_SYSKEYUP))
{
// The current event has not updated asynchronous state yet; the preceding
// modifier events (including MWB input) have. Own events must still reach Windows.
UINT mods = 0;
if (GetAsyncKeyState(VK_CONTROL) & 0x8000) mods |= MOD_CONTROL;
if (GetAsyncKeyState(VK_MENU) & 0x8000) mods |= MOD_ALT;
if (GetAsyncKeyState(VK_SHIFT) & 0x8000) mods |= MOD_SHIFT;
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000) mods |= MOD_WIN;
if (current->handleKeyboard(*(KBDLLHOOKSTRUCT *)lp,
wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN, mods))
return 1;
}
if (code == HC_ACTION && (wp == WM_KEYUP || wp == WM_SYSKEYUP) && current &&
current->hotkeyHook.update(((KBDLLHOOKSTRUCT *)lp)->vkCode, false, 0, current->hookBindings) != 0)
return 1;
return CallNextHookEx(nullptr, code, wp, lp);
}
LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
{
if (code == HC_ACTION && current)
{
auto *e = (MSLLHOOKSTRUCT *)lp;
if (!(e->flags & LLMHF_INJECTED) &&
(wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL))
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
// range just like local clicks, including while a transaction is pending.
if (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL)
current->resetInput();
}
return CallNextHookEx(nullptr, code, wp, lp);
}
void App::dispatch(int command)
{
if (installingUpdate)
return;
try
{
if (command == ShowHistory)
@@ -568,6 +596,11 @@ void App::dispatch(int command)
showSettings(*this);
return;
}
if (command == 22)
{
showLogs(*this);
return;
}
if (command == 21)
{
showSnippets(*this);
@@ -587,9 +620,34 @@ void App::dispatch(int command)
}
void App::transform(int command, int snippet)
{
if (busy.exchange(true))
return;
if (installingUpdate) return;
const auto attempt = ++transformAttempt;
const auto requestedAt = GetTickCount64();
HWND target = GetForegroundWindow();
const bool manual = command != AutoCorrect && snippet < 0;
if (manual)
{
const auto currentVersion = inputVersion.load();
repeatAttempts = target == previousAttemptWindow && command == previousAttemptCommand &&
currentVersion == previousAttemptVersion &&
requestedAt - previousAttemptAt < 10000
? repeatAttempts + 1 : 1;
previousAttemptWindow = target;
previousAttemptCommand = command;
previousAttemptVersion = currentVersion;
previousAttemptAt = requestedAt;
diagnosticEvent(L"Transform request id=" + std::to_wstring(attempt) + L" command=" +
std::to_wstring(command) + L" process=" + processName(target) +
L" window=" + std::to_wstring(reinterpret_cast<uintptr_t>(target)) +
L" version=" + std::to_wstring(currentVersion) +
L" repeat=" + std::to_wstring(repeatAttempts));
}
if (busy.exchange(true))
{
if (manual)
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true);
return;
}
Run recent;
std::optional<LastTransform> previous;
Settings config;
@@ -604,7 +662,7 @@ void App::transform(int command, int snippet)
version = inputVersion;
}
enqueue(
[this, target, recent, previous, config, available, version, command, snippet]
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt]
{
struct BusyReset
{
@@ -615,6 +673,47 @@ void App::transform(int command, int snippet)
}
} reset{busy};
auto start = GetTickCount64();
std::wstring reason = L"cancelled-before-selection";
std::wstring text, result, expected;
int hint = -1, to = command >= Direct ? command - Direct : -1;
if (manual)
{
std::wstring layoutIds;
for (const auto &layout : available)
{
layoutIds += layout.id + L",";
if (layout.handle == recent.layout)
for (wchar_t key : recent.keys)
expected += key == L' ' ? L' ' : layout.at(physicalLayout().code(key));
}
diagnosticEvent(L"Transform context id=" + std::to_wstring(attempt) +
L" layouts=" + layoutIds + L" physicalKeys=" + diagnosticText(recent.keys) +
L" trackedExpected=" + diagnosticText(expected));
}
struct Audit
{
App &app;
HWND target;
uint64_t version, attempt;
ULONGLONG start;
bool enabled;
std::wstring &reason;
std::wstring &text, &result, &expected;
int &hint, &to;
~Audit()
{
if (enabled)
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=" + reason +
L" foregroundSame=" + std::to_wstring(GetForegroundWindow() == target) +
L" inputSame=" + std::to_wstring(app.inputVersion.load() == version) +
L" elapsedMs=" + std::to_wstring(GetTickCount64() - start) +
L" source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
L" expected=" + diagnosticText(expected) + L" copied=" + diagnosticText(text) +
L" result=" + diagnosticText(result),
reason != L"paste-sent-unverified" && reason != L"layout-only" &&
reason != L"unchanged-expected");
}
} audit{*this, target, version, attempt, requestedAt, manual, reason, text, result, expected, hint, to};
try
{
auto valid = [&]
@@ -627,16 +726,16 @@ void App::transform(int command, int snippet)
releaseModifiers();
if (!valid())
return;
int hint = -1;
for (int i = 0; i < (int)available.size(); ++i)
if (available[i].handle == windowLayout(target))
hint = i;
int to = command >= Direct ? command - Direct : -1;
reason = L"invalid-target-layout";
if (to >= (int)available.size())
return;
if (to >= 0 && !available[to].convertible)
{
switchWindowLayout(target, available[to].handle, valid);
reason = switchWindowLayout(target, available[to].handle, valid)
? L"layout-only" : L"layout-switch-failed";
return;
}
if (command == AutoCorrect)
@@ -654,12 +753,16 @@ void App::transform(int command, int snippet)
!autoCorrectionAllowed(target, config) || !valid())
return;
}
reason = L"clipboard-snapshot";
Transaction tx(*this, target, version);
log(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
reason = L"selection-unavailable";
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
L" recent=" + std::to_wstring(config.recent) + L" keys=" +
std::to_wstring(recent.keys.size()) + L" sameWindow=" +
std::to_wstring(recent.window == target));
std::wstring text, result;
std::to_wstring(recent.window == target) + L" overflow=" +
std::to_wstring(recent.overflow) + L" ageMs=" +
std::to_wstring(recent.at ? start - recent.at : 0) +
L" id=" + std::to_wstring(attempt));
bool automatic = false;
if (command == AutoCorrect)
{
@@ -692,6 +795,9 @@ void App::transform(int command, int snippet)
}
else if (command == Undo)
{
if (previous)
expected = previous->result;
reason = L"undo-unavailable";
if (!previous || previous->window != target || previous->version != version ||
GetTickCount64() - previous->at > 120000)
return;
@@ -700,6 +806,7 @@ void App::transform(int command, int snippet)
{ return newlines(candidate) == newlines(previous->result); });
if (newlines(text) != newlines(previous->result))
{
reason = L"undo-verification-mismatch";
if (tx.valid())
chord(VK_RIGHT);
return;
@@ -738,10 +845,11 @@ void App::transform(int command, int snippet)
tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate)
{ return recent.match(candidate, available, physicalLayout()) >= 0; });
hint = recent.match(text, available, physicalLayout());
log(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" +
diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" +
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
if (hint < 0)
{
reason = text.empty() ? L"recent-copy-empty" : L"recent-verification-mismatch";
if (tx.valid())
chord(VK_RIGHT);
return;
@@ -751,11 +859,13 @@ void App::transform(int command, int snippet)
else if (config.recent && previous && previous->window == target &&
previous->version == version)
{
expected = previous->result;
text = tx.copyRange(caretSteps(previous->result), previous->result,
[&](const std::wstring &candidate)
{ return newlines(candidate) == newlines(previous->result); });
if (newlines(text) != newlines(previous->result))
{
reason = L"previous-result-verification-mismatch";
if (tx.valid())
chord(VK_RIGHT);
return;
@@ -765,10 +875,12 @@ void App::transform(int command, int snippet)
}
else
text = tx.copy();
reason = L"context-changed-after-copy";
if (!tx.valid())
return;
if (text.empty())
{
reason = L"copy-empty";
if (to >= 0 && config.switchLayout)
switchWindowLayout(target, available[to].handle, valid);
return;
@@ -790,6 +902,7 @@ void App::transform(int command, int snippet)
cycle.push_back(i);
if (cycle.size() < 2)
{
reason = L"cycle-has-fewer-than-two-layouts";
if (automatic && tx.valid())
chord(VK_RIGHT);
return;
@@ -804,12 +917,20 @@ void App::transform(int command, int snippet)
}
}
bool sent = false;
log(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
diagnosticEvent(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
L" changed=" + std::to_wstring(result != text));
if (result != text)
sent = tx.paste(textClip(result));
else if (automatic && tx.valid())
chord(VK_RIGHT);
reason = result == text ? L"conversion-unchanged" :
sent ? L"paste-sent-unverified" : L"paste-cancelled";
if (result == text &&
(std::none_of(text.begin(), text.end(), letter) ||
(command >= Direct && detect(text, available, hint) == to)))
reason = L"unchanged-expected";
if (sent && !tx.valid())
reason = L"context-changed-after-paste";
if (sent && command == Undo && previous && previous->autoCorrected)
autoCorrector.reject(previous->original, config);
if (tx.valid() && (sent || automatic))
@@ -835,18 +956,22 @@ void App::transform(int command, int snippet)
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
{
bool switched = switchWindowLayout(target, available[to].handle, valid);
log(L"Layout switch confirmed=" + std::to_wstring(switched));
diagnosticEvent(L"Layout switch confirmed=" + std::to_wstring(switched));
if (!switched)
reason += L";layout-switch-failed";
}
log(L"Text operation completed in " + std::to_wstring(GetTickCount64() - start) + L" ms");
diagnosticEvent(L"Text operation completed in " + std::to_wstring(GetTickCount64() - start) + L" ms");
}
catch (const std::exception &e)
{
reason = L"exception:" + wide(e.what());
report(wide(e.what()));
}
});
}
void App::pasteEntry(std::wstring id, bool plain)
{
if (installingUpdate) return;
if (!history || busy.exchange(true))
return;
HWND target = pasteTarget;
@@ -984,6 +1109,10 @@ void App::trayMenu()
AppendMenuW(pause, MF_STRING, 23, L"Until restart");
AppendMenuW(menu, MF_POPUP, (UINT_PTR)pause, L"Pause clipboard history");
AppendMenuW(menu, MF_STRING, 15, L"Open data folder");
AppendMenuW(menu, MF_STRING, 18, L"View log…");
AppendMenuW(menu, MF_STRING | (updateRunning ? MF_GRAYED : 0), 19,
updateRunning ? L"Update (checking / preparing…)" :
availableUpdate.empty() ? L"Update" : L"Update (new version available)");
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
AppendMenuW(menu, MF_STRING, 16, L"About KSwitcher");
AppendMenuW(menu, MF_STRING, 17, L"Exit");
@@ -1022,14 +1151,20 @@ void App::trayMenu()
break;
case 16:
MessageBoxW(window,
L"KSwitcher 0.1.0\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:
PostMessageW(window, WM_CLOSE, 0, 0);
break;
case 18:
showLogs(*this);
break;
case 19:
checkUpdate(true);
break;
case 20:
pauseUntil = 0;
break;
@@ -1049,6 +1184,32 @@ void App::trayMenu()
report(wide(e.what()));
}
}
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.");
return;
}
if (updateWorker.joinable()) updateWorker.join();
updateRunning = true;
installingUpdate = install;
updateWorker = std::thread([this, interactive, install, version]
{
auto result = std::make_unique<UpdateResult>();
try { *result = runUpdate(install, version); }
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
result->interactive = interactive;
result->install = install;
if (PostMessageW(window, UpdateMessage, 0, reinterpret_cast<LPARAM>(result.get()))) result.release();
});
}
LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
{
auto *app = current;
@@ -1061,6 +1222,50 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
}
switch (msg)
{
case UpdateMessage:
{
std::unique_ptr<UpdateResult> result(reinterpret_cast<UpdateResult *>(lp));
if (app->updateWorker.joinable()) app->updateWorker.join();
app->updateRunning = false;
app->installingUpdate = false;
if (result->state == L"prepared")
{
// The helper has verified and staged the package; it waits for process exit.
DestroyWindow(hwnd);
}
else if (result->state == L"available")
{
app->availableUpdate = result->version;
if (result->interactive)
{
auto prompt = L"Install KSwitcher " + result->version +
L"?\n\nThe program will close and restart. Settings, history and existing dictionaries are preserved.";
if (MessageBoxW(hwnd, prompt.c_str(), L"KSwitcher Update", MB_YESNO | MB_ICONQUESTION) == IDYES)
app->checkUpdate(true, true, result->version);
}
else
{
NOTIFYICONDATAW icon{sizeof(icon)};
icon.hWnd = hwnd; icon.uID = 1; icon.uFlags = NIF_INFO; icon.dwInfoFlags = NIIF_INFO;
wcscpy_s(icon.szInfoTitle, L"KSwitcher update available");
auto text = result->version + L" is available. Select Update in the tray menu to install.";
wcsncpy_s(icon.szInfo, text.c_str(), _TRUNCATE);
Shell_NotifyIconW(NIM_MODIFY, &icon);
}
}
else if (result->state == L"current")
{
app->availableUpdate.clear();
if (result->interactive)
MessageBoxW(hwnd, (std::wstring(L"No newer stable release is available.\nInstalled version: ") + AppVersion).c_str(),
L"KSwitcher Update", MB_OK | MB_ICONINFORMATION);
}
else if (result->interactive)
MessageBoxW(hwnd, (L"Update failed.\n\n" + result->error).c_str(), L"KSwitcher Update", MB_OK | MB_ICONERROR);
else
diagnosticEvent(L"Startup update check failed: " + result->error);
return 0;
}
case WM_HOTKEY:
app->dispatch((int)wp);
return 0;
@@ -1078,10 +1283,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
app->captureHistory();
return 0;
case AutoCorrectMessage:
if (app->installingUpdate) return 0;
app->pendingAutoVersion = (uint64_t)wp;
SetTimer(hwnd, 3, 250, nullptr);
return 0;
case SnippetMessage:
if (app->installingUpdate) return 0;
app->transform(0, (int)wp);
return 0;
case HistoryMessage:
@@ -1104,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;
@@ -1113,6 +1325,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
return TRUE;
}
case WM_TIMER:
if (wp == 5)
{
KillTimer(hwnd, 5);
app->checkUpdate(false);
return 0;
}
if (wp == 4)
{
if (GetAsyncKeyState(VK_LBUTTON) & 0x8000)
@@ -1124,7 +1342,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
if (wp == 3)
{
KillTimer(hwnd, 3);
if (app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
app->transform(AutoCorrect);
return 0;
}
@@ -1141,6 +1359,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
}
return 0;
case WM_CLOSE:
if (app->updateRunning)
{
app->report(L"Wait for the update check or preparation to finish before exiting.");
return 0;
}
if (app->busy)
{
app->report(L"Wait for the current text operation to finish before exiting.");
@@ -1149,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:
@@ -1210,6 +1436,10 @@ int App::execute()
report(registrationWarning);
if (smoke)
SetTimer(window, 2, 10000, nullptr);
else
SetTimer(window, 5, 5000, nullptr);
if (keyboardHook && mouseHook)
confirmUpdatedStartup(updateStartupEvent);
if (startCommand)
PostMessageW(window, WM_HOTKEY, startCommand, 0);
MSG msg;
@@ -1225,6 +1455,8 @@ int App::execute()
continue;
if (snippetsWindow && IsDialogMessageW(snippetsWindow, &msg))
continue;
if (logWindow && IsDialogMessageW(logWindow, &msg))
continue;
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
+19 -2
View File
@@ -1,6 +1,7 @@
#pragma once
#include "autocorrect.h"
#include "storage.h"
#include "update.h"
#include <atomic>
#include <condition_variable>
#include <deque>
@@ -42,12 +43,15 @@ class App
std::atomic<ULONGLONG> pauseUntil{0};
HWND pasteTarget{};
HWND hotkeyCapture{};
DWORD hotkeyCaptureProcess{};
HWND logWindow{};
std::mutex stateMutex;
Run run;
std::optional<LastTransform> last;
std::wstring registrationWarning;
bool smoke{};
int startCommand{};
std::wstring updateStartupEvent;
App(HINSTANCE, bool = false, const std::filesystem::path &dataRoot = {});
~App();
int execute();
@@ -63,20 +67,32 @@ 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 &);
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
void resetInput();
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
static App *current;
private:
friend struct InputRegressionAccess;
HHOOK keyboardHook{}, mouseHook{};
std::mutex jobsMutex;
std::condition_variable jobsReady;
std::deque<std::function<void()>> jobs;
std::thread worker;
std::thread updateWorker;
bool updateRunning{}, installingUpdate{};
std::wstring availableUpdate;
bool stop{};
UINT taskbarCreated{};
uint64_t pendingAutoVersion{};
uint64_t transformAttempt{}, previousAttemptVersion{};
ULONGLONG previousAttemptAt{};
HWND previousAttemptWindow{};
int previousAttemptCommand{}, repeatAttempts{};
std::vector<std::pair<int, Hotkey>> hookBindings;
HotkeyHook hotkeyHook;
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
@@ -85,7 +101,8 @@ class App
};
void showHistory(App &);
void refreshHistory(App &);
void showSettings(App &);
void showSettings(App &, bool visible = true);
void showLogs(App &);
void showSnippets(App &);
void showShortcuts(App &);
void showEntry(App &, const std::wstring &);
+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 &);
}
+1
View File
@@ -47,6 +47,7 @@ struct Settings
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
bool elevatedAutostart = false;
bool autoCorrect = false;
bool writeLog = true;
int autoMaxRank = 0;
int autoMinLength = 3;
std::wstring autoPrefixChars = L"([{«“‘\"";
+3 -1
View File
@@ -67,7 +67,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
try
{
ks::App app(instance, arg == L"--smoke-test");
if (!arg.empty() && arg != L"--smoke-test")
if (arg.starts_with(L"--update-ready-event="))
app.updateStartupEvent = arg.substr(std::wstring(L"--update-ready-event=").size());
else if (!arg.empty() && arg != L"--smoke-test")
app.startCommand = cmd;
result = app.execute();
}
+121 -16
View File
@@ -347,15 +347,30 @@ size_t Clip::size() const
n += f.data.size();
return n;
}
ULONG_PTR inputMarker()
{
static const char marker{};
return reinterpret_cast<ULONG_PTR>(&marker);
}
bool ownKeyboardInput(const KBDLLHOOKSTRUCT &key)
{
return (key.flags & LLKHF_INJECTED) && key.dwExtraInfo == inputMarker();
}
INPUT keyboardInput(WORD key, DWORD flags)
{
INPUT input{};
input.type = INPUT_KEYBOARD;
input.ki.wVk = key;
input.ki.dwFlags = flags;
input.ki.dwExtraInfo = inputMarker();
return input;
}
bool chord(WORD key, UINT mods)
{
std::vector<INPUT> inputs;
auto add = [&](WORD k, bool up)
{
INPUT i{};
i.type = INPUT_KEYBOARD;
i.ki.wVk = k;
i.ki.dwFlags = up ? KEYEVENTF_KEYUP : 0;
INPUT i = keyboardInput(k, up ? KEYEVENTF_KEYUP : 0);
if (k == VK_LEFT || k == VK_RIGHT || k == VK_INSERT)
i.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
inputs.push_back(i);
@@ -406,10 +421,7 @@ bool selectPrevious(size_t count, const std::function<bool()> &valid)
std::vector<INPUT> inputs;
auto add = [&](WORD key, DWORD flags)
{
INPUT i{};
i.type = INPUT_KEYBOARD;
i.ki.wVk = key;
i.ki.dwFlags = flags;
INPUT i = keyboardInput(key, flags);
inputs.push_back(i);
};
add(VK_SHIFT, 0);
@@ -437,10 +449,7 @@ void releaseModifiers()
for (int k : {VK_LCONTROL, VK_RCONTROL, VK_LMENU, VK_RMENU, VK_LSHIFT, VK_RSHIFT, VK_LWIN, VK_RWIN})
if (GetAsyncKeyState(k) & 0x8000)
{
INPUT i{};
i.type = INPUT_KEYBOARD;
i.ki.wVk = (WORD)k;
i.ki.dwFlags = KEYEVENTF_KEYUP;
INPUT i = keyboardInput((WORD)k, KEYEVENTF_KEYUP);
inputs.push_back(i);
}
if (!inputs.empty())
@@ -527,16 +536,112 @@ std::wstring lastError(const std::wstring &context)
{
return context + L" (Windows error " + std::to_wstring(GetLastError()) + L")";
}
std::wstring diagnosticText(const std::wstring &text)
{
// Escape UTF-16 code units so invisible characters and malformed surrogate pairs
// can be reproduced without introducing fake log lines. Bound large selections.
std::wstring out = L"\"";
constexpr wchar_t hex[] = L"0123456789abcdef";
const auto count = std::min<size_t>(text.size(), 512);
for (size_t i = 0; i < count; ++i)
{
auto c = static_cast<unsigned short>(text[i]);
if (c == L'"' || c == L'\\')
{
out += L'\\';
out += c;
}
else if (c < 32 || c >= 127)
{
out += L"\\u";
for (int shift = 12; shift >= 0; shift -= 4)
out += hex[(c >> shift) & 15];
}
else
out += c;
}
return out + L"\" units=" + std::to_wstring(text.size()) +
L" truncated=" + std::to_wstring(count != text.size());
}
namespace
{
std::recursive_mutex logGate;
bool loggingEnabled = false;
DiagnosticBuffer diagnosticBuffer;
}
std::wstring DiagnosticBuffer::record(const std::wstring &message, bool problem, ULONGLONG now)
{
while (!entries.empty() && (entries.size() >= 40 || now - entries.front().first > 30000))
entries.pop_front();
entries.emplace_back(now, message);
if (!problem)
return {};
std::wstring out = L"Diagnostic incident (preceding context, up to 30 seconds):";
for (const auto &[at, entry] : entries)
out += L"\n ageMs=" + std::to_wstring(now - at) + L" " + entry;
entries.clear();
return out;
}
void setLoggingEnabled(bool enabled)
{
std::lock_guard lock(logGate);
loggingEnabled = enabled;
if (!enabled)
diagnosticBuffer.clear();
}
std::wstring readDiagnosticLog(bool previous)
{
std::lock_guard lock(logGate);
auto path = dataDirectory() / (previous ? L"kswitcher.log.1" : L"kswitcher.log");
if (!std::filesystem::exists(path))
return previous ? L"No previous log is available yet." : L"No log yet. Entries will appear when a problem is detected.";
auto bytes = readFile(path, 2 * 1024 * 1024);
auto text = wide(std::string(bytes.begin(), bytes.end()));
std::wstring display;
for (size_t i = 0; i < text.size(); ++i)
{
if (text[i] == L'\n' && (i == 0 || text[i - 1] != L'\r'))
display += L'\r';
display += text[i];
}
return display.empty() ? L"The log is empty." : display;
}
void clearDiagnosticLog(bool previous)
{
// Serialize clearing with writes and rotation. Never delete the other log.
std::lock_guard lock(logGate);
auto path = dataDirectory() / (previous ? L"kswitcher.log.1" : L"kswitcher.log");
std::filesystem::remove(path);
if (!previous)
diagnosticBuffer.clear();
}
void diagnosticEvent(const std::wstring &message, bool problem)
{
std::lock_guard lock(logGate);
if (!loggingEnabled)
return;
auto incident = diagnosticBuffer.record(message, problem, GetTickCount64());
if (!incident.empty())
log(incident);
}
void log(const std::wstring &message)
{
static std::mutex gate;
std::lock_guard lock(gate);
std::lock_guard lock(logGate);
if (!loggingEnabled)
return;
try
{
auto path = dataDirectory() / L"kswitcher.log";
std::error_code ec;
if (std::filesystem::file_size(path, ec) > 256 * 1024)
std::filesystem::remove(path, ec);
auto size = std::filesystem::file_size(path, ec);
if (!ec && size > 256 * 1024)
{
auto previous = path;
previous += L".1";
std::filesystem::remove(previous, ec);
ec.clear();
std::filesystem::rename(path, previous, ec);
}
std::ofstream f(path, std::ios::app);
SYSTEMTIME t;
GetLocalTime(&t);
+18
View File
@@ -1,6 +1,7 @@
#pragma once
#include "core.h"
#include <filesystem>
#include <deque>
#include <objbase.h>
#include <span>
#include <stdexcept>
@@ -35,6 +36,10 @@ Clip readClipboard(HWND, size_t limit = 32 * 1024 * 1024);
DWORD writeClipboard(HWND, const Clip &);
Clip textClip(const std::wstring &);
bool chord(WORD key, UINT modifiers = 0);
// Process-local marker, shared by every KSwitcher input producer (not an authentication token).
ULONG_PTR inputMarker();
bool ownKeyboardInput(const KBDLLHOOKSTRUCT &);
INPUT keyboardInput(WORD key, DWORD flags = 0);
bool needsSelectionDelay(HWND);
bool selectPrevious(size_t count, const std::function<bool()> &valid);
bool modifiersReleased();
@@ -50,4 +55,17 @@ void configureAutostart(int mode);
std::wstring executable();
std::wstring lastError(const std::wstring &context);
void log(const std::wstring &message);
std::wstring diagnosticText(const std::wstring &text);
// A bounded, short-lived context. A problem drains it, so old incidents are not repeated.
class DiagnosticBuffer
{
std::deque<std::pair<ULONGLONG, std::wstring>> entries;
public:
std::wstring record(const std::wstring &message, bool problem, ULONGLONG now);
void clear() { entries.clear(); }
};
void setLoggingEnabled(bool enabled);
void diagnosticEvent(const std::wstring &message, bool problem = false);
std::wstring readDiagnosticLog(bool previous);
void clearDiagnosticLog(bool previous);
} // namespace ks
+13 -3
View File
@@ -102,6 +102,10 @@ void validateSettings(const Settings &s)
}
}
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;
for (size_t i = 0; i < layouts.size(); ++i)
@@ -111,14 +115,15 @@ 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.directIds.push_back(layouts[i].id);
}
if (!std::filesystem::exists(path))
if (bytes.empty())
return s;
auto j = parse(readFile(path, 4 * 1024 * 1024));
auto j = parse(bytes);
auto format = j.value("format", std::string());
if (format != "kswitcher-settings" && format != "typlo-settings")
throw std::runtime_error("Not a KSwitcher or Typlo settings file");
s.recent = j.value("convertLastWord", true);
s.switchLayout = j.value("switchSystemLayout", true);
s.writeLog = j.value("writeLog", true);
s.history = j.value("historyEnabled", true);
s.autostart = j.value("startWithSystem", false);
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
@@ -176,12 +181,17 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
return s;
}
void saveSettings(const std::filesystem::path &path, const Settings &s)
{
atomicWrite(path, encodeSettings(s));
}
Bytes encodeSettings(const Settings &s)
{
validateSettings(s);
Json j = {{"format", "kswitcher-settings"},
{"schemaVersion", 1},
{"convertLastWord", s.recent},
{"switchSystemLayout", s.switchLayout},
{"writeLog", s.writeLog},
{"autostartAsAdministrator", s.elevatedAutostart},
{"historyEnabled", s.history},
{"startWithSystem", s.autostart},
@@ -219,7 +229,7 @@ void saveSettings(const std::filesystem::path &path, const Settings &s)
j["excludedApps"] = Json::array();
for (auto &name : s.excluded)
j["excludedApps"].push_back(utf8(name));
atomicWrite(path, jsonBytes(j));
return jsonBytes(j);
}
Bytes encodeClip(const Clip &clip)
{
+2
View File
@@ -4,6 +4,8 @@
namespace ks
{
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 validateSettings(const Settings &);
struct Entry
+455 -20
View File
@@ -1,7 +1,9 @@
#include "../resources/resource.h"
#include "app.h"
#include "configuration.h"
#include "ocr.h"
#include "richtext.h"
#include "ui_geometry.h"
#include <algorithm>
#include <commctrl.h>
#include <commdlg.h>
@@ -26,6 +28,129 @@ HFONT font(HWND w)
OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH,
L"Segoe UI");
}
struct WindowLayout
{
int width{}, height{}, x{}, y{};
bool scrolling{}, arranging{};
int wheelRemainder{}, horizontalWheelRemainder{};
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
};
WindowLayout *windowLayoutState(HWND w)
{
return reinterpret_cast<WindowLayout *>(GetPropW(w, L"WindowLayout"));
}
RECT workArea(RECT rect)
{
MONITORINFO monitor{sizeof(monitor)};
GetMonitorInfoW(MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST), &monitor);
return monitor.rcWork;
}
void fitToMonitor(HWND w, RECT rect)
{
rect = fitWindowRect(rect, workArea(rect));
SetWindowPos(w, nullptr, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top,
SWP_NOACTIVATE | SWP_NOZORDER);
}
void arrangeWindow(HWND w)
{
auto *layout = windowLayoutState(w);
if (!layout || !layout->scrolling || layout->arranging) return;
layout->arranging = true;
// Showing either scrollbar can make the other necessary. Let the client size settle.
for (int pass = 0; pass < 3; ++pass)
{
RECT client{};
GetClientRect(w, &client);
SCROLLINFO horizontal{sizeof(horizontal), SIF_RANGE | SIF_PAGE | SIF_POS,
0, scale(w, layout->width) - 1, (UINT)client.right, layout->x};
SCROLLINFO vertical{sizeof(vertical), SIF_RANGE | SIF_PAGE | SIF_POS,
0, scale(w, layout->height) - 1, (UINT)client.bottom, layout->y};
layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE);
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
}
for (const auto &[child, r] : layout->controls)
SetWindowPos(child, nullptr, scale(w, r.left) - layout->x, scale(w, r.top) - layout->y,
scale(w, r.right - r.left), scale(w, r.bottom - r.top), SWP_NOACTIVATE | SWP_NOZORDER);
layout->arranging = false;
InvalidateRect(w, nullptr, TRUE);
}
void revealControl(HWND child)
{
HWND parent = GetParent(child);
auto *layout = windowLayoutState(parent);
if (!layout || !layout->scrolling) return;
RECT r{}, client{};
GetWindowRect(child, &r);
MapWindowPoints(nullptr, parent, reinterpret_cast<POINT *>(&r), 2);
GetClientRect(parent, &client);
if (r.left < 0) layout->x += r.left;
else if (r.right > client.right) layout->x += r.right - client.right;
if (r.top < 0) layout->y += r.top;
else if (r.bottom > client.bottom) layout->y += r.bottom - client.bottom;
arrangeWindow(parent);
}
LRESULT CALLBACK scrollChildProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
{
if (msg == WM_SETFOCUS) revealControl(w);
if (msg == WM_NCDESTROY) RemoveWindowSubclass(w, scrollChildProc, 2);
return DefSubclassProc(w, msg, wp, lp);
}
void resizeForDpi(HWND, WPARAM, LPARAM);
LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
{
auto *layout = windowLayoutState(w);
if (!layout) return DefSubclassProc(w, msg, wp, lp);
if (msg == WM_DPICHANGED)
{
resizeForDpi(w, wp, lp);
return 0;
}
if (msg == WM_SIZE) arrangeWindow(w);
if (msg == WM_DISPLAYCHANGE || msg == WM_SETTINGCHANGE || (msg == WM_SHOWWINDOW && wp))
{
RECT rect{};
GetWindowRect(w, &rect);
fitToMonitor(w, rect);
arrangeWindow(w);
}
if (layout->scrolling && (msg == WM_VSCROLL || msg == WM_HSCROLL))
{
bool vertical = msg == WM_VSCROLL;
SCROLLINFO info{sizeof(info), SIF_ALL};
GetScrollInfo(w, vertical ? SB_VERT : SB_HORZ, &info);
int &position = vertical ? layout->y : layout->x;
switch (LOWORD(wp))
{
case SB_LINEUP: position -= scale(w, 32); break;
case SB_LINEDOWN: position += scale(w, 32); break;
case SB_PAGEUP: position -= (int)info.nPage; break;
case SB_PAGEDOWN: position += (int)info.nPage; break;
case SB_TOP: position = 0; break;
case SB_BOTTOM: position = info.nMax; break;
case SB_THUMBTRACK: case SB_THUMBPOSITION: position = info.nTrackPos; break;
}
arrangeWindow(w);
return 0;
}
if (layout->scrolling && (msg == WM_MOUSEWHEEL || msg == WM_MOUSEHWHEEL))
{
bool horizontal = msg == WM_MOUSEHWHEEL || (GET_KEYSTATE_WPARAM(wp) & MK_SHIFT);
int &remainder = horizontal ? layout->horizontalWheelRemainder : layout->wheelRemainder;
remainder += GET_WHEEL_DELTA_WPARAM(wp);
int steps = remainder / WHEEL_DELTA;
remainder %= WHEEL_DELTA;
(horizontal ? layout->x : layout->y) += steps * scale(w, 48) * (msg == WM_MOUSEHWHEEL ? 1 : -1);
arrangeWindow(w);
return 0;
}
if (msg == WM_NCDESTROY)
{
RemovePropW(w, L"WindowLayout");
RemoveWindowSubclass(w, layoutProc, 2);
delete layout;
}
return DefSubclassProc(w, msg, wp, lp);
}
HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style, int id, int x, int y,
int width, int height)
{
@@ -34,6 +159,12 @@ HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style,
scale(parent, width), scale(parent, height), parent, (HMENU)(INT_PTR)id,
GetModuleHandleW(nullptr), nullptr);
SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE);
if (auto *layout = windowLayoutState(parent); layout && layout->scrolling)
{
layout->controls.push_back({w, {x, y, x + width, y + height}});
SetWindowSubclass(w, scrollChildProc, 2, 0);
arrangeWindow(parent);
}
return w;
}
void label(HWND w, const wchar_t *text, int x, int y, int width = 200)
@@ -55,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)
{
@@ -88,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);
}
@@ -111,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);
}
@@ -136,13 +267,31 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
RegisterClassW(&wc);
UINT dpi = GetDpiForSystem();
RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | extra;
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_THICKFRAME | WS_MAXIMIZEBOX | extra;
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
POINT cursor{};
GetCursorPos(&cursor);
MONITORINFO monitor{sizeof(monitor)};
GetMonitorInfoW(MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST), &monitor);
HWND w =
CreateWindowExW(WS_EX_APPWINDOW, cls, title, style, CW_USEDEFAULT, CW_USEDEFAULT,
CreateWindowExW(WS_EX_APPWINDOW, cls, title, style, monitor.rcWork.left, monitor.rcWork.top,
rect.right - rect.left, rect.bottom - rect.top, nullptr, nullptr, app.instance, &app);
if (!w)
throw std::runtime_error(utf8(lastError(L"Cannot create native window")));
// The system DPI may still describe the old primary display. The HWND tells
// us the actual destination DPI, and must size the frame AND its controls.
dpi = GetDpiForWindow(w);
rect = {0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
int outerWidth = rect.right - rect.left, outerHeight = rect.bottom - rect.top;
int left = monitor.rcWork.left + std::max(0L, (monitor.rcWork.right - monitor.rcWork.left - outerWidth) / 2);
int top = monitor.rcWork.top + std::max(0L, (monitor.rcWork.bottom - monitor.rcWork.top - outerHeight) / 2);
SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)});
SetWindowSubclass(w, layoutProc, 2, 0);
RECT initial = fitWindowRect({left, top, left + outerWidth, top + outerHeight}, monitor.rcWork);
SetWindowPos(w, nullptr, initial.left, initial.top, initial.right - initial.left, initial.bottom - initial.top,
SWP_NOACTIVATE | SWP_NOZORDER);
arrangeWindow(w);
SetWindowTextW(w, title);
auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
GetSystemMetricsForDpi(SM_CXSMICON, dpi),
@@ -154,6 +303,12 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
}
void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
{
// Creation may send WM_DPICHANGED before controls and the original font exist.
if (!GetPropW(w, L"Dpi"))
{
fitToMonitor(w, *(RECT *)lp);
return;
}
int previous = (int)(UINT_PTR)GetPropW(w, L"Dpi");
int next = LOWORD(wp);
if (!previous)
@@ -166,7 +321,8 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
int previous, next;
HFONT font;
} state{w, previous, next, replacement};
EnumChildWindows(
auto *layout = windowLayoutState(w);
if (!layout || !layout->scrolling) EnumChildWindows(
w,
[](HWND child, LPARAM data) -> BOOL
{
@@ -183,14 +339,21 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
return TRUE;
},
(LPARAM)&state);
else
{
layout->x = MulDiv(layout->x, next, previous);
layout->y = MulDiv(layout->y, next, previous);
for (auto &[child, rect] : layout->controls)
SendMessageW(child, WM_SETFONT, (WPARAM)replacement, TRUE);
}
SetPropW(w, L"Font", replacement);
SetPropW(w, L"Dpi", (HANDLE)(UINT_PTR)next);
if (old)
DeleteObject(old);
auto *r = (RECT *)lp;
SetWindowPos(w, nullptr, r->left, r->top, r->right - r->left, r->bottom - r->top, SWP_NOZORDER);
fitToMonitor(w, *(RECT *)lp);
arrangeWindow(w);
}
std::filesystem::path pickFile(HWND owner, bool save)
[[maybe_unused]] std::filesystem::path pickFile(HWND owner, bool save)
{
wchar_t path[32768]{};
OPENFILENAMEW ofn{sizeof(ofn)};
@@ -210,6 +373,7 @@ void disposeFont(HWND w)
DeleteObject(h);
}
#ifndef KS_CONFIG_APP
struct SearchResult
{
uint64_t generation;
@@ -593,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{};
@@ -645,6 +811,7 @@ void fillSettings(HWND w, SettingsView *v)
auto &s = v->draft;
check(w, 201, s.recent);
check(w, 202, s.switchLayout);
check(w, 208, s.writeLog);
check(w, 203, s.history);
check(w, 204, s.autostart);
check(w, 206, s.elevatedAutostart);
@@ -693,6 +860,7 @@ Settings collectSettings(HWND w, SettingsView *v)
auto s = v->draft;
s.recent = checked(w, 201);
s.switchLayout = checked(w, 202);
s.writeLog = checked(w, 208);
s.history = checked(w, 203);
s.autostart = checked(w, 204);
s.elevatedAutostart = checked(w, 206);
@@ -908,6 +1076,11 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
layoutSelection(w, v);
return 0;
}
if (id == 256)
{
showLogs(*v->app);
return 0;
}
if (id == 230)
{
collectLayout(w, v);
@@ -977,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;
@@ -1125,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)
@@ -1136,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)
@@ -1195,7 +1372,254 @@ void showHistory(App &app)
SetFocus(GetDlgItem(app.historyWindow, 101));
refreshHistory(app);
}
void showSettings(App &app)
namespace
{
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
LogClear = 606;
LRESULT CALLBACK logListProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
{
// Header notifications go to the ListView, not its top-level parent.
if (msg == WM_NOTIFY && ((NMHDR *)lp)->hwndFrom == ListView_GetHeader(w) &&
((NMHDR *)lp)->code == NM_CUSTOMDRAW)
{
auto *draw = (NMCUSTOMDRAW *)lp;
if (draw->dwDrawStage == CDDS_PREPAINT)
{
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE));
return CDRF_NOTIFYITEMDRAW;
}
if (draw->dwDrawStage == CDDS_ITEMPREPAINT)
{
int saved = SaveDC(draw->hdc);
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE));
RECT edge = draw->rc;
edge.top = edge.bottom - 1;
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW));
edge = draw->rc;
edge.left = edge.right - 1;
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW));
wchar_t title[128]{};
HDITEMW item{};
item.mask = HDI_TEXT;
item.pszText = title;
item.cchTextMax = 128;
Header_GetItem(draw->hdr.hwndFrom, (int)draw->dwItemSpec, &item);
auto headerFont = (HFONT)SendMessageW(draw->hdr.hwndFrom, WM_GETFONT, 0, 0);
if (headerFont)
SelectObject(draw->hdc, headerFont);
SetBkMode(draw->hdc, TRANSPARENT);
SetTextColor(draw->hdc, GetSysColor(COLOR_BTNTEXT));
RECT text = draw->rc;
InflateRect(&text, -scale(w, 6), 0);
DrawTextW(draw->hdc, title, -1, &text, DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS | DT_NOPREFIX);
RestoreDC(draw->hdc, saved);
return CDRF_SKIPDEFAULT;
}
}
if (msg == WM_NCDESTROY)
RemoveWindowSubclass(w, logListProc, 1);
return DefSubclassProc(w, msg, wp, lp);
}
void copyLogRows(HWND w, App &app)
{
HWND list = GetDlgItem(w, LogRows);
std::wstring copied;
for (int row = ListView_GetNextItem(list, -1, LVNI_SELECTED); row >= 0;
row = ListView_GetNextItem(list, row, LVNI_SELECTED))
{
for (int column = 0; column < 4; ++column)
{
std::vector<wchar_t> text(32768);
ListView_GetItemText(list, row, column, text.data(), (int)text.size());
if (column)
copied += L'\t';
copied += text.data();
}
copied += L"\r\n";
}
if (!copied.empty())
app.ownSequence = writeClipboard(app.window, textClip(copied));
}
void refreshLogView(HWND w, App &app)
{
const bool previous = SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1;
std::wstring contents;
try
{
contents = readDiagnosticLog(previous);
}
catch (const std::exception &e)
{
contents = L"Could not read the log: " + wide(e.what());
}
HWND list = GetDlgItem(w, LogRows);
SendMessageW(list, WM_SETREDRAW, FALSE, 0);
ListView_DeleteAllItems(list);
std::wistringstream lines(contents);
std::wstring line, timestamp;
int row = 0;
while (std::getline(lines, line))
{
if (!line.empty() && line.back() == L'\r')
line.pop_back();
if (line.empty())
continue;
auto firstSpace = line.find(L' ');
auto secondSpace = firstSpace == line.npos ? line.npos : line.find(L' ', firstSpace + 1);
if (iswdigit(line.front()) && line.find(L'-') < firstSpace && secondSpace != line.npos)
{
timestamp = line.substr(0, secondSpace);
line.erase(0, secondSpace + 1);
}
auto id = line.find(L" id=");
std::wstring attempt;
if (id != line.npos)
{
auto end = line.find(L' ', id + 4);
attempt = line.substr(id + 4, end == line.npos ? end : end - id - 4);
}
std::wstring event = line;
if (event.starts_with(L" ageMs="))
{
auto end = event.find(L' ', 8);
if (end != event.npos)
event.erase(0, end + 1);
}
auto equals = event.find(L'=');
if (equals != event.npos)
{
auto key = event.rfind(L' ', equals);
if (key != event.npos)
event.resize(key);
}
LVITEMW item{};
item.mask = LVIF_TEXT;
item.iItem = row;
item.pszText = timestamp.data();
ListView_InsertItem(list, &item);
ListView_SetItemText(list, row, 1, attempt.data());
ListView_SetItemText(list, row, 2, event.data());
ListView_SetItemText(list, row, 3, line.data());
++row;
}
SendMessageW(list, WM_SETREDRAW, TRUE, 0);
InvalidateRect(list, nullptr, TRUE);
if (row)
ListView_EnsureVisible(list, row - 1, FALSE);
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)
{
auto *app = reinterpret_cast<App *>(GetWindowLongPtrW(w, GWLP_USERDATA));
if (msg == WM_NCCREATE)
{
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)((CREATESTRUCTW *)lp)->lpCreateParams);
return TRUE;
}
if (!app)
return DefWindowProcW(w, msg, wp, lp);
switch (msg)
{
case WM_COMMAND:
if ((LOWORD(wp) == LogRefresh && HIWORD(wp) == BN_CLICKED) ||
(LOWORD(wp) == LogChoice && HIWORD(wp) == CBN_SELCHANGE))
{
refreshLogView(w, *app);
return 0;
}
if (LOWORD(wp) == LogCopy && HIWORD(wp) == BN_CLICKED)
{
try { copyLogRows(w, *app); }
catch (const std::exception &e) { error(w, e); }
return 0;
}
if (LOWORD(wp) == LogClear && HIWORD(wp) == BN_CLICKED)
{
try
{
clearDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
refreshLogView(w, *app);
}
catch (const std::exception &e) { error(w, e); }
return 0;
}
if (LOWORD(wp) != IDCANCEL || HIWORD(wp) != BN_CLICKED)
break;
[[fallthrough]];
case WM_CLOSE:
DestroyWindow(w);
return 0;
case WM_NOTIFY:
if (((NMHDR *)lp)->idFrom == LogRows && ((NMHDR *)lp)->code == LVN_KEYDOWN)
{
auto key = ((NMLVKEYDOWN *)lp)->wVKey;
if (GetKeyState(VK_CONTROL) & 0x8000)
{
if (key == 'A')
ListView_SetItemState(GetDlgItem(w, LogRows), -1, LVIS_SELECTED, LVIS_SELECTED);
if (key == 'C')
{
try { copyLogRows(w, *app); }
catch (const std::exception &e) { error(w, e); }
}
}
}
return 0;
case WM_DPICHANGED:
resizeForDpi(w, wp, lp);
return 0;
case WM_NCDESTROY:
app->logWindow = nullptr;
disposeFont(w);
break;
}
return DefWindowProcW(w, msg, wp, lp);
}
}
void showLogs(App &app)
{
if (app.logWindow)
{
showRequestedWindow(app.logWindow);
activateWindow(app.logWindow);
return;
}
auto w = makeWindow(app, L"KSwitcher.Logs", L"KSwitcher log", logProc, 900, 590);
app.logWindow = w;
auto choice = control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST, LogChoice, 16, 16, 300, 140);
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Current log — kswitcher.log");
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Previous log — kswitcher.log.1");
SendMessageW(choice, CB_SETCURSEL, 0, 0);
button(w, L"Refresh", LogRefresh, 330, 14, 110);
button(w, L"Copy selected", LogCopy, 454, 14, 140);
button(w, L"Clear log", LogClear, 608, 14, 110);
control(w, L"STATIC", L"", 0, LogStatus, 16, 52, 860, 22);
INITCOMMONCONTROLSEX common{sizeof(common), ICC_LISTVIEW_CLASSES};
InitCommonControlsEx(&common);
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
LogRows, 16, 82, 868, 454);
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER);
SetWindowSubclass(list, logListProc, 1, 0);
const wchar_t *titles[] = {L"Time", L"Attempt", L"Event", L"Details"};
const int widths[] = {155, 65, 195, 900};
for (int i = 0; i < 4; ++i)
{
LVCOLUMNW column{};
column.mask = LVCF_TEXT | LVCF_WIDTH;
column.pszText = const_cast<wchar_t *>(titles[i]);
column.cx = scale(w, widths[i]);
ListView_InsertColumn(list, i, &column);
}
label(w, L"Select rows to copy. Ctrl+A selects all; Ctrl+C copies selected rows.", 16, 551, 720);
button(w, L"Close", IDCANCEL, 774, 548, 110);
refreshLogView(w, app);
showRequestedWindow(w);
activateWindow(w);
}
#endif
#ifndef KS_RESIDENT_APP
void showSettings(App &app, bool visible)
{
if (app.settingsWindow)
{
@@ -1209,12 +1633,13 @@ void showSettings(App &app)
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 780, 660);
app.settingsWindow = w;
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"Switch Windows input layout after conversion", 202, 18, 70, 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, 390);
checkbox(w, L"Record clipboard history", 203, 18, 106);
checkbox(w, L"Start with Windows", 204, 18, 133);
checkbox(w, L"Start with Windows as administrator", 206, 18, 160, 395);
checkbox(w, L"Keep screen awake", 205, 18, 184);
checkbox(w, L"Keep screen awake", 205, 18, 184, 210);
checkbox(w, L"Write log", 208, 240, 184, 160);
label(w, L"Click a shortcut and press the desired keys", 420, 16, 350);
const wchar_t *names[] = {L"Next layout", L"Change case", L"Transliterate", L"Undo",
L"Clipboard history"};
@@ -1245,14 +1670,18 @@ void showSettings(App &app)
label(w, L"Exclude applications (process names, comma-separated)", 18, 516, 700);
edit(w, 243, 18, 544, 742);
button(w, L"Auto-correction options…", 255, 18, 575, 240);
button(w, L"View log…", 256, 278, 575, 220);
button(w, L"Import settings…", 250, 18, 606, 145);
button(w, L"Export settings…", 251, 173, 606, 145);
button(w, L"Snippets…", 252, 328, 606, 110);
button(w, L"Save", IDOK, 548, 606, 100);
button(w, L"Cancel", IDCANCEL, 660, 606, 100);
fillSettings(w, (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA));
showRequestedWindow(w);
activateWindow(w);
if (visible)
{
showRequestedWindow(w);
activateWindow(w);
}
}
void showSnippets(App &app)
{
@@ -1280,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;
@@ -1317,4 +1751,5 @@ void showEntry(App &app, const std::wstring &id)
error(app.historyWindow, e);
}
}
#endif
} // namespace ks
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include "core.h"
#include <algorithm>
namespace ks
{
inline RECT fitWindowRect(RECT desired, RECT work)
{
LONG width = std::clamp<LONG>(desired.right - desired.left, 1, std::max<LONG>(1, work.right - work.left));
LONG height = std::clamp<LONG>(desired.bottom - desired.top, 1, std::max<LONG>(1, work.bottom - work.top));
LONG x = std::clamp(desired.left, work.left, work.right - width);
LONG y = std::clamp(desired.top, work.top, work.bottom - height);
return {x, y, x + width, y + height};
}
}
+103
View File
@@ -0,0 +1,103 @@
#include "update.h"
#include "json.hpp"
namespace ks
{
namespace
{
struct Handle
{
HANDLE value{};
~Handle() { if (value) CloseHandle(value); }
};
std::wstring quoteArgument(const std::wstring &value)
{
// Windows argv escaping, including a path ending with a backslash.
std::wstring out = L"\"";
size_t slashes = 0;
for (auto ch : value)
{
if (ch == L'\\') { ++slashes; continue; }
out.append(ch == L'"' ? slashes * 2 + 1 : slashes, L'\\');
slashes = 0;
out += ch;
}
out.append(slashes * 2, L'\\');
return out + L'"';
}
}
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
{
UpdateResult result;
GUID guid{};
if (FAILED(CoCreateGuid(&guid))) throw std::runtime_error("Cannot create update identifier");
wchar_t id[40]{};
StringFromGUID2(guid, id, 40);
auto work = std::filesystem::temp_directory_path() / (L"KSwitcher-update-" + std::wstring(id));
std::filesystem::create_directory(work);
bool detached = false;
Handle child;
try
{
HRSRC resource = FindResourceW(nullptr, MAKEINTRESOURCEW(201), RT_RCDATA);
HGLOBAL loaded = resource ? LoadResource(nullptr, resource) : nullptr;
auto bytes = loaded ? static_cast<const unsigned char *>(LockResource(loaded)) : nullptr;
if (!bytes) throw std::runtime_error("The updater resource is missing");
atomicWrite(work / L"update.ps1", Bytes(bytes, bytes + SizeofResource(nullptr, resource)));
std::wstring readyName = L"Local\\KSwitcher.Update.Ready." + std::wstring(id);
Handle ready{CreateEventW(nullptr, TRUE, FALSE, readyName.c_str())};
if (!ready.value) throw std::runtime_error("Cannot create updater handshake");
wchar_t system[MAX_PATH]{};
if (!GetSystemDirectoryW(system, MAX_PATH)) throw std::runtime_error("Cannot locate Windows PowerShell");
auto powershell = std::filesystem::path(system) / L"WindowsPowerShell/v1.0/powershell.exe";
std::wstring command = quoteArgument(powershell.wstring()) + L" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " +
quoteArgument((work / L"update.ps1").wstring()) + L" -Mode " + (install ? L"Install" : L"Check") +
L" -CurrentVersion " + quoteArgument(AppVersion) + L" -WorkDirectory " + quoteArgument(work.wstring()) +
L" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) +
L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName);
if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
if (!CreateProcessW(powershell.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
nullptr, work.c_str(), &startup, &process))
throw std::runtime_error("Cannot start Windows PowerShell updater");
child.value = process.hProcess;
Handle thread{process.hThread};
HANDLE waits[]{ready.value, child.value};
DWORD waited = WaitForMultipleObjects(2, waits, FALSE, 240000);
if (waited != WAIT_OBJECT_0 && waited != WAIT_OBJECT_0 + 1)
{
// The helper cannot replace files while this process is alive.
TerminateProcess(child.value, 1);
WaitForSingleObject(child.value, 5000);
throw std::runtime_error("Update preparation timed out; the installed application was not changed");
}
auto data = readFile(work / L"result.json", 64 * 1024);
auto json = nlohmann::json::parse(data.begin(), data.end());
result.state = wide(json.at("state").get<std::string>());
result.version = wide(json.value("version", std::string{}));
result.error = wide(json.value("error", std::string{}));
detached = install && waited == WAIT_OBJECT_0 && result.state == L"prepared";
if (result.state == L"prepared" && !detached)
throw std::runtime_error("Update helper exited before installation");
if (!detached) std::filesystem::remove_all(work);
return result;
}
catch (...)
{
if (!detached && child.value && WaitForSingleObject(child.value, 0) == WAIT_TIMEOUT)
{
TerminateProcess(child.value, 1);
WaitForSingleObject(child.value, 5000);
}
if (!detached) { std::error_code ignored; std::filesystem::remove_all(work, ignored); }
throw;
}
}
void confirmUpdatedStartup(const std::wstring &eventName)
{
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return;
Handle event{OpenEventW(EVENT_MODIFY_STATE, FALSE, eventName.c_str())};
if (event.value) SetEvent(event.value);
}
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.3.0";
inline constexpr UINT UpdateMessage = WM_APP + 7;
struct UpdateResult
{
std::wstring state, version, error;
bool interactive{}, install{};
};
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion);
void confirmUpdatedStartup(const std::wstring &eventName);
}
+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
}
+122
View File
@@ -0,0 +1,122 @@
#include "app.h"
#include "ui_geometry.h"
#include <commctrl.h>
#include <iostream>
namespace ks {
struct InputRegressionAccess {
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
};
}
int main(int argc, char **argv) {
if (argc == 2 && std::string(argv[1]) == "--update-check") {
try {
auto result = ks::runUpdate(false, {});
std::cout << "Live update check: " << ks::utf8(result.state) << " " << ks::utf8(result.version)
<< " " << ks::utf8(result.error) << '\n';
return result.state == L"current" || result.state == L"available" ? 0 : 1;
} catch (const std::exception &e) { std::cerr << e.what() << '\n'; return 1; }
}
auto temp=std::filesystem::temp_directory_path()/(L"KSwitcher-input-tests-"+std::to_wstring(GetCurrentProcessId()));
int checks=0;
auto require=[&](bool ok,const char* text) { if(!ok) throw std::runtime_error(text); ++checks; };
try {
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_LISTVIEW_CLASSES};
InitCommonControlsEx(&controls);
{
ks::App app(GetModuleHandleW(nullptr),true,temp);
ks::InputRegressionAccess::setup(app);
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
require(app.window!=nullptr,"message-only test window");
auto event=[](DWORD vk,DWORD scan=0,bool own=false) {
KBDLLHOOKSTRUCT k{}; k.vkCode=vk; k.scanCode=scan;
k.flags=LLKHF_INJECTED|LLKHF_LOWER_IL_INJECTED;
k.dwExtraInfo=own?ks::inputMarker():123; return k;
};
auto commands=[&] { MSG m{}; int n=0; while(PeekMessageW(&m,app.window,WM_HOTKEY,WM_HOTKEY,PM_REMOVE)) ++n; return n; };
auto f12=event(VK_F12);
require(!app.handleKeyboard(event(VK_LCONTROL),true,0),"remote modifier passes through");
require(app.handleKeyboard(f12,true,MOD_CONTROL),"remote Ctrl+F12 handled");
require(app.handleKeyboard(f12,true,MOD_CONTROL),"remote repeat suppressed");
require(commands()==1,"one command per press");
require(!app.handleKeyboard(event(VK_F12,0,true),false,0),"own key-up bypasses latch");
require(app.handleKeyboard(f12,true,MOD_CONTROL),"own key-up leaves remote latch intact");
require(commands()==0,"no repeat after own key-up");
require(app.handleKeyboard(f12,false,0),"remote release consumed after modifier release");
require(app.handleKeyboard(f12,true,MOD_CONTROL),"second remote press handled");
require(commands()==1,"second remote press dispatches");
app.handleKeyboard(f12,false,0);
require(app.handleKeyboard(event('U'),true,MOD_CONTROL|MOD_SHIFT),"combined modifiers");
app.handleKeyboard(event('U'),false,0);
require(!app.handleKeyboard(f12,true,MOD_CONTROL|MOD_SHIFT),"exact modifiers only");
app.resetInput();
app.handleKeyboard(event('A',0x1e),true,0);
require(app.run.keys==L"a"||app.run.keys==L"A","external scan recorded with current CapsLock");
auto keys=app.run.keys; auto version=app.inputVersion.load();
for(WORD key:{WORD('V'),WORD(VK_SHIFT),WORD(VK_LEFT),WORD(VK_F15),WORD(0xE8)})
for(bool up:{false,true}) {
auto i=ks::keyboardInput(key,up?KEYEVENTF_KEYUP:0);
require(i.ki.dwExtraInfo==ks::inputMarker(),"all generated events tagged");
require(!app.handleKeyboard(event(key,0,true),!up,MOD_CONTROL),"own events bypass handling");
}
require(app.run.keys==keys&&app.inputVersion==version,"own input preserves transaction and run");
app.handleKeyboard(event(VK_BACK),true,0);
require(app.run.keys.empty(),"remote backspace recorded");
app.handleKeyboard(event('B'),true,MOD_SHIFT);
require(!app.run.keys.empty(),"VK-only remote input resolves scan code");
require(app.inputVersion>version,"remote input invalidates pending transaction");
app.handleKeyboard(event(VK_PACKET,0x1e),true,0);
require(app.run.keys.empty(),"Unicode packet is not a physical scan code");
app.handleKeyboard(event('A',0x1e),true,0);
ks::InputRegressionAccess::click();
require(app.run.keys.empty(),"remote mouse resets recorded input");
auto physical=f12; physical.flags=0; physical.dwExtraInfo=ks::inputMarker();
require(app.handleKeyboard(physical,true,MOD_CONTROL),"physical hotkey still works");
app.handleKeyboard(physical,false,0);
auto fitted=ks::fitWindowRect({-2500,-800,-500,1200},{0,0,1200,800});
require(fitted.left==0&&fitted.top==0&&fitted.right==1200&&fitted.bottom==800,"oversized disconnected-monitor window fits work area");
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
ks::showSettings(app,false);
auto settings=app.settingsWindow;
require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus");
RECT frame{},client{},cancel{};
GetWindowRect(settings,&frame);
MONITORINFO monitor{sizeof(monitor)};
GetMonitorInfoW(MonitorFromWindow(settings,MONITOR_DEFAULTTONEAREST),&monitor);
require(frame.left>=monitor.rcWork.left&&frame.top>=monitor.rcWork.top&&frame.right<=monitor.rcWork.right&&frame.bottom<=monitor.rcWork.bottom,"initial settings frame fits active work area");
UINT dpi=GetDpiForWindow(settings);
std::cout << "Hidden Settings layout checked at " << dpi << " DPI\n";
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
require(cancel.right-cancel.left==MulDiv(100,dpi,96),"controls use actual window DPI");
SetWindowPos(settings,nullptr,0,0,480,360,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
SCROLLINFO scroll{sizeof(scroll),SIF_ALL};
GetScrollInfo(settings,SB_VERT,&scroll);
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has vertical scrolling");
GetScrollInfo(settings,SB_HORZ,&scroll);
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has horizontal scrolling");
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0);
SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0);
GetClientRect(settings,&client);
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
MapWindowPoints(nullptr,settings,(POINT*)&cancel,2);
require(cancel.left>=0&&cancel.top>=0&&cancel.right<=client.right&&cancel.bottom<=client.bottom,"bottom actions reachable by scrolling");
SendMessageW(settings,WM_VSCROLL,SB_TOP,0);
SendMessageW(settings,WM_HSCROLL,SB_LEFT,0);
SendMessageW(GetDlgItem(settings,IDCANCEL),WM_SETFOCUS,0,0);
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
MapWindowPoints(nullptr,settings,(POINT*)&cancel,2);
require(cancel.left>=0&&cancel.top>=0&&cancel.right<=client.right&&cancel.bottom<=client.bottom,"keyboard focus reveals off-screen action");
SetWindowPos(settings,nullptr,-20000,-20000,0,0,SWP_NOSIZE|SWP_NOZORDER|SWP_NOACTIVATE);
SendMessageW(settings,WM_DISPLAYCHANGE,0,0);
GetWindowRect(settings,&frame);
GetMonitorInfoW(MonitorFromWindow(settings,MONITOR_DEFAULTTONEAREST),&monitor);
require(frame.left>=monitor.rcWork.left&&frame.top>=monitor.rcWork.top&&frame.right<=monitor.rcWork.right&&frame.bottom<=monitor.rcWork.bottom,"display change recovers off-screen window");
DestroyWindow(settings);
DestroyWindow(app.window); app.window=nullptr;
}
std::filesystem::remove_all(temp);
std::cout<<"Passed "<<checks<<" input regression checks\n"; return 0;
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; return 1; }
}
+94 -1
View File
@@ -1,5 +1,7 @@
#include "app.h"
#include <iostream>
#include <fstream>
#include <commctrl.h>
namespace
{
@@ -14,6 +16,12 @@ const std::vector<std::pair<std::wstring, std::wstring>> punctuationCases{
{L"ljvf: ", L"дома: "}, {L"ljvf; ", L"дома; "}, {L"#ljvf%; ", L"#дома%; "}};
ULONGLONG deadline{};
std::wstring sentinel = L"KSwitcher integration clipboard sentinel";
std::string diagnosticLog()
{
std::ifstream file(ks::dataDirectory() / L"kswitcher.log");
return {std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()};
}
size_t diagnosticStart{};
void require(bool ok, const char *name)
{
if (!ok)
@@ -109,10 +117,28 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
require(text() == L"OLDпривет", "recent-input conversion preserves adjacent older text");
prepare(L"OLDabcdef", false);
record(L"ghbdtn");
diagnosticStart = diagnosticLog().size();
app->busy = true;
app->transform(ks::Direct + ru);
app->transform(ks::Direct + ru);
app->busy = false;
app->transform(ks::Direct + ru);
break;
case 6:
require(text() == L"OLDabcdef", "recent-input mismatch refuses replacement");
{
auto diagnostics = diagnosticLog();
diagnostics = diagnostics.substr(std::min(diagnosticStart, diagnostics.size()));
require(diagnostics.find("reason=busy") != std::string::npos,
"busy conversion attempt is logged");
require(diagnostics.find("repeat=3") != std::string::npos,
"three repeated conversion attempts are logged");
require(diagnostics.find("reason=recent-verification-mismatch") != std::string::npos,
"failed recent-word verification has explicit outcome");
require(diagnostics.find("copied=\"abcdef\"") != std::string::npos &&
diagnostics.find("physicalKeys=\"ghbdtn\"") != std::string::npos,
"conversion diagnostics contain actual text and physical input for reproduction");
}
app->settings.snippets = {{L";addr", L"LINE1\r\n{cursor}LINE2"}};
prepare(L"prefix ;addr", false);
record(L";addr");
@@ -237,7 +263,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
return DefWindowProcW(w, msg, wp, lp);
}
} // namespace
int main()
int main(int argc, char **argv)
{
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
auto root = std::filesystem::temp_directory_path() /
@@ -249,6 +275,73 @@ int main()
app = &instance;
app->settings.history = false;
app->settings.switchLayout = false;
if (argc > 1 && std::string(argv[1]) == "--logs-only")
{
ks::showLogs(instance);
HWND viewer = instance.logWindow;
HWND rows = GetDlgItem(viewer, 602);
require(rows && (GetWindowLongPtrW(rows, GWL_STYLE) & LVS_TYPEMASK) == LVS_REPORT,
"log viewer uses report ListView");
require(Header_GetItemCount(ListView_GetHeader(rows)) == 4, "log viewer has four columns");
require(GetDlgItem(viewer, IDCANCEL) != rows, "log rows and Close have different IDs");
SetFocus(rows);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, EN_SETFOCUS), (LPARAM)rows);
NMHDR clicked{rows, 602, NM_CLICK};
SendMessageW(viewer, WM_NOTIFY, 602, (LPARAM)&clicked);
require(IsWindow(viewer), "focus and click notifications do not close log viewer");
ks::showLogs(instance);
require(instance.logWindow == viewer, "opening logs reuses the existing window");
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 603));
require(IsWindow(viewer) && ListView_GetItemCount(rows) > 0, "refresh populates log rows");
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 1, 0);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(601, CBN_SELCHANGE), (LPARAM)GetDlgItem(viewer, 601));
require(IsWindow(viewer) && ListView_GetItemCount(rows) > 0, "previous log or empty state is displayed");
if (argc > 2 && std::string(argv[2]) == "--clear-test")
{
require(std::filesystem::path(ks::executable()).parent_path().filename() == L"log-clear-test",
"clear test runs in isolated executable directory");
const auto logRoot = ks::dataDirectory();
require(logRoot == std::filesystem::path(ks::executable()).parent_path() / L"data",
"clear test uses isolated portable data");
std::ofstream(logRoot / L"kswitcher.log") << "current-marker\n";
std::ofstream(logRoot / L"kswitcher.log.1") << "previous-marker\n";
ks::diagnosticEvent(L"buffered-before-clear");
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(606, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 606));
require(!std::filesystem::exists(logRoot / L"kswitcher.log.1") &&
ks::readDiagnosticLog(false).find(L"current-marker") != std::wstring::npos,
"Clear log removes selected previous log only");
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 0, 0);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(606, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 606));
require(!std::filesystem::exists(logRoot / L"kswitcher.log") && IsWindow(viewer),
"Clear log clears current file without closing viewer");
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(606, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 606));
ks::diagnosticEvent(L"new-problem", true);
auto newLog = ks::readDiagnosticLog(false);
require(newLog.find(L"new-problem") != std::wstring::npos &&
newLog.find(L"buffered-before-clear") == std::wstring::npos,
"logging resumes after clear without stale context");
}
auto savedClipboard = ks::readClipboard(viewer);
instance.window = viewer;
try
{
ListView_SetItemState(rows, 0, LVIS_SELECTED, LVIS_SELECTED);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(605, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 605));
require(ks::readClipboard(viewer).text().find(L'\t') != std::wstring::npos,
"selected log row copies as tab-separated columns");
}
catch (...)
{
ks::writeClipboard(viewer, savedClipboard);
throw;
}
ks::writeClipboard(viewer, savedClipboard);
instance.window = nullptr;
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, BN_CLICKED), (LPARAM)GetDlgItem(viewer, IDCANCEL));
require(!IsWindow(viewer) && !instance.logWindow, "Close button closes and releases viewer");
CoUninitialize();
return 0;
}
for (int i = 0; i < (int)app->layouts.size(); ++i)
{
auto language = LOWORD((uintptr_t)app->layouts[i].handle);
+38
View File
@@ -44,7 +44,37 @@ int main(int argc, char **argv)
}
try
{
require(ks::diagnosticText(L"word") == L"\"word\" units=4 truncated=0",
"diagnostic word preserved");
require(ks::diagnosticText(L"\"\\\n\r\t") ==
L"\"\\\"\\\\\\u000a\\u000d\\u0009\" units=5 truncated=0",
"diagnostic control characters cannot create log lines");
require(ks::diagnosticText(L"\u0457\u200b") == L"\"\\u0457\\u200b\" units=2 truncated=0",
"diagnostic unicode and invisible characters preserved exactly");
require(ks::diagnosticText(std::wstring(513, L'a')).find(L"units=513 truncated=1") != std::wstring::npos,
"large diagnostic selections are explicitly truncated");
STARTUPINFOW startup{sizeof(startup)};
ks::DiagnosticBuffer diagnostics;
require(diagnostics.record(L"successful-attempt", false, 100).empty(),
"successful diagnostics stay in memory");
auto incident = diagnostics.record(L"failed-attempt", true, 200);
require(incident.find(L"successful-attempt") != std::wstring::npos &&
incident.find(L"failed-attempt") != std::wstring::npos,
"incident includes preceding successful context");
require(diagnostics.record(L"next-failure", true, 300).find(L"successful-attempt") == std::wstring::npos,
"flushed context is not duplicated");
diagnostics.record(L"expired-context", false, 400);
require(diagnostics.record(L"later-failure", true, 30500).find(L"expired-context") == std::wstring::npos,
"context expires after thirty seconds");
diagnostics.record(L"oldest-context", false, 30600);
for (int i = 0; i < 40; ++i)
diagnostics.record(L"bounded-context", false, 30600 + i);
require(diagnostics.record(L"bounded-failure", true, 30700).find(L"oldest-context") == std::wstring::npos,
"context buffer has a fixed size");
diagnostics.record(L"disabled-context", false, 30800);
diagnostics.clear();
require(diagnostics.record(L"new-failure", true, 30900).find(L"disabled-context") == std::wstring::npos,
"clearing diagnostics discards sensitive context");
startup.dwFlags = STARTF_USESHOWWINDOW;
startup.wShowWindow = SW_HIDE;
PROCESS_INFORMATION child{};
@@ -346,6 +376,7 @@ int main(int argc, char **argv)
require(history.entries().size() == 2, "a new screenshot is retained");
}
ks::Settings settings;
settings.writeLog = false;
settings.elevatedAutostart = true;
settings.autoCorrect = true;
settings.autoMaxRank = 10000;
@@ -358,6 +389,13 @@ 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");
require(restored.snippets[0].text == settings.snippets[0].text, "settings snippet roundtrip");
require(restored.cycle == settings.cycle, "layout order roundtrip");
require(restored.elevatedAutostart, "elevated autostart setting roundtrip");
+99
View File
@@ -0,0 +1,99 @@
param([string]$Executable = (Join-Path $PSScriptRoot '../build/KSwitcher.exe'), [switch]$Network)
$ErrorActionPreference='Stop'
. (Join-Path $PSScriptRoot '../resources/update.ps1') -Library
$script:checks=0
function Assert($Value,[string]$Name) { if (!$Value) { throw $Name }; ++$script:checks }
function Reject([scriptblock]$Action,[string]$Name) { $rejected=$false; try { & $Action } catch { $rejected=$true }; Assert $rejected $Name }
$root=Join-Path ([IO.Path]::GetTempPath()) ('KSwitcher-update-tests-'+[guid]::NewGuid().ToString('N'))
[IO.Directory]::CreateDirectory($root) | Out-Null
try {
$binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path)
$binaryVersion = Get-StableVersion $binaryInfo.FileVersion
Assert ($null -ne $binaryVersion) 'executable has a stable version'
Assert ($binaryInfo.FileDescription -ceq ('KSwitcher ' + [char]0x2014 + ' native keyboard and clipboard utility')) 'executable description preserves Unicode em dash'
Assert ((Get-StableVersion 'v0.10.0') -gt (Get-StableVersion 'v0.2.9')) 'numeric versions'
Assert ($null -eq (Get-StableVersion 'v1.0.0-beta')) 'prerelease rejected'
Assert ($null -eq (Get-StableVersion 'v1.0.0;whoami')) 'invalid version rejected'
$assets=@(
@{name='KSwitcher-win-x64.zip';browser_download_url='https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/v0.2.1/KSwitcher-win-x64.zip'},
@{name='SHA256SUMS.txt';browser_download_url='https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/v0.2.1/SHA256SUMS.txt'}
)
$release=@{draft=$false;prerelease=$false;tag_name='v0.2.1';assets=$assets}
Assert ((Select-Release @($release) ([version]'0.2.0')).version -eq 'v0.2.1') 'newer release'
Assert ($null -eq (Select-Release @($release) ([version]'0.2.1'))) 'same version'
Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade'
$draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets}
Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored'
Reject { Select-Release @(@{draft=$false;prerelease=$false;tag_name='v2.0.0';assets=@()}) ([version]'0.2.0') } 'incomplete release rejected'
foreach ($url in @('http://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/v1/a','https://evil.example/a','https://gitea.hrynco.com/other/a','https://gitea.hrynco.com:8443/hrynco/hrynco-kswitcher/releases/download/v1/a')) {
Reject { Assert-ReleaseUrl $url } 'untrusted URL rejected'
}
$source=Join-Path $root 'source'; [IO.Directory]::CreateDirectory($source) | Out-Null
Copy-Item -LiteralPath $Executable -Destination (Join-Path $source 'KSwitcher.exe')
$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')
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive=Join-Path $root 'release.zip'
[IO.Compression.ZipFile]::CreateFromDirectory($source,$archive)
$sum=Join-Path $root 'SHA256SUMS.txt'
((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash+' KSwitcher-win-x64.zip') | Set-Content -LiteralPath $sum
Assert-Checksum $archive $sum; ++$script:checks
$stage=Join-Path $root 'stage'
Expand-Release $archive $stage $binaryVersion; ++$script:checks
Reject { Expand-Release $archive (Join-Path $root 'wrong-version') ([version]'9.0.0') } 'version mismatch rejected'
('0'*64+' KSwitcher-win-x64.zip') | Set-Content -LiteralPath $sum
Reject { Assert-Checksum $archive $sum } 'corrupt package rejected'
$bad=Join-Path $root 'bad.zip'
$z=[IO.Compression.ZipFile]::Open($bad,[IO.Compression.ZipArchiveMode]::Create)
$null=$z.CreateEntry('../escape.exe'); $z.Dispose()
Reject { Expand-Release $bad (Join-Path $root 'badstage') ([version]'0.2.1') } 'zip traversal rejected'
$target=Join-Path $root 'install'; $backup=Join-Path $root 'backup'
[IO.Directory]::CreateDirectory((Join-Path $target 'data')) | Out-Null
[IO.Directory]::CreateDirectory((Join-Path $target 'dictionaries')) | Out-Null
[IO.File]::WriteAllText((Join-Path $target 'data/settings.json'),'personal settings')
[IO.File]::WriteAllText((Join-Path $target 'dictionaries/en.txt'),'personal dictionary')
[IO.File]::WriteAllText((Join-Path $target 'KSwitcher.exe'),'old binary fixture')
$changed=[Collections.Generic.List[string]]::new()
Install-Files $stage $target $backup $changed
Assert ((Get-FileHash -LiteralPath (Join-Path $target 'KSwitcher.exe')).Hash -eq (Get-FileHash -LiteralPath $Executable).Hash) 'replacement installed'
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')
$locked=[IO.File]::Open((Join-Path $stage2 'KSwitcher.exe'),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None)
$changed2=[Collections.Generic.List[string]]::new()
try { Reject { Install-Files $stage2 $target $backup $changed2 } 'locked staged file fails safely' } finally { $locked.Dispose() }
Undo-Files $target $backup $changed2
Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'partial install rollback'
if ($Network) {
$metadata=Join-Path $root 'live-releases.json'
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' $metadata 2MB
$live=Select-Release (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json) ([version]'0.0.0')
Assert ($null -ne $live) 'live repository exposes a complete stable release'
$liveZip=Join-Path $root 'live.zip'; $liveSum=Join-Path $root 'live-sums.txt'
Receive-File $live.zip $liveZip 50MB
Receive-File $live.checksum $liveSum 64KB
Assert-Checksum $liveZip $liveSum; ++$script:checks
Expand-Release $liveZip (Join-Path $root 'live-stage') (Get-StableVersion $live.version); ++$script:checks
Reject { Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/kswitcher-updater-missing-test-repository' (Join-Path $root 'missing') 2MB } 'server failure reported'
Write-Output "Downloaded and validated $($live.version) in a disposable test directory; no application was updated."
}
Write-Output "Passed $script:checks updater regression checks"
} finally {
if ([IO.Path]::GetDirectoryName($root) -eq [IO.Path]::GetTempPath().TrimEnd('\') -and (Split-Path -Leaf $root) -like 'KSwitcher-update-tests-*') {
Remove-Item -LiteralPath $root -Recurse -Force
}
}
+3
View File
@@ -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')