Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5ff9495e4 | |||
| eb30701e07 | |||
| 855719863e | |||
| 1093bb5b2e | |||
| cf18a0a3c5 | |||
| 076210dc9c | |||
| 5531c49224 |
+18
-6
@@ -1,36 +1,48 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
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)
|
if(NOT WIN32)
|
||||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||||
endif()
|
endif()
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
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_include_directories(kscore PUBLIC src PRIVATE vendor)
|
||||||
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
||||||
target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||||
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/ui.cpp resources/app.rc)
|
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
|
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
|
||||||
target_link_libraries(KSwitcher PRIVATE kscore)
|
target_link_libraries(KSwitcher PRIVATE kscore)
|
||||||
|
add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
|
set_target_properties(KSwitcherConfig PROPERTIES OUTPUT_NAME "KSwitcher.Config")
|
||||||
|
target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
|
||||||
|
target_link_libraries(KSwitcherConfig PRIVATE kscore)
|
||||||
add_executable(KSwitcherTests tests/tests.cpp)
|
add_executable(KSwitcherTests tests/tests.cpp)
|
||||||
target_link_libraries(KSwitcherTests PRIVATE kscore)
|
target_link_libraries(KSwitcherTests PRIVATE kscore)
|
||||||
add_executable(KSwitcherIntegration tests/integration.cpp src/app.cpp src/ui.cpp resources/app.rc)
|
add_executable(KSwitcherIntegration tests/integration.cpp src/app.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
|
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
|
||||||
|
add_executable(KSwitcherInputTests tests/input.cpp src/app.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
|
target_link_libraries(KSwitcherInputTests PRIVATE kscore)
|
||||||
add_executable(KSwitcherOcrTests tests/ocr.cpp)
|
add_executable(KSwitcherOcrTests tests/ocr.cpp)
|
||||||
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
|
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
|
||||||
if(MSVC)
|
if(MSVC)
|
||||||
set_property(TARGET kscore KSwitcher KSwitcherTests KSwitcherIntegration KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
target_compile_options(kscore PRIVATE /W4 /utf-8)
|
target_compile_options(kscore PRIVATE /W4 /utf-8)
|
||||||
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
|
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
|
||||||
|
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
|
||||||
else()
|
else()
|
||||||
target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||||
|
target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
|
target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
endif()
|
endif()
|
||||||
enable_testing()
|
enable_testing()
|
||||||
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
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}")
|
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||||
install(TARGETS KSwitcher RUNTIME DESTINATION .)
|
install(TARGETS KSwitcher KSwitcherConfig RUNTIME DESTINATION .)
|
||||||
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
|
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
|
||||||
|
|
||||||
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
|
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# KSwitcher
|
# 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.
|
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
|
## 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 build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
|
||||||
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
|
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
|
||||||
Double-click the tray icon to open Settings, the bold default menu item.
|
Double-click the tray icon to open Settings, the bold default menu item.
|
||||||
|
Settings and the snippet editor run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
||||||
|
Repeated launches activate the existing configurator; closing its last window exits that process.
|
||||||
|
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
|
||||||
|
load clipboard history, dictionaries or global input hooks. Clipboard history and the log viewer remain
|
||||||
|
in the resident app. Keep both executables together when copying or updating the application.
|
||||||
|
If settings change in the tray while the configurator is open, a conflicting Save is rejected; reopen
|
||||||
|
the configurator to load the latest settings.
|
||||||
|
The first upgrade from v0.2.3 or earlier to this two-executable build requires manual extraction:
|
||||||
|
the older updater rejects additional executable files. Exit KSwitcher, replace both executables,
|
||||||
|
and retain `data/` and customized dictionaries. Subsequent updates support both executables.
|
||||||
In Settings, enable **Start with Windows** and **Start with Windows as administrator**
|
In Settings, enable **Start with Windows** and **Start with Windows as administrator**
|
||||||
to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing
|
to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing
|
||||||
the elevated task requests UAC approval if the app is not already elevated. No password is stored;
|
the elevated task requests UAC approval if the app is not already elevated. No password is stored;
|
||||||
@@ -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
|
Disabling the administrator option restores ordinary per-user startup; disabling startup stops
|
||||||
future automatic launches. Saving unrelated settings does not recreate startup entries.
|
future automatic launches. Saving unrelated settings does not recreate startup entries.
|
||||||
`KSwitcher.exe --settings` opens Settings, including when the app is already running.
|
`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
|
### 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
|
Choose another shortcut or exit KSwitcher to restore the original action. Windows protects
|
||||||
Ctrl+Alt+Delete and Win+L; these cannot be assigned.
|
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
|
## Implemented
|
||||||
|
|
||||||
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
|
- 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.
|
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
||||||
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
||||||
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
- 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;
|
- 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.
|
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
|
- 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`,
|
- Single instance and `--history`, `--convert`, `--case`, `--transliterate`, `--settings`,
|
||||||
`--snippets`, `--exit` commands. `--convert` cycles layouts.
|
`--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).
|
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,
|
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
|
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;
|
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
|
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,
|
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 files are
|
hotkeys, snippets, exclusions and limits. Import is staged until Save. Existing [Typlo](https://github.com/bitcodepro/Typlo) files are
|
||||||
never modified. Typlo's encrypted SQLite history is **not migrated** to the new DPAPI file format.
|
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.
|
DPAPI data belongs to the Windows account/machine; copying the portable folder is not a cross-account backup.
|
||||||
|
|
||||||
## Differences still to port
|
## Differences still to port
|
||||||
|
|
||||||
This is a working native implementation, not a claim of complete UI/release parity:
|
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.
|
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.
|
- 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.
|
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.
|
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 -Test # Pure/native regression checks; does not touch the user's clipboard
|
||||||
.\build.ps1 -Ocr # Render a synthetic image, decode it with WIC and recognize it with Windows OCR
|
.\build.ps1 -Ocr # Render a synthetic image, decode it with WIC and recognize it with Windows OCR
|
||||||
.\build.ps1 -Integration # Interactive, isolated test editor; avoid input for about 10 seconds
|
.\build.ps1 -Integration # Interactive, isolated test editor; avoid input for about 10 seconds
|
||||||
|
.\tests\configuration.ps1 # Separate-process settings tests; close KSwitcher first
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`-Test` also checks remote/own input separation, hidden Settings layout and scrolling, and updater
|
||||||
|
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,
|
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,
|
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
|
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.
|
- `platform.*`: Win32 layouts, clipboard, input, ownership, DPAPI and file primitives.
|
||||||
- `storage.*`: settings and encrypted, disk-backed history.
|
- `storage.*`: settings and encrypted, disk-backed history.
|
||||||
- `app.*`: message loop, hooks, background work and guarded text transactions.
|
- `app.*`: message loop, hooks, background work and guarded text transactions.
|
||||||
|
- `config_main.cpp`, `configuration.*`: single-instance configurator and validated settings IPC.
|
||||||
- `ui.cpp`: native windows and shortcut recording.
|
- `ui.cpp`: native windows and shortcut recording.
|
||||||
- `ocr.*`: Windows WIC and OCR via the native Windows Runtime ABI.
|
- `ocr.*`: Windows WIC and OCR via the native Windows Runtime ABI.
|
||||||
- `tests/`: regression checks, real editor integration and OCR fixture.
|
- `tests/`: regression checks, real editor integration and OCR fixture.
|
||||||
|
|
||||||
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.
|
Dictionary data has its own CC BY-SA 4.0 license as described above.
|
||||||
|
|||||||
Binary file not shown.
+21
-10
@@ -1,21 +1,32 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
Download **KSwitcher-win-x64.zip**, extract the entire archive to a writable folder,
|
## v0.3.0 — 2026-10-28
|
||||||
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.
|
|
||||||
|
|
||||||
For upgrades, exit KSwitcher first. Back up any edited dictionaries before replacing files.
|
- Move Settings and the snippet editor into the on-demand `KSwitcher.Config.exe` process.
|
||||||
Keep your existing `data/` directory to retain settings and clipboard history.
|
- Repeated launches activate the existing configurator. Closing its last window releases the process and its memory.
|
||||||
`SHA256SUMS.txt` contains the checksum of the archive.
|
- 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
|
```powershell
|
||||||
.\build.ps1 -Stage -Test
|
.\build.ps1 -Stage -Test
|
||||||
.\package.ps1 -PublishRelease
|
.\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 @@
|
|||||||
906de70d92045cc6ff50802841cc357c6b47536fa0cbbb63301d779b33179469 KSwitcher-win-x64.zip
|
104c79b64bb30a2161e3ec2f5108a731da0d274e015d53d8835ea7e64f431ee1 KSwitcher-win-x64.zip
|
||||||
|
|||||||
@@ -14,24 +14,38 @@ Push-Location $root
|
|||||||
try {
|
try {
|
||||||
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
||||||
$libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
$libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','app','ui','main')) {
|
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','app','ui','main','configuration','config_main')) {
|
||||||
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
||||||
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
||||||
}
|
}
|
||||||
& 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' }
|
if ($LASTEXITCODE) { throw 'Resource compilation failed' }
|
||||||
New-Item -ItemType Directory -Force build/release | Out-Null
|
New-Item -ItemType Directory -Force build/release | Out-Null
|
||||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/app.o build/ui.o build/main.o build/app-res.o @libs -o $executablePath
|
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
|
||||||
if ($LASTEXITCODE) { throw 'Link failed' }
|
if ($LASTEXITCODE) { throw 'Link failed' }
|
||||||
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
||||||
|
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
|
||||||
|
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
|
||||||
|
if ($LASTEXITCODE) { throw 'Configurator link failed' }
|
||||||
|
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
|
||||||
if ($Test) {
|
if ($Test) {
|
||||||
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o @libs -static -s -o build/KSwitcherTests.exe
|
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Test build failed' }
|
if ($LASTEXITCODE) { throw 'Test build failed' }
|
||||||
& .\build\KSwitcherTests.exe
|
& .\build\KSwitcherTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Tests failed' }
|
if ($LASTEXITCODE) { throw 'Tests failed' }
|
||||||
|
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/app.o build/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) {
|
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' }
|
if ($LASTEXITCODE) { throw 'Integration test build failed' }
|
||||||
& .\build\KSwitcherIntegration.exe
|
& .\build\KSwitcherIntegration.exe
|
||||||
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
||||||
|
|||||||
+2
-1
@@ -4,12 +4,13 @@ $root = $PSScriptRoot
|
|||||||
$destination = Join-Path $root 'dist\KSwitcher'
|
$destination = Join-Path $root 'dist\KSwitcher'
|
||||||
New-Item -ItemType Directory -Force $destination | Out-Null
|
New-Item -ItemType Directory -Force $destination | Out-Null
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination
|
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination
|
||||||
|
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
|
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
|
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
|
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
|
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
|
||||||
# Enumerate the distributable files explicitly; do not include a user's data directory.
|
# Enumerate the distributable files explicitly; do not include a user's data directory.
|
||||||
$files = @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
|
$files = @('KSwitcher.exe','KSwitcher.Config.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
|
||||||
$archive = Join-Path $root 'dist\KSwitcher-win-x64.zip'
|
$archive = Join-Path $root 'dist\KSwitcher-win-x64.zip'
|
||||||
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
|
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
|
||||||
Get-FileHash -LiteralPath $archive -Algorithm SHA256
|
Get-FileHash -LiteralPath $archive -Algorithm SHA256
|
||||||
|
|||||||
+5
-3
@@ -1,10 +1,12 @@
|
|||||||
|
#pragma code_page(65001)
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
#include "resource.h"
|
#include "resource.h"
|
||||||
IDI_KSWITCHER ICON "kswitcher.ico"
|
IDI_KSWITCHER ICON "kswitcher.ico"
|
||||||
1 RT_MANIFEST "app.manifest"
|
1 RT_MANIFEST "app.manifest"
|
||||||
|
201 RCDATA "update.ps1"
|
||||||
1 VERSIONINFO
|
1 VERSIONINFO
|
||||||
FILEVERSION 0,1,0,0
|
FILEVERSION 0,3,0,0
|
||||||
PRODUCTVERSION 0,1,0,0
|
PRODUCTVERSION 0,3,0,0
|
||||||
FILEOS VOS_NT_WINDOWS32
|
FILEOS VOS_NT_WINDOWS32
|
||||||
FILETYPE VFT_APP
|
FILETYPE VFT_APP
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -14,7 +16,7 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||||
VALUE "ProductName", "KSwitcher"
|
VALUE "ProductName", "KSwitcher"
|
||||||
VALUE "FileVersion", "0.1.0"
|
VALUE "FileVersion", "0.3.0"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+304
-72
@@ -1,6 +1,7 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
#include "../resources/resource.h"
|
#include "../resources/resource.h"
|
||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
|
#include "configuration.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
#include <set>
|
#include <set>
|
||||||
@@ -125,7 +126,10 @@ class Transaction
|
|||||||
write(textClip(L"\x2063KSwitcher-" + std::to_wstring(GetTickCount64())));
|
write(textClip(L"\x2063KSwitcher-" + std::to_wstring(GetTickCount64())));
|
||||||
DWORD before = expected;
|
DWORD before = expected;
|
||||||
if (!valid() || !chord(L'C', MOD_CONTROL))
|
if (!valid() || !chord(L'C', MOD_CONTROL))
|
||||||
|
{
|
||||||
|
diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked");
|
||||||
return {};
|
return {};
|
||||||
|
}
|
||||||
ULONGLONG deadline = GetTickCount64() + 2000;
|
ULONGLONG deadline = GetTickCount64() + 2000;
|
||||||
DWORD changed = before;
|
DWORD changed = before;
|
||||||
ULONGLONG stable = 0;
|
ULONGLONG stable = 0;
|
||||||
@@ -146,7 +150,11 @@ class Transaction
|
|||||||
Sleep(10);
|
Sleep(10);
|
||||||
}
|
}
|
||||||
if (changed == before || !valid())
|
if (changed == before || !valid())
|
||||||
|
{
|
||||||
|
diagnosticEvent(changed == before ? L"Copy failed: clipboard timeout (2000 ms)" :
|
||||||
|
L"Copy failed: context changed");
|
||||||
return {};
|
return {};
|
||||||
|
}
|
||||||
if ((GetClipboardOwner() ? !ownsClipboard(target) : changedAgain) ||
|
if ((GetClipboardOwner() ? !ownsClipboard(target) : changedAgain) ||
|
||||||
changed != GetClipboardSequenceNumber())
|
changed != GetClipboardSequenceNumber())
|
||||||
throw std::runtime_error("Operation cancelled: copy did not belong to the target application");
|
throw std::runtime_error("Operation cancelled: copy did not belong to the target application");
|
||||||
@@ -159,15 +167,21 @@ class Transaction
|
|||||||
{
|
{
|
||||||
if (f.name == L"VisualStudioEditorOperationsLineCutCopyClipboardTag" ||
|
if (f.name == L"VisualStudioEditorOperationsLineCutCopyClipboardTag" ||
|
||||||
f.name == L"MSDEVLineSelect")
|
f.name == L"MSDEVLineSelect")
|
||||||
|
{
|
||||||
|
diagnosticEvent(L"Copy rejected: editor copied line without selection");
|
||||||
return {};
|
return {};
|
||||||
|
}
|
||||||
if (f.name == L"vscode-editor-data")
|
if (f.name == L"vscode-editor-data")
|
||||||
{
|
{
|
||||||
std::string json(f.data.begin(), f.data.end());
|
std::string json(f.data.begin(), f.data.end());
|
||||||
if (json.find("\"isFromEmptySelection\":true") != json.npos ||
|
if (json.find("\"isFromEmptySelection\":true") != json.npos ||
|
||||||
json.find("\"isFromEmptySelection\": true") != json.npos)
|
json.find("\"isFromEmptySelection\": true") != json.npos)
|
||||||
|
{
|
||||||
|
diagnosticEvent(L"Copy rejected: editor reported empty selection");
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
bool select(size_t n)
|
bool select(size_t n)
|
||||||
@@ -188,7 +202,7 @@ class Transaction
|
|||||||
auto text = copy();
|
auto text = copy();
|
||||||
if (valid() && matches(text))
|
if (valid() && matches(text))
|
||||||
{
|
{
|
||||||
log(L"Selection: verified word boundary");
|
diagnosticEvent(L"Selection: verified word boundary");
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
if (!valid() || !chord(VK_RIGHT))
|
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);
|
settings = loadSettings(data / L"missing-defaults.json", layouts);
|
||||||
registrationWarning = L"Settings were not loaded: " + wide(e.what());
|
registrationWarning = L"Settings were not loaded: " + wide(e.what());
|
||||||
}
|
}
|
||||||
|
setLoggingEnabled(settings.writeLog);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
history = std::make_unique<History>(data / L"history");
|
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()
|
App::~App()
|
||||||
{
|
{
|
||||||
|
if (updateWorker.joinable())
|
||||||
|
updateWorker.join();
|
||||||
if (keyboardHook)
|
if (keyboardHook)
|
||||||
UnhookWindowsHookEx(keyboardHook);
|
UnhookWindowsHookEx(keyboardHook);
|
||||||
if (mouseHook)
|
if (mouseHook)
|
||||||
@@ -349,6 +366,7 @@ void App::applySettings(const Settings &s)
|
|||||||
{
|
{
|
||||||
std::lock_guard lock(stateMutex);
|
std::lock_guard lock(stateMutex);
|
||||||
settings = s;
|
settings = s;
|
||||||
|
setLoggingEnabled(s.writeLog);
|
||||||
run.reset();
|
run.reset();
|
||||||
last.reset();
|
last.reset();
|
||||||
++inputVersion;
|
++inputVersion;
|
||||||
@@ -381,6 +399,18 @@ void App::reloadLayouts()
|
|||||||
}
|
}
|
||||||
registerHotkeys(settings);
|
registerHotkeys(settings);
|
||||||
}
|
}
|
||||||
|
void App::clearHistory()
|
||||||
|
{
|
||||||
|
if (busy) throw std::runtime_error("Wait for the current operation to finish.");
|
||||||
|
if (history) history->clear();
|
||||||
|
if (historyWindow) refreshHistory(*this);
|
||||||
|
}
|
||||||
|
void App::setHotkeyCapture(HWND field)
|
||||||
|
{
|
||||||
|
hotkeyCapture = field;
|
||||||
|
hotkeyCaptureProcess = 0;
|
||||||
|
if (field) GetWindowThreadProcessId(field, &hotkeyCaptureProcess);
|
||||||
|
}
|
||||||
bool App::isHotkey(DWORD key, UINT mods)
|
bool App::isHotkey(DWORD key, UINT mods)
|
||||||
{
|
{
|
||||||
for (auto &[id, text] : shortcuts(settings))
|
for (auto &[id, text] : shortcuts(settings))
|
||||||
@@ -398,23 +428,15 @@ void App::resetInput()
|
|||||||
run.reset();
|
run.reset();
|
||||||
last.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;
|
return;
|
||||||
DWORD vk = k.vkCode;
|
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)
|
vk == VK_RMENU || vk == VK_LWIN || vk == VK_RWIN)
|
||||||
return;
|
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);
|
std::lock_guard lock(stateMutex);
|
||||||
if (isHotkey(vk, mods))
|
if (isHotkey(vk, mods))
|
||||||
return;
|
return;
|
||||||
@@ -433,7 +455,11 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bool shift = (mods & MOD_SHIFT) != 0;
|
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))
|
if (ch && letter(ch) && (GetKeyState(VK_CAPITAL) & 1))
|
||||||
ch = shift ? lower(std::wstring(1, ch))[0] : upper(std::wstring(1, ch))[0];
|
ch = shift ? lower(std::wstring(1, ch))[0] : upper(std::wstring(1, ch))[0];
|
||||||
if (!ch)
|
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) &&
|
// Filter before capture, hotkey latching and typing history. External injected
|
||||||
((KBDLLHOOKSTRUCT *)lp)->vkCode == 0xE8)
|
// down AND up events use exactly the same path as physical keyboard input.
|
||||||
return CallNextHookEx(nullptr, code, wp, lp); // Our shell-mask key is never recorded.
|
if (ownKeyboardInput(key) || installingUpdate)
|
||||||
if (code == HC_ACTION && current && current->hotkeyCapture &&
|
return false;
|
||||||
GetForegroundWindow() == GetAncestor(current->hotkeyCapture, GA_ROOT))
|
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_CONTROL || key.vkCode == VK_SHIFT || key.vkCode == VK_MENU ||
|
||||||
bool modifier = key.vkCode == VK_LCONTROL || key.vkCode == VK_RCONTROL || key.vkCode == VK_LSHIFT ||
|
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_RSHIFT || key.vkCode == VK_LMENU || key.vkCode == VK_RMENU ||
|
||||||
key.vkCode == VK_LWIN || key.vkCode == VK_RWIN;
|
key.vkCode == VK_LWIN || key.vkCode == VK_RWIN;
|
||||||
if (!modifier)
|
if (!modifier)
|
||||||
{
|
{
|
||||||
if (wp == WM_KEYUP || wp == WM_SYSKEYUP)
|
if (!down)
|
||||||
current->hotkeyHook.update(key.vkCode, false, 0, {});
|
hotkeyHook.update(key.vkCode, false, 0, {});
|
||||||
if (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN)
|
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)
|
if (mods & MOD_WIN)
|
||||||
chord(0xE8);
|
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.
|
return 1; // Record the chord without executing a global shortcut or typing into an editor.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (code == HC_ACTION && (((KBDLLHOOKSTRUCT *)lp)->flags & LLKHF_INJECTED))
|
if (down)
|
||||||
return CallNextHookEx(nullptr, code, wp, lp);
|
|
||||||
if (code == HC_ACTION && (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN) && current)
|
|
||||||
{
|
{
|
||||||
auto &key = *(KBDLLHOOKSTRUCT *)lp;
|
auto action = hotkeyHook.update(key.vkCode, true, mods, hookBindings);
|
||||||
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);
|
|
||||||
if (action > 0)
|
if (action > 0)
|
||||||
{
|
{
|
||||||
// Tell the shell that Win participated in a chord, preventing Start
|
// Tell the shell that Win participated in a chord, preventing Start
|
||||||
// from opening on Win-up after we consumed V. 0xE8 is unassigned.
|
// from opening on Win-up after we consumed V. 0xE8 is unassigned.
|
||||||
if (mods & (MOD_WIN | MOD_ALT))
|
if (mods & (MOD_WIN | MOD_ALT))
|
||||||
chord(0xE8);
|
chord(0xE8);
|
||||||
PostMessageW(current->window, WM_HOTKEY, action, 0);
|
PostMessageW(window, WM_HOTKEY, action, 0);
|
||||||
}
|
}
|
||||||
if (action != 0)
|
if (action != 0)
|
||||||
return 1;
|
return 1;
|
||||||
current->keyboard(*(KBDLLHOOKSTRUCT *)lp);
|
keyboard(key, mods);
|
||||||
}
|
}
|
||||||
if (code == HC_ACTION && (wp == WM_KEYUP || wp == WM_SYSKEYUP) && current &&
|
else if (hotkeyHook.update(key.vkCode, false, 0, hookBindings) != 0)
|
||||||
current->hotkeyHook.update(((KBDLLHOOKSTRUCT *)lp)->vkCode, false, 0, current->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;
|
return 1;
|
||||||
|
}
|
||||||
return CallNextHookEx(nullptr, code, wp, lp);
|
return CallNextHookEx(nullptr, code, wp, lp);
|
||||||
}
|
}
|
||||||
LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
|
LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
|
||||||
{
|
{
|
||||||
if (code == HC_ACTION && current)
|
if (code == HC_ACTION && current)
|
||||||
{
|
{
|
||||||
auto *e = (MSLLHOOKSTRUCT *)lp;
|
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
|
||||||
if (!(e->flags & LLMHF_INJECTED) &&
|
// range just like local clicks, including while a transaction is pending.
|
||||||
(wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL))
|
if (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL)
|
||||||
current->resetInput();
|
current->resetInput();
|
||||||
}
|
}
|
||||||
return CallNextHookEx(nullptr, code, wp, lp);
|
return CallNextHookEx(nullptr, code, wp, lp);
|
||||||
}
|
}
|
||||||
void App::dispatch(int command)
|
void App::dispatch(int command)
|
||||||
{
|
{
|
||||||
|
if (installingUpdate)
|
||||||
|
return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (command == ShowHistory)
|
if (command == ShowHistory)
|
||||||
@@ -568,6 +596,11 @@ void App::dispatch(int command)
|
|||||||
showSettings(*this);
|
showSettings(*this);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (command == 22)
|
||||||
|
{
|
||||||
|
showLogs(*this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (command == 21)
|
if (command == 21)
|
||||||
{
|
{
|
||||||
showSnippets(*this);
|
showSnippets(*this);
|
||||||
@@ -587,9 +620,34 @@ void App::dispatch(int command)
|
|||||||
}
|
}
|
||||||
void App::transform(int command, int snippet)
|
void App::transform(int command, int snippet)
|
||||||
{
|
{
|
||||||
if (busy.exchange(true))
|
if (installingUpdate) return;
|
||||||
return;
|
const auto attempt = ++transformAttempt;
|
||||||
|
const auto requestedAt = GetTickCount64();
|
||||||
HWND target = GetForegroundWindow();
|
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;
|
Run recent;
|
||||||
std::optional<LastTransform> previous;
|
std::optional<LastTransform> previous;
|
||||||
Settings config;
|
Settings config;
|
||||||
@@ -604,7 +662,7 @@ void App::transform(int command, int snippet)
|
|||||||
version = inputVersion;
|
version = inputVersion;
|
||||||
}
|
}
|
||||||
enqueue(
|
enqueue(
|
||||||
[this, target, recent, previous, config, available, version, command, snippet]
|
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt]
|
||||||
{
|
{
|
||||||
struct BusyReset
|
struct BusyReset
|
||||||
{
|
{
|
||||||
@@ -615,6 +673,47 @@ void App::transform(int command, int snippet)
|
|||||||
}
|
}
|
||||||
} reset{busy};
|
} reset{busy};
|
||||||
auto start = GetTickCount64();
|
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
|
try
|
||||||
{
|
{
|
||||||
auto valid = [&]
|
auto valid = [&]
|
||||||
@@ -627,16 +726,16 @@ void App::transform(int command, int snippet)
|
|||||||
releaseModifiers();
|
releaseModifiers();
|
||||||
if (!valid())
|
if (!valid())
|
||||||
return;
|
return;
|
||||||
int hint = -1;
|
|
||||||
for (int i = 0; i < (int)available.size(); ++i)
|
for (int i = 0; i < (int)available.size(); ++i)
|
||||||
if (available[i].handle == windowLayout(target))
|
if (available[i].handle == windowLayout(target))
|
||||||
hint = i;
|
hint = i;
|
||||||
int to = command >= Direct ? command - Direct : -1;
|
reason = L"invalid-target-layout";
|
||||||
if (to >= (int)available.size())
|
if (to >= (int)available.size())
|
||||||
return;
|
return;
|
||||||
if (to >= 0 && !available[to].convertible)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (command == AutoCorrect)
|
if (command == AutoCorrect)
|
||||||
@@ -654,12 +753,16 @@ void App::transform(int command, int snippet)
|
|||||||
!autoCorrectionAllowed(target, config) || !valid())
|
!autoCorrectionAllowed(target, config) || !valid())
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
reason = L"clipboard-snapshot";
|
||||||
Transaction tx(*this, target, version);
|
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=" +
|
L" recent=" + std::to_wstring(config.recent) + L" keys=" +
|
||||||
std::to_wstring(recent.keys.size()) + L" sameWindow=" +
|
std::to_wstring(recent.keys.size()) + L" sameWindow=" +
|
||||||
std::to_wstring(recent.window == target));
|
std::to_wstring(recent.window == target) + L" overflow=" +
|
||||||
std::wstring text, result;
|
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;
|
bool automatic = false;
|
||||||
if (command == AutoCorrect)
|
if (command == AutoCorrect)
|
||||||
{
|
{
|
||||||
@@ -692,6 +795,9 @@ void App::transform(int command, int snippet)
|
|||||||
}
|
}
|
||||||
else if (command == Undo)
|
else if (command == Undo)
|
||||||
{
|
{
|
||||||
|
if (previous)
|
||||||
|
expected = previous->result;
|
||||||
|
reason = L"undo-unavailable";
|
||||||
if (!previous || previous->window != target || previous->version != version ||
|
if (!previous || previous->window != target || previous->version != version ||
|
||||||
GetTickCount64() - previous->at > 120000)
|
GetTickCount64() - previous->at > 120000)
|
||||||
return;
|
return;
|
||||||
@@ -700,6 +806,7 @@ void App::transform(int command, int snippet)
|
|||||||
{ return newlines(candidate) == newlines(previous->result); });
|
{ return newlines(candidate) == newlines(previous->result); });
|
||||||
if (newlines(text) != newlines(previous->result))
|
if (newlines(text) != newlines(previous->result))
|
||||||
{
|
{
|
||||||
|
reason = L"undo-verification-mismatch";
|
||||||
if (tx.valid())
|
if (tx.valid())
|
||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
return;
|
return;
|
||||||
@@ -738,10 +845,11 @@ void App::transform(int command, int snippet)
|
|||||||
tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate)
|
tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate)
|
||||||
{ return recent.match(candidate, available, physicalLayout()) >= 0; });
|
{ return recent.match(candidate, available, physicalLayout()) >= 0; });
|
||||||
hint = recent.match(text, available, physicalLayout());
|
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));
|
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
|
||||||
if (hint < 0)
|
if (hint < 0)
|
||||||
{
|
{
|
||||||
|
reason = text.empty() ? L"recent-copy-empty" : L"recent-verification-mismatch";
|
||||||
if (tx.valid())
|
if (tx.valid())
|
||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
return;
|
return;
|
||||||
@@ -751,11 +859,13 @@ void App::transform(int command, int snippet)
|
|||||||
else if (config.recent && previous && previous->window == target &&
|
else if (config.recent && previous && previous->window == target &&
|
||||||
previous->version == version)
|
previous->version == version)
|
||||||
{
|
{
|
||||||
|
expected = previous->result;
|
||||||
text = tx.copyRange(caretSteps(previous->result), previous->result,
|
text = tx.copyRange(caretSteps(previous->result), previous->result,
|
||||||
[&](const std::wstring &candidate)
|
[&](const std::wstring &candidate)
|
||||||
{ return newlines(candidate) == newlines(previous->result); });
|
{ return newlines(candidate) == newlines(previous->result); });
|
||||||
if (newlines(text) != newlines(previous->result))
|
if (newlines(text) != newlines(previous->result))
|
||||||
{
|
{
|
||||||
|
reason = L"previous-result-verification-mismatch";
|
||||||
if (tx.valid())
|
if (tx.valid())
|
||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
return;
|
return;
|
||||||
@@ -765,10 +875,12 @@ void App::transform(int command, int snippet)
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
text = tx.copy();
|
text = tx.copy();
|
||||||
|
reason = L"context-changed-after-copy";
|
||||||
if (!tx.valid())
|
if (!tx.valid())
|
||||||
return;
|
return;
|
||||||
if (text.empty())
|
if (text.empty())
|
||||||
{
|
{
|
||||||
|
reason = L"copy-empty";
|
||||||
if (to >= 0 && config.switchLayout)
|
if (to >= 0 && config.switchLayout)
|
||||||
switchWindowLayout(target, available[to].handle, valid);
|
switchWindowLayout(target, available[to].handle, valid);
|
||||||
return;
|
return;
|
||||||
@@ -790,6 +902,7 @@ void App::transform(int command, int snippet)
|
|||||||
cycle.push_back(i);
|
cycle.push_back(i);
|
||||||
if (cycle.size() < 2)
|
if (cycle.size() < 2)
|
||||||
{
|
{
|
||||||
|
reason = L"cycle-has-fewer-than-two-layouts";
|
||||||
if (automatic && tx.valid())
|
if (automatic && tx.valid())
|
||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
return;
|
return;
|
||||||
@@ -804,12 +917,20 @@ void App::transform(int command, int snippet)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
bool sent = false;
|
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));
|
L" changed=" + std::to_wstring(result != text));
|
||||||
if (result != text)
|
if (result != text)
|
||||||
sent = tx.paste(textClip(result));
|
sent = tx.paste(textClip(result));
|
||||||
else if (automatic && tx.valid())
|
else if (automatic && tx.valid())
|
||||||
chord(VK_RIGHT);
|
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)
|
if (sent && command == Undo && previous && previous->autoCorrected)
|
||||||
autoCorrector.reject(previous->original, config);
|
autoCorrector.reject(previous->original, config);
|
||||||
if (tx.valid() && (sent || automatic))
|
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))
|
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
|
||||||
{
|
{
|
||||||
bool switched = switchWindowLayout(target, available[to].handle, valid);
|
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)
|
catch (const std::exception &e)
|
||||||
{
|
{
|
||||||
|
reason = L"exception:" + wide(e.what());
|
||||||
report(wide(e.what()));
|
report(wide(e.what()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
void App::pasteEntry(std::wstring id, bool plain)
|
void App::pasteEntry(std::wstring id, bool plain)
|
||||||
{
|
{
|
||||||
|
if (installingUpdate) return;
|
||||||
if (!history || busy.exchange(true))
|
if (!history || busy.exchange(true))
|
||||||
return;
|
return;
|
||||||
HWND target = pasteTarget;
|
HWND target = pasteTarget;
|
||||||
@@ -984,6 +1109,10 @@ void App::trayMenu()
|
|||||||
AppendMenuW(pause, MF_STRING, 23, L"Until restart");
|
AppendMenuW(pause, MF_STRING, 23, L"Until restart");
|
||||||
AppendMenuW(menu, MF_POPUP, (UINT_PTR)pause, L"Pause clipboard history");
|
AppendMenuW(menu, MF_POPUP, (UINT_PTR)pause, L"Pause clipboard history");
|
||||||
AppendMenuW(menu, MF_STRING, 15, L"Open data folder");
|
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_SEPARATOR, 0, nullptr);
|
||||||
AppendMenuW(menu, MF_STRING, 16, L"About KSwitcher");
|
AppendMenuW(menu, MF_STRING, 16, L"About KSwitcher");
|
||||||
AppendMenuW(menu, MF_STRING, 17, L"Exit");
|
AppendMenuW(menu, MF_STRING, 17, L"Exit");
|
||||||
@@ -1022,14 +1151,20 @@ void App::trayMenu()
|
|||||||
break;
|
break;
|
||||||
case 16:
|
case 16:
|
||||||
MessageBoxW(window,
|
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"Typlo by bitcodepro. MIT license.\nHistory is encrypted for your Windows "
|
||||||
L"account.\nNo .NET, WebView or graphics framework.",
|
L"account.\nNo .NET, WebView or graphics framework.").c_str(),
|
||||||
L"About KSwitcher", MB_OK);
|
L"About KSwitcher", MB_OK);
|
||||||
break;
|
break;
|
||||||
case 17:
|
case 17:
|
||||||
PostMessageW(window, WM_CLOSE, 0, 0);
|
PostMessageW(window, WM_CLOSE, 0, 0);
|
||||||
break;
|
break;
|
||||||
|
case 18:
|
||||||
|
showLogs(*this);
|
||||||
|
break;
|
||||||
|
case 19:
|
||||||
|
checkUpdate(true);
|
||||||
|
break;
|
||||||
case 20:
|
case 20:
|
||||||
pauseUntil = 0;
|
pauseUntil = 0;
|
||||||
break;
|
break;
|
||||||
@@ -1049,6 +1184,32 @@ void App::trayMenu()
|
|||||||
report(wide(e.what()));
|
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)
|
LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
{
|
{
|
||||||
auto *app = current;
|
auto *app = current;
|
||||||
@@ -1061,6 +1222,50 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
switch (msg)
|
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:
|
case WM_HOTKEY:
|
||||||
app->dispatch((int)wp);
|
app->dispatch((int)wp);
|
||||||
return 0;
|
return 0;
|
||||||
@@ -1078,10 +1283,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
app->captureHistory();
|
app->captureHistory();
|
||||||
return 0;
|
return 0;
|
||||||
case AutoCorrectMessage:
|
case AutoCorrectMessage:
|
||||||
|
if (app->installingUpdate) return 0;
|
||||||
app->pendingAutoVersion = (uint64_t)wp;
|
app->pendingAutoVersion = (uint64_t)wp;
|
||||||
SetTimer(hwnd, 3, 250, nullptr);
|
SetTimer(hwnd, 3, 250, nullptr);
|
||||||
return 0;
|
return 0;
|
||||||
case SnippetMessage:
|
case SnippetMessage:
|
||||||
|
if (app->installingUpdate) return 0;
|
||||||
app->transform(0, (int)wp);
|
app->transform(0, (int)wp);
|
||||||
return 0;
|
return 0;
|
||||||
case HistoryMessage:
|
case HistoryMessage:
|
||||||
@@ -1104,6 +1311,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case WM_COPYDATA:
|
case WM_COPYDATA:
|
||||||
{
|
{
|
||||||
auto *data = (COPYDATASTRUCT *)lp;
|
auto *data = (COPYDATASTRUCT *)lp;
|
||||||
|
if (data && data->dwData >= ConfigRead && data->dwData < ConfigReply)
|
||||||
|
{
|
||||||
|
if (app->installingUpdate) return FALSE;
|
||||||
|
return configurationRequest(*app, (HWND)wp, *data);
|
||||||
|
}
|
||||||
if (data && data->dwData == 1 && data->cbData == sizeof(int))
|
if (data && data->dwData == 1 && data->cbData == sizeof(int))
|
||||||
{
|
{
|
||||||
int cmd;
|
int cmd;
|
||||||
@@ -1113,6 +1325,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
case WM_TIMER:
|
case WM_TIMER:
|
||||||
|
if (wp == 5)
|
||||||
|
{
|
||||||
|
KillTimer(hwnd, 5);
|
||||||
|
app->checkUpdate(false);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (wp == 4)
|
if (wp == 4)
|
||||||
{
|
{
|
||||||
if (GetAsyncKeyState(VK_LBUTTON) & 0x8000)
|
if (GetAsyncKeyState(VK_LBUTTON) & 0x8000)
|
||||||
@@ -1124,7 +1342,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (wp == 3)
|
if (wp == 3)
|
||||||
{
|
{
|
||||||
KillTimer(hwnd, 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);
|
app->transform(AutoCorrect);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -1141,6 +1359,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
case WM_CLOSE:
|
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)
|
if (app->busy)
|
||||||
{
|
{
|
||||||
app->report(L"Wait for the current text operation to finish before exiting.");
|
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);
|
DestroyWindow(hwnd);
|
||||||
return 0;
|
return 0;
|
||||||
case WM_DESTROY:
|
case WM_DESTROY:
|
||||||
|
if (auto config = FindWindowW(L"KSwitcher.Config", nullptr);
|
||||||
|
config && configurationPeer(config, L"KSwitcher.Config.exe"))
|
||||||
|
PostMessageW(config, WM_CLOSE, 0, 0);
|
||||||
PostQuitMessage(0);
|
PostQuitMessage(0);
|
||||||
return 0;
|
return 0;
|
||||||
default:
|
default:
|
||||||
@@ -1210,6 +1436,10 @@ int App::execute()
|
|||||||
report(registrationWarning);
|
report(registrationWarning);
|
||||||
if (smoke)
|
if (smoke)
|
||||||
SetTimer(window, 2, 10000, nullptr);
|
SetTimer(window, 2, 10000, nullptr);
|
||||||
|
else
|
||||||
|
SetTimer(window, 5, 5000, nullptr);
|
||||||
|
if (keyboardHook && mouseHook)
|
||||||
|
confirmUpdatedStartup(updateStartupEvent);
|
||||||
if (startCommand)
|
if (startCommand)
|
||||||
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
||||||
MSG msg;
|
MSG msg;
|
||||||
@@ -1225,6 +1455,8 @@ int App::execute()
|
|||||||
continue;
|
continue;
|
||||||
if (snippetsWindow && IsDialogMessageW(snippetsWindow, &msg))
|
if (snippetsWindow && IsDialogMessageW(snippetsWindow, &msg))
|
||||||
continue;
|
continue;
|
||||||
|
if (logWindow && IsDialogMessageW(logWindow, &msg))
|
||||||
|
continue;
|
||||||
TranslateMessage(&msg);
|
TranslateMessage(&msg);
|
||||||
DispatchMessageW(&msg);
|
DispatchMessageW(&msg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "autocorrect.h"
|
#include "autocorrect.h"
|
||||||
#include "storage.h"
|
#include "storage.h"
|
||||||
|
#include "update.h"
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <condition_variable>
|
#include <condition_variable>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
@@ -42,12 +43,15 @@ class App
|
|||||||
std::atomic<ULONGLONG> pauseUntil{0};
|
std::atomic<ULONGLONG> pauseUntil{0};
|
||||||
HWND pasteTarget{};
|
HWND pasteTarget{};
|
||||||
HWND hotkeyCapture{};
|
HWND hotkeyCapture{};
|
||||||
|
DWORD hotkeyCaptureProcess{};
|
||||||
|
HWND logWindow{};
|
||||||
std::mutex stateMutex;
|
std::mutex stateMutex;
|
||||||
Run run;
|
Run run;
|
||||||
std::optional<LastTransform> last;
|
std::optional<LastTransform> last;
|
||||||
std::wstring registrationWarning;
|
std::wstring registrationWarning;
|
||||||
bool smoke{};
|
bool smoke{};
|
||||||
int startCommand{};
|
int startCommand{};
|
||||||
|
std::wstring updateStartupEvent;
|
||||||
App(HINSTANCE, bool = false, const std::filesystem::path &dataRoot = {});
|
App(HINSTANCE, bool = false, const std::filesystem::path &dataRoot = {});
|
||||||
~App();
|
~App();
|
||||||
int execute();
|
int execute();
|
||||||
@@ -63,20 +67,32 @@ class App
|
|||||||
bool registerHotkeys(const Settings &, bool rollback = false);
|
bool registerHotkeys(const Settings &, bool rollback = false);
|
||||||
void applySettings(const Settings &);
|
void applySettings(const Settings &);
|
||||||
void reloadLayouts();
|
void reloadLayouts();
|
||||||
|
void clearHistory();
|
||||||
|
void setHotkeyCapture(HWND);
|
||||||
bool isHotkey(DWORD, UINT);
|
bool isHotkey(DWORD, UINT);
|
||||||
void keyboard(const KBDLLHOOKSTRUCT &);
|
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
||||||
|
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
|
||||||
void resetInput();
|
void resetInput();
|
||||||
|
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
|
||||||
static App *current;
|
static App *current;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
friend struct InputRegressionAccess;
|
||||||
HHOOK keyboardHook{}, mouseHook{};
|
HHOOK keyboardHook{}, mouseHook{};
|
||||||
std::mutex jobsMutex;
|
std::mutex jobsMutex;
|
||||||
std::condition_variable jobsReady;
|
std::condition_variable jobsReady;
|
||||||
std::deque<std::function<void()>> jobs;
|
std::deque<std::function<void()>> jobs;
|
||||||
std::thread worker;
|
std::thread worker;
|
||||||
|
std::thread updateWorker;
|
||||||
|
bool updateRunning{}, installingUpdate{};
|
||||||
|
std::wstring availableUpdate;
|
||||||
bool stop{};
|
bool stop{};
|
||||||
UINT taskbarCreated{};
|
UINT taskbarCreated{};
|
||||||
uint64_t pendingAutoVersion{};
|
uint64_t pendingAutoVersion{};
|
||||||
|
uint64_t transformAttempt{}, previousAttemptVersion{};
|
||||||
|
ULONGLONG previousAttemptAt{};
|
||||||
|
HWND previousAttemptWindow{};
|
||||||
|
int previousAttemptCommand{}, repeatAttempts{};
|
||||||
std::vector<std::pair<int, Hotkey>> hookBindings;
|
std::vector<std::pair<int, Hotkey>> hookBindings;
|
||||||
HotkeyHook hotkeyHook;
|
HotkeyHook hotkeyHook;
|
||||||
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
||||||
@@ -85,7 +101,8 @@ class App
|
|||||||
};
|
};
|
||||||
void showHistory(App &);
|
void showHistory(App &);
|
||||||
void refreshHistory(App &);
|
void refreshHistory(App &);
|
||||||
void showSettings(App &);
|
void showSettings(App &, bool visible = true);
|
||||||
|
void showLogs(App &);
|
||||||
void showSnippets(App &);
|
void showSnippets(App &);
|
||||||
void showShortcuts(App &);
|
void showShortcuts(App &);
|
||||||
void showEntry(App &, const std::wstring &);
|
void showEntry(App &, const std::wstring &);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 &);
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ struct Settings
|
|||||||
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
||||||
bool elevatedAutostart = false;
|
bool elevatedAutostart = false;
|
||||||
bool autoCorrect = false;
|
bool autoCorrect = false;
|
||||||
|
bool writeLog = true;
|
||||||
int autoMaxRank = 0;
|
int autoMaxRank = 0;
|
||||||
int autoMinLength = 3;
|
int autoMinLength = 3;
|
||||||
std::wstring autoPrefixChars = L"([{«“‘\"";
|
std::wstring autoPrefixChars = L"([{«“‘\"";
|
||||||
|
|||||||
+3
-1
@@ -67,7 +67,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
ks::App app(instance, arg == L"--smoke-test");
|
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;
|
app.startCommand = cmd;
|
||||||
result = app.execute();
|
result = app.execute();
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-16
@@ -347,15 +347,30 @@ size_t Clip::size() const
|
|||||||
n += f.data.size();
|
n += f.data.size();
|
||||||
return n;
|
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)
|
bool chord(WORD key, UINT mods)
|
||||||
{
|
{
|
||||||
std::vector<INPUT> inputs;
|
std::vector<INPUT> inputs;
|
||||||
auto add = [&](WORD k, bool up)
|
auto add = [&](WORD k, bool up)
|
||||||
{
|
{
|
||||||
INPUT i{};
|
INPUT i = keyboardInput(k, up ? KEYEVENTF_KEYUP : 0);
|
||||||
i.type = INPUT_KEYBOARD;
|
|
||||||
i.ki.wVk = k;
|
|
||||||
i.ki.dwFlags = up ? KEYEVENTF_KEYUP : 0;
|
|
||||||
if (k == VK_LEFT || k == VK_RIGHT || k == VK_INSERT)
|
if (k == VK_LEFT || k == VK_RIGHT || k == VK_INSERT)
|
||||||
i.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
|
i.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
|
||||||
inputs.push_back(i);
|
inputs.push_back(i);
|
||||||
@@ -406,10 +421,7 @@ bool selectPrevious(size_t count, const std::function<bool()> &valid)
|
|||||||
std::vector<INPUT> inputs;
|
std::vector<INPUT> inputs;
|
||||||
auto add = [&](WORD key, DWORD flags)
|
auto add = [&](WORD key, DWORD flags)
|
||||||
{
|
{
|
||||||
INPUT i{};
|
INPUT i = keyboardInput(key, flags);
|
||||||
i.type = INPUT_KEYBOARD;
|
|
||||||
i.ki.wVk = key;
|
|
||||||
i.ki.dwFlags = flags;
|
|
||||||
inputs.push_back(i);
|
inputs.push_back(i);
|
||||||
};
|
};
|
||||||
add(VK_SHIFT, 0);
|
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})
|
for (int k : {VK_LCONTROL, VK_RCONTROL, VK_LMENU, VK_RMENU, VK_LSHIFT, VK_RSHIFT, VK_LWIN, VK_RWIN})
|
||||||
if (GetAsyncKeyState(k) & 0x8000)
|
if (GetAsyncKeyState(k) & 0x8000)
|
||||||
{
|
{
|
||||||
INPUT i{};
|
INPUT i = keyboardInput((WORD)k, KEYEVENTF_KEYUP);
|
||||||
i.type = INPUT_KEYBOARD;
|
|
||||||
i.ki.wVk = (WORD)k;
|
|
||||||
i.ki.dwFlags = KEYEVENTF_KEYUP;
|
|
||||||
inputs.push_back(i);
|
inputs.push_back(i);
|
||||||
}
|
}
|
||||||
if (!inputs.empty())
|
if (!inputs.empty())
|
||||||
@@ -527,16 +536,112 @@ std::wstring lastError(const std::wstring &context)
|
|||||||
{
|
{
|
||||||
return context + L" (Windows error " + std::to_wstring(GetLastError()) + L")";
|
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)
|
void log(const std::wstring &message)
|
||||||
{
|
{
|
||||||
static std::mutex gate;
|
std::lock_guard lock(logGate);
|
||||||
std::lock_guard lock(gate);
|
if (!loggingEnabled)
|
||||||
|
return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
auto path = dataDirectory() / L"kswitcher.log";
|
auto path = dataDirectory() / L"kswitcher.log";
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
if (std::filesystem::file_size(path, ec) > 256 * 1024)
|
auto size = std::filesystem::file_size(path, ec);
|
||||||
std::filesystem::remove(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);
|
std::ofstream f(path, std::ios::app);
|
||||||
SYSTEMTIME t;
|
SYSTEMTIME t;
|
||||||
GetLocalTime(&t);
|
GetLocalTime(&t);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "core.h"
|
#include "core.h"
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <deque>
|
||||||
#include <objbase.h>
|
#include <objbase.h>
|
||||||
#include <span>
|
#include <span>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
@@ -35,6 +36,10 @@ Clip readClipboard(HWND, size_t limit = 32 * 1024 * 1024);
|
|||||||
DWORD writeClipboard(HWND, const Clip &);
|
DWORD writeClipboard(HWND, const Clip &);
|
||||||
Clip textClip(const std::wstring &);
|
Clip textClip(const std::wstring &);
|
||||||
bool chord(WORD key, UINT modifiers = 0);
|
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 needsSelectionDelay(HWND);
|
||||||
bool selectPrevious(size_t count, const std::function<bool()> &valid);
|
bool selectPrevious(size_t count, const std::function<bool()> &valid);
|
||||||
bool modifiersReleased();
|
bool modifiersReleased();
|
||||||
@@ -50,4 +55,17 @@ void configureAutostart(int mode);
|
|||||||
std::wstring executable();
|
std::wstring executable();
|
||||||
std::wstring lastError(const std::wstring &context);
|
std::wstring lastError(const std::wstring &context);
|
||||||
void log(const std::wstring &message);
|
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
|
} // namespace ks
|
||||||
|
|||||||
+13
-3
@@ -102,6 +102,10 @@ void validateSettings(const Settings &s)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Settings loadSettings(const std::filesystem::path &path, const std::vector<Layout> &layouts)
|
Settings loadSettings(const std::filesystem::path &path, const std::vector<Layout> &layouts)
|
||||||
|
{
|
||||||
|
return decodeSettings(std::filesystem::exists(path) ? readFile(path, 4 * 1024 * 1024) : Bytes{}, layouts);
|
||||||
|
}
|
||||||
|
Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
||||||
{
|
{
|
||||||
Settings s;
|
Settings s;
|
||||||
for (size_t i = 0; i < layouts.size(); ++i)
|
for (size_t i = 0; i < layouts.size(); ++i)
|
||||||
@@ -111,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.direct.push_back(i < 3 ? L"Ctrl+Alt+Shift+" + std::to_wstring(i + 1) : L"");
|
||||||
s.directIds.push_back(layouts[i].id);
|
s.directIds.push_back(layouts[i].id);
|
||||||
}
|
}
|
||||||
if (!std::filesystem::exists(path))
|
if (bytes.empty())
|
||||||
return s;
|
return s;
|
||||||
auto j = parse(readFile(path, 4 * 1024 * 1024));
|
auto j = parse(bytes);
|
||||||
auto format = j.value("format", std::string());
|
auto format = j.value("format", std::string());
|
||||||
if (format != "kswitcher-settings" && format != "typlo-settings")
|
if (format != "kswitcher-settings" && format != "typlo-settings")
|
||||||
throw std::runtime_error("Not a KSwitcher or Typlo settings file");
|
throw std::runtime_error("Not a KSwitcher or Typlo settings file");
|
||||||
s.recent = j.value("convertLastWord", true);
|
s.recent = j.value("convertLastWord", true);
|
||||||
s.switchLayout = j.value("switchSystemLayout", true);
|
s.switchLayout = j.value("switchSystemLayout", true);
|
||||||
|
s.writeLog = j.value("writeLog", true);
|
||||||
s.history = j.value("historyEnabled", true);
|
s.history = j.value("historyEnabled", true);
|
||||||
s.autostart = j.value("startWithSystem", false);
|
s.autostart = j.value("startWithSystem", false);
|
||||||
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
|
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
|
||||||
@@ -176,12 +181,17 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
void saveSettings(const std::filesystem::path &path, const Settings &s)
|
void saveSettings(const std::filesystem::path &path, const Settings &s)
|
||||||
|
{
|
||||||
|
atomicWrite(path, encodeSettings(s));
|
||||||
|
}
|
||||||
|
Bytes encodeSettings(const Settings &s)
|
||||||
{
|
{
|
||||||
validateSettings(s);
|
validateSettings(s);
|
||||||
Json j = {{"format", "kswitcher-settings"},
|
Json j = {{"format", "kswitcher-settings"},
|
||||||
{"schemaVersion", 1},
|
{"schemaVersion", 1},
|
||||||
{"convertLastWord", s.recent},
|
{"convertLastWord", s.recent},
|
||||||
{"switchSystemLayout", s.switchLayout},
|
{"switchSystemLayout", s.switchLayout},
|
||||||
|
{"writeLog", s.writeLog},
|
||||||
{"autostartAsAdministrator", s.elevatedAutostart},
|
{"autostartAsAdministrator", s.elevatedAutostart},
|
||||||
{"historyEnabled", s.history},
|
{"historyEnabled", s.history},
|
||||||
{"startWithSystem", s.autostart},
|
{"startWithSystem", s.autostart},
|
||||||
@@ -219,7 +229,7 @@ void saveSettings(const std::filesystem::path &path, const Settings &s)
|
|||||||
j["excludedApps"] = Json::array();
|
j["excludedApps"] = Json::array();
|
||||||
for (auto &name : s.excluded)
|
for (auto &name : s.excluded)
|
||||||
j["excludedApps"].push_back(utf8(name));
|
j["excludedApps"].push_back(utf8(name));
|
||||||
atomicWrite(path, jsonBytes(j));
|
return jsonBytes(j);
|
||||||
}
|
}
|
||||||
Bytes encodeClip(const Clip &clip)
|
Bytes encodeClip(const Clip &clip)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
Settings loadSettings(const std::filesystem::path &, const std::vector<Layout> &);
|
Settings loadSettings(const std::filesystem::path &, const std::vector<Layout> &);
|
||||||
|
Settings decodeSettings(const Bytes &, const std::vector<Layout> &);
|
||||||
|
Bytes encodeSettings(const Settings &);
|
||||||
void saveSettings(const std::filesystem::path &, const Settings &);
|
void saveSettings(const std::filesystem::path &, const Settings &);
|
||||||
void validateSettings(const Settings &);
|
void validateSettings(const Settings &);
|
||||||
struct Entry
|
struct Entry
|
||||||
|
|||||||
+453
-18
@@ -1,7 +1,9 @@
|
|||||||
#include "../resources/resource.h"
|
#include "../resources/resource.h"
|
||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include "configuration.h"
|
||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
#include "richtext.h"
|
#include "richtext.h"
|
||||||
|
#include "ui_geometry.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
#include <commdlg.h>
|
#include <commdlg.h>
|
||||||
@@ -26,6 +28,129 @@ HFONT font(HWND w)
|
|||||||
OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH,
|
OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH,
|
||||||
L"Segoe UI");
|
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,
|
HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style, int id, int x, int y,
|
||||||
int width, int height)
|
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,
|
scale(parent, width), scale(parent, height), parent, (HMENU)(INT_PTR)id,
|
||||||
GetModuleHandleW(nullptr), nullptr);
|
GetModuleHandleW(nullptr), nullptr);
|
||||||
SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE);
|
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;
|
return w;
|
||||||
}
|
}
|
||||||
void label(HWND w, const wchar_t *text, int x, int y, int width = 200)
|
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;
|
auto *app = (App *)data;
|
||||||
if (msg == WM_SETFOCUS)
|
if (msg == WM_SETFOCUS)
|
||||||
{
|
{
|
||||||
app->hotkeyCapture = w;
|
app->setHotkeyCapture(w);
|
||||||
SendMessageW(w, EM_SETSEL, 0, -1);
|
SendMessageW(w, EM_SETSEL, 0, -1);
|
||||||
}
|
}
|
||||||
if (msg == WM_KILLFOCUS || msg == WM_NCDESTROY)
|
if (msg == WM_KILLFOCUS || msg == WM_NCDESTROY)
|
||||||
{
|
{
|
||||||
if (app->hotkeyCapture == w)
|
if (app->hotkeyCapture == w)
|
||||||
app->hotkeyCapture = nullptr;
|
app->setHotkeyCapture(nullptr);
|
||||||
}
|
}
|
||||||
if (msg == CaptureHotkeyMessage)
|
if (msg == CaptureHotkeyMessage)
|
||||||
{
|
{
|
||||||
@@ -88,13 +219,13 @@ LRESULT CALLBACK hotkeyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
|
|||||||
return 0;
|
return 0;
|
||||||
return DefSubclassProc(w, msg, wp, lp);
|
return DefSubclassProc(w, msg, wp, lp);
|
||||||
}
|
}
|
||||||
void hotkeyEdit(App &app, HWND w, int id, int x, int y, int width)
|
[[maybe_unused]] void hotkeyEdit(App &app, HWND w, int id, int x, int y, int width)
|
||||||
{
|
{
|
||||||
HWND field = edit(w, id, x, y, width, 25, ES_AUTOHSCROLL | ES_READONLY);
|
HWND field = edit(w, id, x, y, width, 25, ES_AUTOHSCROLL | ES_READONLY);
|
||||||
SendMessageW(field, EM_SETCUEBANNER, FALSE, (LPARAM)L"Click, then press a shortcut");
|
SendMessageW(field, EM_SETCUEBANNER, FALSE, (LPARAM)L"Click, then press a shortcut");
|
||||||
SetWindowSubclass(field, hotkeyProc, 1, (DWORD_PTR)&app);
|
SetWindowSubclass(field, hotkeyProc, 1, (DWORD_PTR)&app);
|
||||||
}
|
}
|
||||||
void checkbox(HWND w, const wchar_t *text, int id, int x, int y, int width = 300)
|
[[maybe_unused]] void checkbox(HWND w, const wchar_t *text, int id, int x, int y, int width = 300)
|
||||||
{
|
{
|
||||||
control(w, L"BUTTON", text, WS_TABSTOP | BS_AUTOCHECKBOX, id, x, y, width, 24);
|
control(w, L"BUTTON", text, WS_TABSTOP | BS_AUTOCHECKBOX, id, x, y, width, 24);
|
||||||
}
|
}
|
||||||
@@ -111,11 +242,11 @@ void value(HWND w, int id, const std::wstring &text)
|
|||||||
{
|
{
|
||||||
SetDlgItemTextW(w, id, text.c_str());
|
SetDlgItemTextW(w, id, text.c_str());
|
||||||
}
|
}
|
||||||
bool checked(HWND w, int id)
|
[[maybe_unused]] bool checked(HWND w, int id)
|
||||||
{
|
{
|
||||||
return IsDlgButtonChecked(w, id) == BST_CHECKED;
|
return IsDlgButtonChecked(w, id) == BST_CHECKED;
|
||||||
}
|
}
|
||||||
void check(HWND w, int id, bool yes)
|
[[maybe_unused]] void check(HWND w, int id, bool yes)
|
||||||
{
|
{
|
||||||
CheckDlgButton(w, id, yes ? BST_CHECKED : BST_UNCHECKED);
|
CheckDlgButton(w, id, yes ? BST_CHECKED : BST_UNCHECKED);
|
||||||
}
|
}
|
||||||
@@ -136,13 +267,31 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
|
|||||||
RegisterClassW(&wc);
|
RegisterClassW(&wc);
|
||||||
UINT dpi = GetDpiForSystem();
|
UINT dpi = GetDpiForSystem();
|
||||||
RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
|
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);
|
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
|
||||||
|
POINT cursor{};
|
||||||
|
GetCursorPos(&cursor);
|
||||||
|
MONITORINFO monitor{sizeof(monitor)};
|
||||||
|
GetMonitorInfoW(MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST), &monitor);
|
||||||
HWND w =
|
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);
|
rect.right - rect.left, rect.bottom - rect.top, nullptr, nullptr, app.instance, &app);
|
||||||
if (!w)
|
if (!w)
|
||||||
throw std::runtime_error(utf8(lastError(L"Cannot create native window")));
|
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);
|
SetWindowTextW(w, title);
|
||||||
auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
||||||
GetSystemMetricsForDpi(SM_CXSMICON, dpi),
|
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)
|
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 previous = (int)(UINT_PTR)GetPropW(w, L"Dpi");
|
||||||
int next = LOWORD(wp);
|
int next = LOWORD(wp);
|
||||||
if (!previous)
|
if (!previous)
|
||||||
@@ -166,7 +321,8 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
|
|||||||
int previous, next;
|
int previous, next;
|
||||||
HFONT font;
|
HFONT font;
|
||||||
} state{w, previous, next, replacement};
|
} state{w, previous, next, replacement};
|
||||||
EnumChildWindows(
|
auto *layout = windowLayoutState(w);
|
||||||
|
if (!layout || !layout->scrolling) EnumChildWindows(
|
||||||
w,
|
w,
|
||||||
[](HWND child, LPARAM data) -> BOOL
|
[](HWND child, LPARAM data) -> BOOL
|
||||||
{
|
{
|
||||||
@@ -183,14 +339,21 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
|
|||||||
return TRUE;
|
return TRUE;
|
||||||
},
|
},
|
||||||
(LPARAM)&state);
|
(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"Font", replacement);
|
||||||
SetPropW(w, L"Dpi", (HANDLE)(UINT_PTR)next);
|
SetPropW(w, L"Dpi", (HANDLE)(UINT_PTR)next);
|
||||||
if (old)
|
if (old)
|
||||||
DeleteObject(old);
|
DeleteObject(old);
|
||||||
auto *r = (RECT *)lp;
|
fitToMonitor(w, *(RECT *)lp);
|
||||||
SetWindowPos(w, nullptr, r->left, r->top, r->right - r->left, r->bottom - r->top, SWP_NOZORDER);
|
arrangeWindow(w);
|
||||||
}
|
}
|
||||||
std::filesystem::path pickFile(HWND owner, bool save)
|
[[maybe_unused]] std::filesystem::path pickFile(HWND owner, bool save)
|
||||||
{
|
{
|
||||||
wchar_t path[32768]{};
|
wchar_t path[32768]{};
|
||||||
OPENFILENAMEW ofn{sizeof(ofn)};
|
OPENFILENAMEW ofn{sizeof(ofn)};
|
||||||
@@ -210,6 +373,7 @@ void disposeFont(HWND w)
|
|||||||
DeleteObject(h);
|
DeleteObject(h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ifndef KS_CONFIG_APP
|
||||||
struct SearchResult
|
struct SearchResult
|
||||||
{
|
{
|
||||||
uint64_t generation;
|
uint64_t generation;
|
||||||
@@ -593,6 +757,8 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
return DefWindowProcW(w, msg, wp, lp);
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef KS_RESIDENT_APP
|
||||||
struct SettingsView
|
struct SettingsView
|
||||||
{
|
{
|
||||||
App *app{};
|
App *app{};
|
||||||
@@ -645,6 +811,7 @@ void fillSettings(HWND w, SettingsView *v)
|
|||||||
auto &s = v->draft;
|
auto &s = v->draft;
|
||||||
check(w, 201, s.recent);
|
check(w, 201, s.recent);
|
||||||
check(w, 202, s.switchLayout);
|
check(w, 202, s.switchLayout);
|
||||||
|
check(w, 208, s.writeLog);
|
||||||
check(w, 203, s.history);
|
check(w, 203, s.history);
|
||||||
check(w, 204, s.autostart);
|
check(w, 204, s.autostart);
|
||||||
check(w, 206, s.elevatedAutostart);
|
check(w, 206, s.elevatedAutostart);
|
||||||
@@ -693,6 +860,7 @@ Settings collectSettings(HWND w, SettingsView *v)
|
|||||||
auto s = v->draft;
|
auto s = v->draft;
|
||||||
s.recent = checked(w, 201);
|
s.recent = checked(w, 201);
|
||||||
s.switchLayout = checked(w, 202);
|
s.switchLayout = checked(w, 202);
|
||||||
|
s.writeLog = checked(w, 208);
|
||||||
s.history = checked(w, 203);
|
s.history = checked(w, 203);
|
||||||
s.autostart = checked(w, 204);
|
s.autostart = checked(w, 204);
|
||||||
s.elevatedAutostart = checked(w, 206);
|
s.elevatedAutostart = checked(w, 206);
|
||||||
@@ -908,6 +1076,11 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
layoutSelection(w, v);
|
layoutSelection(w, v);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (id == 256)
|
||||||
|
{
|
||||||
|
showLogs(*v->app);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (id == 230)
|
if (id == 230)
|
||||||
{
|
{
|
||||||
collectLayout(w, v);
|
collectLayout(w, v);
|
||||||
@@ -977,11 +1150,11 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
fillSettings(w, v);
|
fillSettings(w, v);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (id == 254 && v->app->history)
|
if (id == 254)
|
||||||
{
|
{
|
||||||
if (MessageBoxW(w, L"Delete all clipboard history, including pinned items?", L"Clear history",
|
if (MessageBoxW(w, L"Delete all clipboard history, including pinned items?", L"Clear history",
|
||||||
MB_YESNO | MB_ICONWARNING) == IDYES)
|
MB_YESNO | MB_ICONWARNING) == IDYES)
|
||||||
v->app->history->clear();
|
v->app->clearHistory();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -1125,6 +1298,8 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
return DefWindowProcW(w, msg, wp, lp);
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
#ifndef KS_CONFIG_APP
|
||||||
LRESULT CALLBACK entryProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
LRESULT CALLBACK entryProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
{
|
{
|
||||||
if (msg == WM_SIZE)
|
if (msg == WM_SIZE)
|
||||||
@@ -1136,7 +1311,9 @@ LRESULT CALLBACK entryProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
disposeFont(w);
|
disposeFont(w);
|
||||||
return DefWindowProcW(w, msg, wp, lp);
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
} // namespace
|
} // namespace
|
||||||
|
#ifndef KS_CONFIG_APP
|
||||||
void refreshHistory(App &app)
|
void refreshHistory(App &app)
|
||||||
{
|
{
|
||||||
if (app.historyWindow)
|
if (app.historyWindow)
|
||||||
@@ -1195,7 +1372,254 @@ void showHistory(App &app)
|
|||||||
SetFocus(GetDlgItem(app.historyWindow, 101));
|
SetFocus(GetDlgItem(app.historyWindow, 101));
|
||||||
refreshHistory(app);
|
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)
|
if (app.settingsWindow)
|
||||||
{
|
{
|
||||||
@@ -1209,12 +1633,13 @@ void showSettings(App &app)
|
|||||||
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 780, 660);
|
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 780, 660);
|
||||||
app.settingsWindow = w;
|
app.settingsWindow = w;
|
||||||
label(w, L"Text conversion", 18, 16);
|
label(w, L"Text conversion", 18, 16);
|
||||||
checkbox(w, L"Convert recently typed text when nothing is selected", 201, 18, 43, 440);
|
checkbox(w, L"Convert recently typed text when nothing is selected", 201, 18, 43, 390);
|
||||||
checkbox(w, L"Switch Windows input layout after conversion", 202, 18, 70, 440);
|
checkbox(w, L"Switch Windows input layout after conversion", 202, 18, 70, 390);
|
||||||
checkbox(w, L"Record clipboard history", 203, 18, 106);
|
checkbox(w, L"Record clipboard history", 203, 18, 106);
|
||||||
checkbox(w, L"Start with Windows", 204, 18, 133);
|
checkbox(w, L"Start with Windows", 204, 18, 133);
|
||||||
checkbox(w, L"Start with Windows as administrator", 206, 18, 160, 395);
|
checkbox(w, L"Start with Windows as administrator", 206, 18, 160, 395);
|
||||||
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);
|
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",
|
const wchar_t *names[] = {L"Next layout", L"Change case", L"Transliterate", L"Undo",
|
||||||
L"Clipboard history"};
|
L"Clipboard history"};
|
||||||
@@ -1245,14 +1670,18 @@ void showSettings(App &app)
|
|||||||
label(w, L"Exclude applications (process names, comma-separated)", 18, 516, 700);
|
label(w, L"Exclude applications (process names, comma-separated)", 18, 516, 700);
|
||||||
edit(w, 243, 18, 544, 742);
|
edit(w, 243, 18, 544, 742);
|
||||||
button(w, L"Auto-correction options…", 255, 18, 575, 240);
|
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"Import settings…", 250, 18, 606, 145);
|
||||||
button(w, L"Export settings…", 251, 173, 606, 145);
|
button(w, L"Export settings…", 251, 173, 606, 145);
|
||||||
button(w, L"Snippets…", 252, 328, 606, 110);
|
button(w, L"Snippets…", 252, 328, 606, 110);
|
||||||
button(w, L"Save", IDOK, 548, 606, 100);
|
button(w, L"Save", IDOK, 548, 606, 100);
|
||||||
button(w, L"Cancel", IDCANCEL, 660, 606, 100);
|
button(w, L"Cancel", IDCANCEL, 660, 606, 100);
|
||||||
fillSettings(w, (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
fillSettings(w, (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||||
|
if (visible)
|
||||||
|
{
|
||||||
showRequestedWindow(w);
|
showRequestedWindow(w);
|
||||||
activateWindow(w);
|
activateWindow(w);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
void showSnippets(App &app)
|
void showSnippets(App &app)
|
||||||
{
|
{
|
||||||
@@ -1280,6 +1709,11 @@ void showSnippets(App &app)
|
|||||||
showRequestedWindow(w);
|
showRequestedWindow(w);
|
||||||
SetForegroundWindow(w);
|
SetForegroundWindow(w);
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
void showSettings(App &app, bool) { launchConfiguration(app); }
|
||||||
|
void showSnippets(App &app) { launchConfiguration(app, true); }
|
||||||
|
#endif
|
||||||
|
#ifndef KS_CONFIG_APP
|
||||||
void showShortcuts(App &app)
|
void showShortcuts(App &app)
|
||||||
{
|
{
|
||||||
auto &s = app.settings;
|
auto &s = app.settings;
|
||||||
@@ -1317,4 +1751,5 @@ void showEntry(App &app, const std::wstring &id)
|
|||||||
error(app.historyWindow, e);
|
error(app.historyWindow, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -1,5 +1,7 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
#include <fstream>
|
||||||
|
#include <commctrl.h>
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
@@ -14,6 +16,12 @@ const std::vector<std::pair<std::wstring, std::wstring>> punctuationCases{
|
|||||||
{L"ljvf: ", L"дома: "}, {L"ljvf; ", L"дома; "}, {L"#ljvf%; ", L"#дома%; "}};
|
{L"ljvf: ", L"дома: "}, {L"ljvf; ", L"дома; "}, {L"#ljvf%; ", L"#дома%; "}};
|
||||||
ULONGLONG deadline{};
|
ULONGLONG deadline{};
|
||||||
std::wstring sentinel = L"KSwitcher integration clipboard sentinel";
|
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)
|
void require(bool ok, const char *name)
|
||||||
{
|
{
|
||||||
if (!ok)
|
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");
|
require(text() == L"OLDпривет", "recent-input conversion preserves adjacent older text");
|
||||||
prepare(L"OLDabcdef", false);
|
prepare(L"OLDabcdef", false);
|
||||||
record(L"ghbdtn");
|
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);
|
app->transform(ks::Direct + ru);
|
||||||
break;
|
break;
|
||||||
case 6:
|
case 6:
|
||||||
require(text() == L"OLDabcdef", "recent-input mismatch refuses replacement");
|
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"}};
|
app->settings.snippets = {{L";addr", L"LINE1\r\n{cursor}LINE2"}};
|
||||||
prepare(L"prefix ;addr", false);
|
prepare(L"prefix ;addr", false);
|
||||||
record(L";addr");
|
record(L";addr");
|
||||||
@@ -237,7 +263,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
return DefWindowProcW(w, msg, wp, lp);
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
int main()
|
int main(int argc, char **argv)
|
||||||
{
|
{
|
||||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||||
auto root = std::filesystem::temp_directory_path() /
|
auto root = std::filesystem::temp_directory_path() /
|
||||||
@@ -249,6 +275,73 @@ int main()
|
|||||||
app = &instance;
|
app = &instance;
|
||||||
app->settings.history = false;
|
app->settings.history = false;
|
||||||
app->settings.switchLayout = 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)
|
for (int i = 0; i < (int)app->layouts.size(); ++i)
|
||||||
{
|
{
|
||||||
auto language = LOWORD((uintptr_t)app->layouts[i].handle);
|
auto language = LOWORD((uintptr_t)app->layouts[i].handle);
|
||||||
|
|||||||
@@ -44,7 +44,37 @@ int main(int argc, char **argv)
|
|||||||
}
|
}
|
||||||
try
|
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)};
|
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.dwFlags = STARTF_USESHOWWINDOW;
|
||||||
startup.wShowWindow = SW_HIDE;
|
startup.wShowWindow = SW_HIDE;
|
||||||
PROCESS_INFORMATION child{};
|
PROCESS_INFORMATION child{};
|
||||||
@@ -346,6 +376,7 @@ int main(int argc, char **argv)
|
|||||||
require(history.entries().size() == 2, "a new screenshot is retained");
|
require(history.entries().size() == 2, "a new screenshot is retained");
|
||||||
}
|
}
|
||||||
ks::Settings settings;
|
ks::Settings settings;
|
||||||
|
settings.writeLog = false;
|
||||||
settings.elevatedAutostart = true;
|
settings.elevatedAutostart = true;
|
||||||
settings.autoCorrect = true;
|
settings.autoCorrect = true;
|
||||||
settings.autoMaxRank = 10000;
|
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"}};
|
settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}};
|
||||||
ks::saveSettings(temp / L"settings.json", settings);
|
ks::saveSettings(temp / L"settings.json", settings);
|
||||||
auto restored = ks::loadSettings(temp / L"settings.json", {});
|
auto restored = ks::loadSettings(temp / L"settings.json", {});
|
||||||
|
require(ks::encodeSettings(ks::decodeSettings(ks::encodeSettings(settings), {})) == ks::encodeSettings(settings),
|
||||||
|
"configurator snapshot survives serialization without changing the concurrency token");
|
||||||
|
rejects([&] { ks::decodeSettings(ks::Bytes{'{', '}'}, {}); },
|
||||||
|
"configurator rejects missing settings format");
|
||||||
|
require(!restored.writeLog, "logging setting persists when disabled");
|
||||||
|
require(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.snippets[0].text == settings.snippets[0].text, "settings snippet roundtrip");
|
||||||
require(restored.cycle == settings.cycle, "layout order roundtrip");
|
require(restored.cycle == settings.cycle, "layout order roundtrip");
|
||||||
require(restored.elevatedAutostart, "elevated autostart setting roundtrip");
|
require(restored.elevatedAutostart, "elevated autostart setting roundtrip");
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ try {
|
|||||||
$stop = Start-Process -FilePath $exe -ArgumentList '--exit' -WindowStyle Hidden -PassThru
|
$stop = Start-Process -FilePath $exe -ArgumentList '--exit' -WindowStyle Hidden -PassThru
|
||||||
if (-not $stop.WaitForExit(5000)) { throw 'Exit command timed out.' }
|
if (-not $stop.WaitForExit(5000)) { throw 'Exit command timed out.' }
|
||||||
foreach ($process in $running) { if (-not $process.WaitForExit(10000)) { throw 'KSwitcher did not stop.' } }
|
foreach ($process in $running) { if (-not $process.WaitForExit(10000)) { throw 'KSwitcher did not stop.' } }
|
||||||
|
$config = @(Get-Process 'KSwitcher.Config' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq (Join-Path $root 'build\KSwitcher.Config.exe') })
|
||||||
|
foreach ($process in $config) { if (-not $process.WaitForExit(10000)) { throw 'Close the configurator before updating.' } }
|
||||||
|
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.Config.exe') -Destination (Join-Path $root 'build\KSwitcher.Config.exe') -Force
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.exe') -Destination $exe -Force
|
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.exe') -Destination $exe -Force
|
||||||
Start-Process -FilePath $exe -WindowStyle Hidden
|
Start-Process -FilePath $exe -WindowStyle Hidden
|
||||||
'OK' | Set-Content -LiteralPath (Join-Path $root 'build\update-result.txt')
|
'OK' | Set-Content -LiteralPath (Join-Path $root 'build\update-result.txt')
|
||||||
|
|||||||
Reference in New Issue
Block a user