Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e33387c2d5 | ||
|
|
5de5b791bf | ||
|
|
c109408a98 |
No files matched your search
+32
-1
@@ -1,10 +1,24 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.6.44 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.6.47 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_executable(StayHere WIN32 utilities/StayHere/main.cpp)
|
||||||
|
add_executable(StayHereTest WIN32 utilities/StayHere/main.cpp)
|
||||||
|
target_compile_definitions(StayHereTest PRIVATE STAYHERE_TEST_INSTANCE)
|
||||||
|
set_target_properties(StayHere PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/tools")
|
||||||
|
foreach(utilityTarget StayHere StayHereTest)
|
||||||
|
target_compile_definitions(${utilityTarget} PRIVATE UNICODE _UNICODE _WIN32_WINNT=0x0A00)
|
||||||
|
target_link_libraries(${utilityTarget} PRIVATE user32 shell32 gdi32 wtsapi32)
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(${utilityTarget} PRIVATE /utf-8)
|
||||||
|
set_property(TARGET ${utilityTarget} PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
|
else()
|
||||||
|
target_link_options(${utilityTarget} PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
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 src/sync.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 src/sync.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)
|
||||||
@@ -30,6 +44,15 @@ target_link_libraries(KSwitcherScrollTests PRIVATE kscore)
|
|||||||
target_link_libraries(KSwitcherTests PRIVATE kscore)
|
target_link_libraries(KSwitcherTests PRIVATE kscore)
|
||||||
add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
|
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
|
||||||
|
# Opt-in interactive fixture: requires an installed Far Manager.
|
||||||
|
add_executable(KSwitcherFarSnippetTests WIN32 tests/far_snippets.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
|
target_link_libraries(KSwitcherFarSnippetTests PRIVATE kscore)
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(KSwitcherFarSnippetTests PRIVATE /utf-8)
|
||||||
|
set_property(TARGET KSwitcherFarSnippetTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
|
else()
|
||||||
|
target_link_options(KSwitcherFarSnippetTests PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||||
|
endif()
|
||||||
add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
target_link_libraries(KSwitcherInputTests PRIVATE kscore)
|
target_link_libraries(KSwitcherInputTests PRIVATE kscore)
|
||||||
target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES)
|
target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES)
|
||||||
@@ -111,6 +134,14 @@ add_test(NAME dictionary-regressions COMMAND KSwitcherDictionaryTests)
|
|||||||
set_tests_properties(dictionary-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
set_tests_properties(dictionary-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||||
add_test(NAME sync-regressions COMMAND KSwitcherSyncTests)
|
add_test(NAME sync-regressions COMMAND KSwitcherSyncTests)
|
||||||
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
||||||
|
add_executable(KSwitcherUtilitiesTests tests/utilities.cpp)
|
||||||
|
if(MSVC)
|
||||||
|
set_property(TARGET KSwitcherUtilitiesTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
|
else()
|
||||||
|
target_link_options(KSwitcherUtilitiesTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
|
endif()
|
||||||
|
add_test(NAME utilities-regressions COMMAND KSwitcherUtilitiesTests)
|
||||||
|
add_test(NAME stayhere-lifecycle COMMAND powershell -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/stayhere.ps1" -Executable "$<TARGET_FILE:StayHereTest>")
|
||||||
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
|
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
|
||||||
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
||||||
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
|
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
|
||||||
|
|||||||
@@ -280,9 +280,15 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
|||||||
Clipboard synchronizers such as Mouse Without Borders may republish a copy only when
|
Clipboard synchronizers such as Mouse Without Borders may republish a copy only when
|
||||||
KSwitcher first observed it owned by the target application and the selection text and
|
KSwitcher first observed it owned by the target application and the selection text and
|
||||||
editor selection metadata remain identical. Unobserved or altered foreign copies are rejected.
|
editor selection metadata remain identical. Unobserved or altered foreign copies are rejected.
|
||||||
Snippets require accessibility verification of their selected trigger and pasted text,
|
Snippets normally require accessibility verification of their selected trigger and pasted text,
|
||||||
even with logging disabled. If verification is unavailable or the paste differs, expansion
|
even with logging disabled. If verification is unavailable or the paste differs, expansion
|
||||||
stops without running cursor or Enter actions. Clipboard changes during paste also cancel actions.
|
stops without running cursor or Enter actions. Clipboard changes during paste also cancel actions.
|
||||||
|
Far Manager uses its native Alt+Shift+Arrow selection, Ctrl+Insert copy and Shift+Insert
|
||||||
|
paste commands instead: both the trigger and inserted text must match verified clipboard
|
||||||
|
copies from Far. Cancellation restores the full temporary selection, not one cursor step.
|
||||||
|
Far supports nonempty single-line replacements up to 4096 caret steps, including
|
||||||
|
`{cursor}` and verified `{enter}` actions. Empty or multiline replacements are rejected
|
||||||
|
before selection; pasting a newline could execute a command before it can be verified.
|
||||||
- Teams recent-word conversion uses a single word-selection command and validates the copied range.
|
- Teams recent-word conversion uses a single word-selection command and validates the copied range.
|
||||||
If the editor's word boundary differs from the recorded typing, it falls back to paced character
|
If the editor's word boundary differs from the recorded typing, it falls back to paced character
|
||||||
selection: Teams can coalesce a burst of Shift+Left events into just one character.
|
selection: Teams can coalesce a burst of Shift+Left events into just one character.
|
||||||
@@ -536,6 +542,30 @@ OpenBao administrator setup:
|
|||||||
and UI integration, so another service can be added without replacing the synchronization logic.
|
and UI integration, so another service can be added without replacing the synchronization logic.
|
||||||
Protocol and conflict regressions run with `build.ps1 -Test` or CMake's `sync-regressions` test.
|
Protocol and conflict regressions run with `build.ps1 -Test` or CMake's `sync-regressions` test.
|
||||||
|
|
||||||
|
## Portable utilities
|
||||||
|
|
||||||
|
The tray menu's **Utilities** submenu lists `.exe` files in a `tools` folder beside
|
||||||
|
`KSwitcher.exe`, sorted by filename. For example, place `StayHere.exe` at
|
||||||
|
`tools/StayHere.exe` and choose **Utilities → StayHere** to launch it manually.
|
||||||
|
The folder is rescanned whenever the menu opens; no configuration or restart is needed.
|
||||||
|
Only files directly in `tools` are listed (not subfolders, shortcuts, or KSwitcher's
|
||||||
|
own executables beside the main program). Utilities start with `tools` as their working
|
||||||
|
directory and are never started automatically. Their lifetime is independent of KSwitcher.
|
||||||
|
An absent or empty folder displays a disabled placeholder; launch errors are reported.
|
||||||
|
From v0.6.45, the release includes `tools/README.txt`, so extracting the ZIP or
|
||||||
|
installing an update creates the folder. Add your own trusted portable executables;
|
||||||
|
StayHere is bundled from v0.6.46, and settings synchronization does not transfer
|
||||||
|
utility files. Updates preserve personal utilities and refresh bundled StayHere and
|
||||||
|
documentation. Repository development scripts
|
||||||
|
are not included in the portable tools folder.
|
||||||
|
|
||||||
|
Choose **Utilities → StayHere** to start the bundled activity utility. It starts
|
||||||
|
enabled; left-click its tray icon to pause/resume or right-click for Exit. While
|
||||||
|
enabled it prevents automatic sleep and, after three minutes without input, sends
|
||||||
|
F15 on its 15-second check. It does not move the mouse, bypass locking, start with
|
||||||
|
KSwitcher automatically, or directly control Teams status. Locked/disconnected
|
||||||
|
sessions do not receive simulated activity. See `tools/StayHere-README.md`.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
The easiest reproducible local route uses the pinned portable w64devkit toolchain:
|
The easiest reproducible local route uses the pinned portable w64devkit toolchain:
|
||||||
@@ -569,8 +599,10 @@ default and require the main **Automatic correction (experimental)** checkbox to
|
|||||||
|
|
||||||
- **Use custom replacements**: enter one `typo=correction` pair per line, for example
|
- **Use custom replacements**: enter one `typo=correction` pair per line, for example
|
||||||
`првиіт=привіт` or `helo=hello`. Entries are normalized to lowercase; duplicate source words
|
`првиіт=привіт` or `helo=hello`. Entries are normalized to lowercase; duplicate source words
|
||||||
are rejected. Each side must contain 1–32 letters of the same supported language, without
|
are rejected. The typo must contain 1–32 letters. The correction may contain several words
|
||||||
spaces or punctuation. Explicit pairs take priority over dictionary recognition, including
|
separated by single spaces, for example `мамамила=мама мила`, up to 32 characters including
|
||||||
|
spaces. Both sides must use the same supported language, without punctuation; leading,
|
||||||
|
trailing and repeated spaces are not allowed. Explicit pairs take priority over dictionary recognition, including
|
||||||
already valid words, and do not require dictionary membership or satisfy the frequency limit.
|
already valid words, and do not require dictionary membership or satisfy the frequency limit.
|
||||||
- **Correct spelling typos using dictionaries**: checks unknown words in the current language
|
- **Correct spelling typos using dictionaries**: checks unknown words in the current language
|
||||||
after no layout correction is found. It accepts exactly one dictionary candidate reachable
|
after no layout correction is found. It accepts exactly one dictionary candidate reachable
|
||||||
@@ -586,6 +618,8 @@ ALL CAPS, mixed-case identifiers, numbers, URLs and email addresses are skipped.
|
|||||||
is applied once, without chaining. Click **OK**, then **Save** in Settings; pairs and switches
|
is applied once, without chaining. Click **OK**, then **Save** in Settings; pairs and switches
|
||||||
are included in settings export/import and synchronization. Undo restores the original word
|
are included in settings export/import and synchronization. Undo restores the original word
|
||||||
and suppresses its correction until restart.
|
and suppresses its correction until restart.
|
||||||
|
When synchronizing phrase replacements, update all participating PCs to v0.6.47 or newer;
|
||||||
|
older clients reject spaces in replacement pairs.
|
||||||
|
|
||||||
### Layout correction and shared options
|
### Layout correction and shared options
|
||||||
|
|
||||||
@@ -659,6 +693,7 @@ Run `./package.ps1 -PublishRelease` after building and testing to refresh the tr
|
|||||||
.\build.ps1 -Test # Pure/native regression checks; does not touch the user's clipboard
|
.\build.ps1 -Test # Pure/native regression checks; does not touch the user's clipboard
|
||||||
.\build.ps1 -Ocr # Render a synthetic image, decode it with WIC and recognize it with Windows OCR
|
.\build.ps1 -Ocr # Render a synthetic image, decode it with WIC and recognize it with Windows OCR
|
||||||
.\build.ps1 -Integration # Interactive, isolated test editor; avoid input for about 10 seconds
|
.\build.ps1 -Integration # Interactive, isolated test editor; avoid input for about 10 seconds
|
||||||
|
.\build.ps1 -Far # Explicit integration test using an isolated Far Manager profile
|
||||||
.\tests\configuration.ps1 # Separate-process settings tests; close KSwitcher first
|
.\tests\configuration.ps1 # Separate-process settings tests; close KSwitcher first
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
+10
-8
@@ -1,20 +1,22 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.44 — 2026-11-05
|
## v0.6.47 — 2026-11-05
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- Opening clipboard history selects and reveals the newest matching entry instead of restoring an older selection. This fixes reopening history after recording a new screenshot.
|
- Custom auto-correction replacements now accept several words on the correction side, for example `мамамила=мама мила`. The source remains a single word of 1–32 letters; the correction supports up to 32 characters including single spaces between words, in the same supported language.
|
||||||
- Search and the pinned-only filter remain saved. Clear the search and uncheck **★ Pinned only** if the new screenshot is hidden.
|
- Preserve initial capitalization, surrounding punctuation, the current layout and Undo when splitting joined words. Subsequent typing is preserved. Updated validation messages and the options hint explain support for spaces.
|
||||||
- Background clipboard/OCR refreshes preserve the selected record while browsing. Invoking the history shortcut while history is already active retains its existing quick-paste behavior.
|
- Enable **Automatic correction (experimental)** and **Use custom replacements**, enter one `typo=correction` pair per line, then click **OK → Save**. Existing language, minimum-length and exclusion settings still apply; defaults are unchanged.
|
||||||
- No shortcuts, settings, capture behavior or storage format changed.
|
- Retain bundled StayHere and all earlier fixes. The tray menu remains **Utilities → StayHere**.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
Passed dictionary equivalence checks for 140,939 words, 105 sync checks, shared wheel regressions, 319 native checks, 574 input checks, screenshot editor UI/save/registration, 48 updater checks and updater progress UI checks. The history regression covers reopening after a new image and preserving an explicitly selected older record during background refresh.
|
Passed 332 native regression checks, 574 input/settings checks and focused auto-correction integration checks, including phrase replacement, capitalization/punctuation, Undo and immediate subsequent typing. Also passed 105 synchronization checks and 54 updater regression checks. Release binaries, package manifest and SHA-256 checksums are verified; published assets are read back before completion.
|
||||||
|
|
||||||
## Upgrade
|
## Upgrade
|
||||||
Choose **Update** in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
Choose **Update** in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||||
For manual installation, exit KSwitcher, close its configurator and screenshot editors, and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
For manual installation, exit KSwitcher, close its configurator and screenshot editors, and extract the entire ZIP into its installation folder. Keep personal `data/`, customized dictionaries and your own tools. Keep the three KSwitcher executables together; StayHere runs independently.
|
||||||
|
|
||||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. Keep all three executables together; history uses another instance of KSwitcher.exe. The package contains no personal settings, credentials or clipboard history.
|
When synchronizing phrase replacements across PCs, update each participating PC to v0.6.47 or newer: older versions reject spaces in replacement pairs.
|
||||||
|
|
||||||
|
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains no personal settings, credentials or clipboard history.
|
||||||
|
|
||||||
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
||||||
@@ -1 +1 @@
|
|||||||
a3e8a42db787ab5b8edca698fc97943a3e0d5348c5000cc632aeb07179210469 KSwitcher-win-x64.zip
|
c15371f89ea86410267d96c909739b44c207cd76256cd29dc7b5f40b04ac7ffb KSwitcher-win-x64.zip
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
param([switch]$Test, [switch]$Integration, [switch]$Ocr, [switch]$Stage)
|
param([switch]$Test, [switch]$Integration, [switch]$Ocr, [switch]$Stage, [switch]$Far)
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$root = $PSScriptRoot
|
$root = $PSScriptRoot
|
||||||
$compiler = Join-Path $root '.tools\w64devkit\bin\g++.exe'
|
$compiler = Join-Path $root '.tools\w64devkit\bin\g++.exe'
|
||||||
@@ -16,6 +16,12 @@ 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 = @('-lwinhttp','-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
$libs = @('-lwinhttp','-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||||
|
$utilityDirectory = Join-Path (Split-Path $executablePath) 'tools'
|
||||||
|
New-Item -ItemType Directory -Force $utilityDirectory,build/release/tools | Out-Null
|
||||||
|
$stayHere = Join-Path $utilityDirectory 'StayHere.exe'
|
||||||
|
& $compiler @common -Werror -mwindows -municode -static -s utilities/StayHere/main.cpp -luser32 -lshell32 -lgdi32 -lwtsapi32 -o $stayHere
|
||||||
|
if ($LASTEXITCODE) { throw 'StayHere build failed' }
|
||||||
|
Copy-Item -LiteralPath $stayHere -Destination build/release/tools/StayHere.exe -Force
|
||||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','sync','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
foreach ($name in @('core','platform','autostart','autocorrect','storage','sync','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','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" }
|
||||||
@@ -41,6 +47,14 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||||
if ($Test) {
|
if ($Test) {
|
||||||
|
& $compiler @common -Werror -DSTAYHERE_TEST_INSTANCE -mwindows -municode -static -s utilities/StayHere/main.cpp -luser32 -lshell32 -lgdi32 -lwtsapi32 -o build/StayHereTest.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'StayHere fixture build failed' }
|
||||||
|
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/stayhere.ps1 -Executable (Join-Path $outDir 'StayHereTest.exe')
|
||||||
|
if ($LASTEXITCODE) { throw 'StayHere lifecycle tests failed' }
|
||||||
|
& $compiler @common tests/utilities.cpp -static -s -o build/KSwitcherUtilitiesTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Utilities test build failed' }
|
||||||
|
& .\build\KSwitcherUtilitiesTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Utilities tests failed' }
|
||||||
& $compiler @common tests/dictionary.cpp build/core.o build/platform.o build/autocorrect.o @libs -lpsapi -static -s -o build/KSwitcherDictionaryTests.exe
|
& $compiler @common tests/dictionary.cpp build/core.o build/platform.o build/autocorrect.o @libs -lpsapi -static -s -o build/KSwitcherDictionaryTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Dictionary test build failed' }
|
if ($LASTEXITCODE) { throw 'Dictionary test build failed' }
|
||||||
& .\build\KSwitcherDictionaryTests.exe
|
& .\build\KSwitcherDictionaryTests.exe
|
||||||
@@ -80,6 +94,13 @@ try {
|
|||||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update_progress.ps1
|
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update_progress.ps1
|
||||||
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
|
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
|
||||||
}
|
}
|
||||||
|
if ($Far) {
|
||||||
|
& $compiler @common tests/far_snippets.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -mwindows -municode -static -s -o build/KSwitcherFarSnippetTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Far snippet test build failed' }
|
||||||
|
$fixture = Start-Process -FilePath (Join-Path $outDir 'KSwitcherFarSnippetTests.exe') -WindowStyle Hidden -Wait -PassThru
|
||||||
|
Get-Content -LiteralPath (Join-Path $outDir 'far-snippets.log')
|
||||||
|
if ($fixture.ExitCode) { throw 'Far snippet tests failed' }
|
||||||
|
}
|
||||||
if ($Integration) {
|
if ($Integration) {
|
||||||
$historyFixture = Join-Path (Resolve-Path build) ('history-host-fixture-' + [guid]::NewGuid().ToString('N'))
|
$historyFixture = Join-Path (Resolve-Path build) ('history-host-fixture-' + [guid]::NewGuid().ToString('N'))
|
||||||
New-Item -ItemType Directory $historyFixture | Out-Null
|
New-Item -ItemType Directory $historyFixture | Out-Null
|
||||||
|
|||||||
+15
-2
@@ -10,8 +10,12 @@ 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
|
||||||
|
New-Item -ItemType Directory -Force (Join-Path $destination 'tools') | Out-Null
|
||||||
|
Copy-Item -LiteralPath (Join-Path $root 'resources\portable-tools-README.txt') -Destination (Join-Path $destination 'tools\README.txt') -Force
|
||||||
|
Copy-Item -LiteralPath (Join-Path $root 'build\release\tools\StayHere.exe') -Destination (Join-Path $destination 'tools\StayHere.exe') -Force
|
||||||
|
Copy-Item -LiteralPath (Join-Path $root 'utilities\StayHere\README.md') -Destination (Join-Path $destination 'tools\StayHere-README.md') -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','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
|
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries','tools\README.txt','tools\StayHere.exe','tools\StayHere-README.md') | ForEach-Object { Join-Path $destination $_ }
|
||||||
$entries = foreach ($path in $files) {
|
$entries = foreach ($path in $files) {
|
||||||
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
|
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
|
||||||
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
|
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
|
||||||
@@ -22,7 +26,16 @@ $manifest=Join-Path $destination 'release-files.json'
|
|||||||
@{schema=1; version=$version; files=@($entries)} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifest -Encoding utf8
|
@{schema=1; version=$version; files=@($entries)} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifest -Encoding utf8
|
||||||
$files += $manifest
|
$files += $manifest
|
||||||
$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
|
# Archive only manifest-listed files, preserving relative paths. Never sweep up
|
||||||
|
# personal utilities or leftover developer files from the staging directory.
|
||||||
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
|
$stream = [IO.File]::Open($archive,[IO.FileMode]::Create)
|
||||||
|
$zip = [IO.Compression.ZipArchive]::new($stream,[IO.Compression.ZipArchiveMode]::Create)
|
||||||
|
try {
|
||||||
|
foreach ($name in @($entries | ForEach-Object { $_.path }) + @('release-files.json')) {
|
||||||
|
[IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,(Join-Path $destination $name),$name,[IO.Compression.CompressionLevel]::Optimal) | Out-Null
|
||||||
|
}
|
||||||
|
} finally { $zip.Dispose(); $stream.Dispose() }
|
||||||
Get-FileHash -LiteralPath $archive -Algorithm SHA256
|
Get-FileHash -LiteralPath $archive -Algorithm SHA256
|
||||||
if ($PublishRelease) {
|
if ($PublishRelease) {
|
||||||
$release = Join-Path $root 'Release'
|
$release = Join-Path $root 'Release'
|
||||||
|
|||||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
|||||||
1 RT_MANIFEST "app.manifest"
|
1 RT_MANIFEST "app.manifest"
|
||||||
201 RCDATA "update.ps1"
|
201 RCDATA "update.ps1"
|
||||||
1 VERSIONINFO
|
1 VERSIONINFO
|
||||||
FILEVERSION 0,6,44,0
|
FILEVERSION 0,6,47,0
|
||||||
PRODUCTVERSION 0,6,44,0
|
PRODUCTVERSION 0,6,47,0
|
||||||
FILEOS VOS_NT_WINDOWS32
|
FILEOS VOS_NT_WINDOWS32
|
||||||
FILETYPE VFT_APP
|
FILETYPE VFT_APP
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -20,7 +20,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.6.44"
|
VALUE "FileVersion", "0.6.47"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
KSwitcher portable utilities
|
||||||
|
|
||||||
|
Put your trusted portable .exe utilities directly in this tools folder,
|
||||||
|
beside this README.txt. Right-click the KSwitcher tray icon and open Utilities.
|
||||||
|
For example: tools/StayHere.exe appears as Utilities > StayHere.
|
||||||
|
|
||||||
|
The list refreshes each time the tray menu opens. Subfolders and shortcuts
|
||||||
|
are not listed. Utilities start only when you choose them, with this folder
|
||||||
|
as their working directory. Closing KSwitcher does not close a utility.
|
||||||
|
|
||||||
|
StayHere.exe is bundled. Choose Utilities > StayHere to start it; it starts
|
||||||
|
enabled, prevents automatic sleep, and sends F15 after 3 minutes of inactivity.
|
||||||
|
Left-click its tray icon to pause/resume; right-click for Exit. See StayHere-README.md.
|
||||||
|
It does not start automatically with KSwitcher or bypass Windows locking.
|
||||||
|
|
||||||
|
Add other utilities separately. Updates preserve your own files in this folder;
|
||||||
|
bundled StayHere.exe and documentation may be refreshed. Utilities are not
|
||||||
|
transferred by settings synchronization.
|
||||||
+102
-16
@@ -6,6 +6,7 @@
|
|||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
#include "diagnostics.h"
|
#include "diagnostics.h"
|
||||||
#include "paste_probe.h"
|
#include "paste_probe.h"
|
||||||
|
#include "utilities.h"
|
||||||
#include "screenshot_notifications.h"
|
#include "screenshot_notifications.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
@@ -82,9 +83,12 @@ class Transaction
|
|||||||
bool trustedContentsObserved{};
|
bool trustedContentsObserved{};
|
||||||
std::unique_ptr<PasteProbe> selectionProof;
|
std::unique_ptr<PasteProbe> selectionProof;
|
||||||
std::wstring provenSelection;
|
std::wstring provenSelection;
|
||||||
|
bool farSnippet{};
|
||||||
|
size_t farSelectedSteps{};
|
||||||
|
|
||||||
public:
|
public:
|
||||||
Transaction(App &a, HWND t, uint64_t v) : app(a), target(t), version(v)
|
Transaction(App &a, HWND t, uint64_t v, bool farEditor = false)
|
||||||
|
: app(a), target(t), version(v), farSnippet(farEditor)
|
||||||
{
|
{
|
||||||
app.suspended = true;
|
app.suspended = true;
|
||||||
try
|
try
|
||||||
@@ -119,6 +123,7 @@ class Transaction
|
|||||||
}
|
}
|
||||||
~Transaction()
|
~Transaction()
|
||||||
{
|
{
|
||||||
|
if (farSnippet) finishSelection(false);
|
||||||
if (captured && GetClipboardSequenceNumber() == expected)
|
if (captured && GetClipboardSequenceNumber() == expected)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -200,7 +205,7 @@ class Transaction
|
|||||||
diagnosticEvent(L"Copy begin number=" + std::to_wstring(number) + L" sentinelSequence=" +
|
diagnosticEvent(L"Copy begin number=" + std::to_wstring(number) + L" sentinelSequence=" +
|
||||||
std::to_wstring(before) + L" target=" + std::to_wstring((uintptr_t)target) +
|
std::to_wstring(before) + L" target=" + std::to_wstring((uintptr_t)target) +
|
||||||
L" sentinel=" + diagnosticText(sentinel));
|
L" sentinel=" + diagnosticText(sentinel));
|
||||||
if (!valid() || !chord(L'C', MOD_CONTROL))
|
if (!valid() || !chord(farSnippet ? VK_INSERT : L'C', MOD_CONTROL))
|
||||||
{
|
{
|
||||||
diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked");
|
diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked");
|
||||||
return {};
|
return {};
|
||||||
@@ -250,7 +255,7 @@ class Transaction
|
|||||||
check();
|
check();
|
||||||
if (!valid() || GetClipboardSequenceNumber() != before)
|
if (!valid() || GetClipboardSequenceNumber() != before)
|
||||||
return {};
|
return {};
|
||||||
if (!chord(L'C', MOD_CONTROL))
|
if (!chord(farSnippet ? VK_INSERT : L'C', MOD_CONTROL))
|
||||||
return {};
|
return {};
|
||||||
diagnosticEvent(L"Copy retry: clipboard unchanged after Ctrl+C number=" + std::to_wstring(number) +
|
diagnosticEvent(L"Copy retry: clipboard unchanged after Ctrl+C number=" + std::to_wstring(number) +
|
||||||
L" attempt=" + std::to_wstring(attempts + 1) + L" elapsedMs=" + std::to_wstring(GetTickCount64() - started));
|
L" attempt=" + std::to_wstring(attempts + 1) + L" elapsedMs=" + std::to_wstring(GetTickCount64() - started));
|
||||||
@@ -337,8 +342,44 @@ class Transaction
|
|||||||
bool recoveredSelection() const { return selectionProof != nullptr; }
|
bool recoveredSelection() const { return selectionProof != nullptr; }
|
||||||
bool select(size_t n)
|
bool select(size_t n)
|
||||||
{
|
{
|
||||||
|
if (farSnippet)
|
||||||
|
{
|
||||||
|
if (n > 4096 || farSelectedSteps) return false;
|
||||||
|
for (size_t i = 0; i < n; ++i)
|
||||||
|
{
|
||||||
|
if (!valid() || !chord(VK_LEFT, MOD_ALT | MOD_SHIFT)) return false;
|
||||||
|
++farSelectedSteps;
|
||||||
|
}
|
||||||
|
return valid();
|
||||||
|
}
|
||||||
return selectPrevious(n, [&] { return valid(); });
|
return selectPrevious(n, [&] { return valid(); });
|
||||||
}
|
}
|
||||||
|
bool finishSelection(bool atStart)
|
||||||
|
{
|
||||||
|
if (!farSnippet) return valid() && chord(atStart ? VK_LEFT : VK_RIGHT);
|
||||||
|
if (!farSelectedSteps) return valid();
|
||||||
|
if (atStart)
|
||||||
|
{
|
||||||
|
// Far arrows move one character, rather than collapsing a selection
|
||||||
|
// to its edge. Move into the nonempty selected range and back.
|
||||||
|
if (!valid() || !chord(VK_RIGHT)) return false;
|
||||||
|
farSelectedSteps = 0;
|
||||||
|
return valid() && chord(VK_LEFT);
|
||||||
|
}
|
||||||
|
while (farSelectedSteps && valid())
|
||||||
|
{
|
||||||
|
if (!chord(VK_RIGHT, MOD_ALT | MOD_SHIFT)) return false;
|
||||||
|
--farSelectedSteps;
|
||||||
|
}
|
||||||
|
return !farSelectedSteps && valid();
|
||||||
|
}
|
||||||
|
std::optional<std::wstring> readFarSnippet(size_t count)
|
||||||
|
{
|
||||||
|
if (!farSnippet || !count || !select(count)) return {};
|
||||||
|
auto observed = copy();
|
||||||
|
if (!finishSelection(false) || observed.empty()) return {};
|
||||||
|
return observed;
|
||||||
|
}
|
||||||
std::wstring copyRange(size_t count, const std::wstring &wordCandidate,
|
std::wstring copyRange(size_t count, const std::wstring &wordCandidate,
|
||||||
const std::function<bool(const std::wstring &)> &matches,
|
const std::function<bool(const std::wstring &)> &matches,
|
||||||
const std::function<bool(const std::wstring &)> &suffixMatches = {})
|
const std::function<bool(const std::wstring &)> &suffixMatches = {})
|
||||||
@@ -441,8 +482,11 @@ class Transaction
|
|||||||
if (!valid())
|
if (!valid())
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!chord(L'V', MOD_CONTROL))
|
if (!chord(farSnippet ? VK_INSERT : L'V', farSnippet ? MOD_SHIFT : MOD_CONTROL))
|
||||||
throw std::runtime_error("Windows blocked simulated input; check application privilege levels");
|
throw std::runtime_error("Windows blocked simulated input; check application privilege levels");
|
||||||
|
// The selected trigger was replaced. Do not restore its old caret range
|
||||||
|
// if clipboard verification below subsequently rejects a concurrent copy.
|
||||||
|
if (farSnippet) farSelectedSteps = 0;
|
||||||
Sleep(300);
|
Sleep(300);
|
||||||
// A clipboard synchronizer may replace our payload after Ctrl+V is
|
// A clipboard synchronizer may replace our payload after Ctrl+V is
|
||||||
// queued but before the editor consumes it. Do not run snippet actions
|
// queued but before the editor consumes it. Do not run snippet actions
|
||||||
@@ -1294,14 +1338,16 @@ void App::transform(int command, int snippet)
|
|||||||
}
|
}
|
||||||
// Collapse a temporary selection on every failed auto-correction,
|
// Collapse a temporary selection on every failed auto-correction,
|
||||||
// including clipboard exceptions, before queued typing is replayed.
|
// including clipboard exceptions, before queued typing is replayed.
|
||||||
|
const bool farSnippet = snippet >= 0 && lower(processName(target)) == L"far";
|
||||||
bool autoSelected = false;
|
bool autoSelected = false;
|
||||||
struct SelectionReset
|
struct SelectionReset
|
||||||
{
|
{
|
||||||
bool &selected;
|
bool &selected;
|
||||||
std::function<bool()> valid;
|
std::function<bool()> valid;
|
||||||
~SelectionReset() { if (selected && valid()) chord(VK_RIGHT); }
|
bool farEditor;
|
||||||
} selectionReset{autoSelected, valid};
|
~SelectionReset() { if (selected && !farEditor && valid()) chord(VK_RIGHT); }
|
||||||
Transaction tx(*this, target, version);
|
} selectionReset{autoSelected, valid, farSnippet};
|
||||||
|
Transaction tx(*this, target, version, farSnippet);
|
||||||
reason = L"selection-unavailable";
|
reason = L"selection-unavailable";
|
||||||
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
|
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=" +
|
||||||
@@ -1381,6 +1427,15 @@ void App::transform(int command, int snippet)
|
|||||||
const auto &sn = config.snippets[snippet];
|
const auto &sn = config.snippets[snippet];
|
||||||
if (recent.keys.size() < sn.trigger.size())
|
if (recent.keys.size() < sn.trigger.size())
|
||||||
return;
|
return;
|
||||||
|
result = parseTemplate(sn.text).text;
|
||||||
|
// A multiline clipboard paste can execute a console command
|
||||||
|
// before readback. Only explicit, verified {enter} may do so.
|
||||||
|
if (farSnippet && (result.empty() || result.find_first_of(L"\r\n") != result.npos ||
|
||||||
|
caretSteps(result) > 4096))
|
||||||
|
{
|
||||||
|
reason = L"far-snippet-template-unsupported";
|
||||||
|
return;
|
||||||
|
}
|
||||||
Run tail = recent;
|
Run tail = recent;
|
||||||
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
|
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
|
||||||
if (!tx.select(sn.trigger.size()))
|
if (!tx.select(sn.trigger.size()))
|
||||||
@@ -1390,7 +1445,6 @@ void App::transform(int command, int snippet)
|
|||||||
text = tx.copy();
|
text = tx.copy();
|
||||||
if (tail.match(text, available, physicalLayout()) < 0)
|
if (tail.match(text, available, physicalLayout()) < 0)
|
||||||
return;
|
return;
|
||||||
result = parseTemplate(sn.text).text;
|
|
||||||
automatic = true;
|
automatic = true;
|
||||||
}
|
}
|
||||||
else if (config.recent && recent.window == target && !recent.keys.empty() && !recent.overflow)
|
else if (config.recent && recent.window == target && !recent.keys.empty() && !recent.overflow)
|
||||||
@@ -1480,9 +1534,10 @@ void App::transform(int command, int snippet)
|
|||||||
}
|
}
|
||||||
bool sent = false;
|
bool sent = false;
|
||||||
const bool requireReadback = tx.recoveredSelection() || snippet >= 0;
|
const bool requireReadback = tx.recoveredSelection() || snippet >= 0;
|
||||||
PasteProbe probe(((config.writeLog && manual) || requireReadback) && result != text, text, target);
|
PasteProbe probe(!farSnippet && ((config.writeLog && manual) || requireReadback) && result != text, text, target);
|
||||||
if (requireReadback && result != text && probe.status != L"ready")
|
if (requireReadback && !farSnippet && result != text && probe.status != L"ready")
|
||||||
{
|
{
|
||||||
|
verification = probe.status;
|
||||||
reason = snippet >= 0 ? L"snippet-verification-unavailable" : L"recovered-selection-unavailable";
|
reason = snippet >= 0 ? L"snippet-verification-unavailable" : L"recovered-selection-unavailable";
|
||||||
std::lock_guard lock(stateMutex);
|
std::lock_guard lock(stateMutex);
|
||||||
last.reset();
|
last.reset();
|
||||||
@@ -1494,7 +1549,7 @@ void App::transform(int command, int snippet)
|
|||||||
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);
|
tx.finishSelection(false);
|
||||||
if (requireReadback && result != text && !sent)
|
if (requireReadback && result != text && !sent)
|
||||||
{
|
{
|
||||||
reason = snippet >= 0 ? L"snippet-paste-cancelled" : L"recovered-selection-paste-cancelled";
|
reason = snippet >= 0 ? L"snippet-paste-cancelled" : L"recovered-selection-paste-cancelled";
|
||||||
@@ -1517,11 +1572,12 @@ void App::transform(int command, int snippet)
|
|||||||
verification = probe.status;
|
verification = probe.status;
|
||||||
if (tx.valid())
|
if (tx.valid())
|
||||||
{
|
{
|
||||||
auto observed = probe.read((int)caretSteps(result));
|
auto observed = farSnippet ? tx.readFarSnippet(caretSteps(result)) :
|
||||||
|
probe.read((int)caretSteps(result));
|
||||||
// A native Edit may silently ignore Ctrl+V while the clipboard is busy.
|
// A native Edit may silently ignore Ctrl+V while the clipboard is busy.
|
||||||
// Retry once only with fresh native proof that the exact original range
|
// Retry once only with fresh native proof that the exact original range
|
||||||
// is still selected. Never retry an unknown or changed editor state.
|
// is still selected. Never retry an unknown or changed editor state.
|
||||||
if (observed && newlines(*observed) == newlines(text) && tx.nativeSelectionIs(text))
|
if (!farSnippet && observed && newlines(*observed) == newlines(text) && tx.nativeSelectionIs(text))
|
||||||
{
|
{
|
||||||
diagnosticEvent(L"Paste retry: native Edit still selects the unchanged original");
|
diagnosticEvent(L"Paste retry: native Edit still selects the unchanged original");
|
||||||
if (tx.paste(textClip(result)) && tx.valid())
|
if (tx.paste(textClip(result)) && tx.valid())
|
||||||
@@ -1529,7 +1585,8 @@ void App::transform(int command, int snippet)
|
|||||||
else
|
else
|
||||||
observed.reset();
|
observed.reset();
|
||||||
}
|
}
|
||||||
verification = probe.status;
|
verification = farSnippet ? (observed ? L"read-back-via-far-clipboard" :
|
||||||
|
L"far-readback-unavailable") : probe.status;
|
||||||
if (observed && tx.valid())
|
if (observed && tx.valid())
|
||||||
{
|
{
|
||||||
actual = *observed;
|
actual = *observed;
|
||||||
@@ -1573,6 +1630,7 @@ void App::transform(int command, int snippet)
|
|||||||
}
|
}
|
||||||
if (snippet >= 0 && sent && tx.valid())
|
if (snippet >= 0 && sent && tx.valid())
|
||||||
{
|
{
|
||||||
|
tx.check();
|
||||||
auto t = parseTemplate(config.snippets[snippet].text);
|
auto t = parseTemplate(config.snippets[snippet].text);
|
||||||
if (!t.suffix.empty())
|
if (!t.suffix.empty())
|
||||||
{
|
{
|
||||||
@@ -1581,15 +1639,18 @@ void App::transform(int command, int snippet)
|
|||||||
if (!tx.valid()) return;
|
if (!tx.valid()) return;
|
||||||
if (newlines(suffix) != newlines(t.suffix))
|
if (newlines(suffix) != newlines(t.suffix))
|
||||||
{
|
{
|
||||||
chord(VK_RIGHT);
|
tx.finishSelection(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!chord(VK_LEFT)) return;
|
if (!tx.finishSelection(true)) return;
|
||||||
}
|
}
|
||||||
// Action markers run after insertion and cursor placement,
|
// Action markers run after insertion and cursor placement,
|
||||||
// before any physical typing buffered during expansion.
|
// before any physical typing buffered during expansion.
|
||||||
for (int i = 0; i < t.enters && tx.valid(); ++i)
|
for (int i = 0; i < t.enters && tx.valid(); ++i)
|
||||||
|
{
|
||||||
|
tx.check();
|
||||||
if (!chord(VK_RETURN)) break;
|
if (!chord(VK_RETURN)) break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
|
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
|
||||||
{
|
{
|
||||||
@@ -1761,6 +1822,10 @@ void App::recognizeEntry(std::wstring id)
|
|||||||
}
|
}
|
||||||
void App::trayMenu()
|
void App::trayMenu()
|
||||||
{
|
{
|
||||||
|
constexpr int utilityCommandBase = 1000;
|
||||||
|
std::error_code utilitiesError;
|
||||||
|
auto utilities = discoverUtilities(std::filesystem::path(executable()).parent_path() / L"tools", utilitiesError);
|
||||||
|
if (utilities.size() > 50000) utilities.resize(50000);
|
||||||
HMENU menu = CreatePopupMenu();
|
HMENU menu = CreatePopupMenu();
|
||||||
AppendMenuW(menu, MF_STRING, 10, L"Clipboard &history");
|
AppendMenuW(menu, MF_STRING, 10, L"Clipboard &history");
|
||||||
AppendMenuW(menu, MF_STRING, 24, L"&Capture screen region…");
|
AppendMenuW(menu, MF_STRING, 24, L"&Capture screen region…");
|
||||||
@@ -1768,6 +1833,13 @@ void App::trayMenu()
|
|||||||
SetMenuDefaultItem(menu, 11, FALSE);
|
SetMenuDefaultItem(menu, 11, FALSE);
|
||||||
AppendMenuW(menu, MF_STRING, 12, L"S&nippets…");
|
AppendMenuW(menu, MF_STRING, 12, L"S&nippets…");
|
||||||
AppendMenuW(menu, MF_STRING, 13, L"&Keyboard shortcuts…");
|
AppendMenuW(menu, MF_STRING, 13, L"&Keyboard shortcuts…");
|
||||||
|
HMENU utilityMenu = CreatePopupMenu();
|
||||||
|
for (size_t i = 0; i < utilities.size(); ++i)
|
||||||
|
AppendMenuW(utilityMenu, MF_STRING, utilityCommandBase + i, utilityMenuLabel(utilities[i]).c_str());
|
||||||
|
if (utilities.empty())
|
||||||
|
AppendMenuW(utilityMenu, MF_STRING | MF_GRAYED, 0,
|
||||||
|
utilitiesError ? L"Cannot read tools folder" : L"No utilities in tools folder");
|
||||||
|
AppendMenuW(menu, MF_POPUP, (UINT_PTR)utilityMenu, L"&Utilities");
|
||||||
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||||
AppendMenuW(menu, MF_STRING | (settings.awake ? MF_CHECKED : 0), 14, L"Keep screen a&wake");
|
AppendMenuW(menu, MF_STRING | (settings.awake ? MF_CHECKED : 0), 14, L"Keep screen a&wake");
|
||||||
HMENU pause = CreatePopupMenu();
|
HMENU pause = CreatePopupMenu();
|
||||||
@@ -1794,6 +1866,20 @@ void App::trayMenu()
|
|||||||
PostMessageW(window, WM_NULL, 0, 0);
|
PostMessageW(window, WM_NULL, 0, 0);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (cmd >= utilityCommandBase && size_t(cmd - utilityCommandBase) < utilities.size())
|
||||||
|
{
|
||||||
|
const auto &path = utilities[cmd - utilityCommandBase];
|
||||||
|
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(utf8(lastError(L"Cannot start utility " + path.filename().wstring())));
|
||||||
|
AllowSetForegroundWindow(process.dwProcessId);
|
||||||
|
CloseHandle(process.hThread);
|
||||||
|
CloseHandle(process.hProcess);
|
||||||
|
return;
|
||||||
|
}
|
||||||
switch (cmd)
|
switch (cmd)
|
||||||
{
|
{
|
||||||
case 24:
|
case 24:
|
||||||
|
|||||||
+1
-1
@@ -257,7 +257,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
{
|
{
|
||||||
auto replacement = settings.autoReplacements.find(small);
|
auto replacement = settings.autoReplacements.find(small);
|
||||||
if (replacement != settings.autoReplacements.end() &&
|
if (replacement != settings.autoReplacements.end() &&
|
||||||
replacement->second.find_first_not_of(alphabet) == small.npos)
|
replacement->second.find_first_not_of(alphabet + L" ") == small.npos)
|
||||||
return sameLayout(replacement->second);
|
return sameLayout(replacement->second);
|
||||||
}
|
}
|
||||||
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
|
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
|
||||||
|
|||||||
+4
-2
@@ -118,9 +118,11 @@ void validateSettings(const Settings &s)
|
|||||||
L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя"})
|
L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя"})
|
||||||
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
|
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
|
||||||
from == lower(from) && to == lower(to) && from != to &&
|
from == lower(from) && to == lower(to) && from != to &&
|
||||||
from.find_first_not_of(alphabet) == from.npos && to.find_first_not_of(alphabet) == to.npos;
|
from.find_first_not_of(alphabet) == from.npos &&
|
||||||
|
to.find_first_not_of(std::wstring(alphabet) + L" ") == to.npos &&
|
||||||
|
to.front() != L' ' && to.back() != L' ' && to.find(L" ") == to.npos;
|
||||||
if (!valid)
|
if (!valid)
|
||||||
throw std::runtime_error("Replacement pairs must be different lowercase words of 1-32 letters in the same language");
|
throw std::runtime_error("Use a lowercase typo of 1-32 letters and a different correction of 1-32 characters in the same language. Single spaces between correction words are allowed.");
|
||||||
}
|
}
|
||||||
for (const auto &[language, words] : s.autoWords)
|
for (const auto &[language, words] : s.autoWords)
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -1285,7 +1285,7 @@ void showAutoOptions(HWND parent, SettingsView *v)
|
|||||||
label(w, L"Enter punctuation without spaces or separators. These characters are kept.", 18, 530, 525);
|
label(w, L"Enter punctuation without spaces or separators. These characters are kept.", 18, 530, 525);
|
||||||
checkbox(w, L"Use custom &replacements (take priority over dictionaries)", 414, 18, 562, 524);
|
checkbox(w, L"Use custom &replacements (take priority over dictionaries)", 414, 18, 562, 524);
|
||||||
check(w, 414, v->draft.autoReplace);
|
check(w, 414, v->draft.autoReplace);
|
||||||
label(w, L"One pair per line: typo=correction (same language, letters only)", 18, 592, 525);
|
label(w, L"One pair per line: typo=correction (same language; correction may contain spaces)", 18, 592, 525);
|
||||||
edit(w, 416, 18, 618, 524, 70, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL | ES_WANTRETURN);
|
edit(w, 416, 18, 618, 524, 70, ES_MULTILINE | WS_VSCROLL | ES_AUTOVSCROLL | ES_WANTRETURN);
|
||||||
std::wstring pairs;
|
std::wstring pairs;
|
||||||
for (const auto &[from, to] : v->draft.autoReplacements) pairs += from + L"=" + to + L"\r\n";
|
for (const auto &[from, to] : v->draft.autoReplacements) pairs += from + L"=" + to + L"\r\n";
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.44";
|
inline constexpr wchar_t AppVersion[] = L"0.6.47";
|
||||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cwchar>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace ks
|
||||||
|
{
|
||||||
|
// Scan each time the menu opens, relative to the resident executable, not its working directory.
|
||||||
|
inline std::vector<std::filesystem::path> discoverUtilities(const std::filesystem::path &folder,
|
||||||
|
std::error_code &error)
|
||||||
|
{
|
||||||
|
error.clear();
|
||||||
|
std::vector<std::filesystem::path> result;
|
||||||
|
std::filesystem::directory_iterator it(folder, error), end;
|
||||||
|
if (error == std::errc::no_such_file_or_directory) error.clear();
|
||||||
|
while (!error && it != end)
|
||||||
|
{
|
||||||
|
std::error_code entryError;
|
||||||
|
if (_wcsicmp(it->path().extension().c_str(), L".exe") == 0 && it->is_regular_file(entryError))
|
||||||
|
result.push_back(it->path());
|
||||||
|
it.increment(error);
|
||||||
|
}
|
||||||
|
if (error) result.clear();
|
||||||
|
std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) {
|
||||||
|
return _wcsicmp(a.filename().c_str(), b.filename().c_str()) < 0;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
inline std::wstring utilityMenuLabel(const std::filesystem::path &path)
|
||||||
|
{
|
||||||
|
std::wstring label;
|
||||||
|
for (wchar_t ch : path.stem().wstring())
|
||||||
|
{
|
||||||
|
label += ch;
|
||||||
|
if (ch == L'&') label += ch; // Win32 uses ampersands for access keys.
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Explicit Windows integration test. Opens an isolated Far profile and never
|
||||||
|
// types into an existing Far window. Requires Far Manager at its default path.
|
||||||
|
#include "app.h"
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cwctype>
|
||||||
|
#include <fstream>
|
||||||
|
#include <future>
|
||||||
|
|
||||||
|
namespace ks {
|
||||||
|
struct InputRegressionAccess {
|
||||||
|
static void runQueued(App &app) {
|
||||||
|
std::function<void()> job;
|
||||||
|
{ std::lock_guard lock(app.jobsMutex);
|
||||||
|
if (app.jobs.empty()) throw std::runtime_error("snippet did not queue");
|
||||||
|
job=std::move(app.jobs.front());app.jobs.pop_front(); }
|
||||||
|
auto future=std::async(std::launch::async,[job=std::move(job)] {
|
||||||
|
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);job();CoUninitialize();
|
||||||
|
});
|
||||||
|
while(future.wait_for(std::chrono::milliseconds(5))!=std::future_status::ready) {
|
||||||
|
MSG message{};while(PeekMessageW(&message,nullptr,0,0,PM_REMOVE)) {
|
||||||
|
TranslateMessage(&message);DispatchMessageW(&message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
future.get();app.replayTyping();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int) {
|
||||||
|
auto base=std::filesystem::path(ks::executable()).parent_path();
|
||||||
|
std::ofstream log(base/L"far-snippets.log");
|
||||||
|
int checks=0;
|
||||||
|
auto require=[&](bool ok,const char *message) {
|
||||||
|
if(!ok)throw std::runtime_error(message);
|
||||||
|
++checks;log<<"PASS "<<message<<'\n';log.flush();
|
||||||
|
};
|
||||||
|
auto folder=std::filesystem::temp_directory_path()/(L"ks-far-snippets-"+ks::newSnippetId());
|
||||||
|
std::filesystem::create_directories(folder);
|
||||||
|
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||||
|
ks::Clip saved;bool captured=false;PROCESS_INFORMATION child{};
|
||||||
|
HANDLE input=INVALID_HANDLE_VALUE,output=INVALID_HANDLE_VALUE;
|
||||||
|
HWND farWindow{};
|
||||||
|
HWND owner=CreateWindowExW(0,L"STATIC",L"Far snippet fixture clipboard",0,0,0,0,0,HWND_MESSAGE,nullptr,instance,nullptr);
|
||||||
|
auto originalForeground=GetForegroundWindow();
|
||||||
|
int result=1;
|
||||||
|
try {
|
||||||
|
saved=ks::readClipboard(owner,128*1024*1024);captured=true;
|
||||||
|
auto sentinel=ks::textClip(L"KSwitcher Far fixture clipboard");
|
||||||
|
sentinel.formats.push_back({0,L"ExcludeClipboardContentFromMonitorProcessing",ks::Bytes{0}});
|
||||||
|
ks::writeClipboard(owner,sentinel);
|
||||||
|
const auto programFiles=_wgetenv(L"ProgramFiles");
|
||||||
|
if(!programFiles)throw std::runtime_error("ProgramFiles is unavailable");
|
||||||
|
auto farExecutable=std::filesystem::path(programFiles)/L"Far Manager/Far.exe";
|
||||||
|
std::wstring command=L"\""+farExecutable.wstring()+L"\" -m -p -s \""+folder.wstring()+L"\\profile\"";
|
||||||
|
// A GUID-length working directory can horizontally scroll Far's command
|
||||||
|
// line, making absolute cursor-cell assertions invalid. Keep the prompt
|
||||||
|
// short; profile and application data still live in the unique fixture.
|
||||||
|
auto workingDirectory=folder.root_path();
|
||||||
|
STARTUPINFOW startup{sizeof(startup)};startup.dwFlags=STARTF_USESHOWWINDOW;startup.wShowWindow=SW_HIDE;
|
||||||
|
require(CreateProcessW(nullptr,command.data(),nullptr,nullptr,FALSE,CREATE_NEW_CONSOLE,nullptr,workingDirectory.c_str(),&startup,&child),"start isolated Far without plugins/macros");
|
||||||
|
CloseHandle(child.hThread);Sleep(1800);
|
||||||
|
require(AttachConsole(child.dwProcessId),"attach isolated console for test-only input and observation");
|
||||||
|
input=CreateFileW(L"CONIN$",GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ|FILE_SHARE_WRITE,nullptr,OPEN_EXISTING,0,nullptr);
|
||||||
|
output=CreateFileW(L"CONOUT$",GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ|FILE_SHARE_WRITE,nullptr,OPEN_EXISTING,0,nullptr);
|
||||||
|
require(input!=INVALID_HANDLE_VALUE && output!=INVALID_HANDLE_VALUE,"open isolated console buffers");
|
||||||
|
auto target=GetConsoleWindow();farWindow=target;ShowWindow(target,SW_SHOW);
|
||||||
|
require(ks::activateWindow(target),"activate only the isolated Far window");
|
||||||
|
auto key=[&](WORD vk,wchar_t c=0,DWORD mods=0) {
|
||||||
|
INPUT_RECORD records[2]{};
|
||||||
|
for(int i=0;i<2;++i) {
|
||||||
|
auto &r=records[i];r.EventType=KEY_EVENT;
|
||||||
|
r.Event.KeyEvent={BOOL(i==0),1,vk,WORD(MapVirtualKeyW(vk,MAPVK_VK_TO_VSC)),{c},mods};
|
||||||
|
}
|
||||||
|
DWORD written{};
|
||||||
|
if(!WriteConsoleInputW(input,records,2,&written)||written!=2)throw std::runtime_error("test input failed");
|
||||||
|
Sleep(60);
|
||||||
|
};
|
||||||
|
auto position=[&] {CONSOLE_SCREEN_BUFFER_INFO info{};if(!GetConsoleScreenBufferInfo(output,&info))throw std::runtime_error("console position unavailable");return info.dwCursorPosition;};
|
||||||
|
auto row=[&] {
|
||||||
|
CONSOLE_SCREEN_BUFFER_INFO info{};GetConsoleScreenBufferInfo(output,&info);
|
||||||
|
std::wstring text(info.dwSize.X,L' ');DWORD count{};
|
||||||
|
if(!ReadConsoleOutputCharacterW(output,text.data(),DWORD(text.size()),{0,info.dwCursorPosition.Y},&count))throw std::runtime_error("console row unavailable");
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
{
|
||||||
|
ks::App app(instance,false,folder/L"data");app.window=owner;
|
||||||
|
app.settings.history=false;app.settings.autoCorrect=false;app.settings.writeLog=true;
|
||||||
|
auto prepare=[&](wchar_t last=L'r') {
|
||||||
|
if(!ks::activateWindow(target))throw std::runtime_error("isolated Far lost foreground");
|
||||||
|
key(VK_ESCAPE);key('Y',0,LEFT_CTRL_PRESSED);key(VK_OEM_1,L':',SHIFT_PRESSED);key(WORD(towupper(last)),last);
|
||||||
|
app.run.reset();app.run.window=target;app.run.layout=ks::windowLayout(target);
|
||||||
|
app.run.at=GetTickCount64();app.run.keys=L":r";
|
||||||
|
};
|
||||||
|
auto expand=[&](const std::wstring &replacement) {
|
||||||
|
app.settings.snippets={{L":r",replacement}};
|
||||||
|
app.transform(0,0);ks::InputRegressionAccess::runQueued(app);Sleep(120);
|
||||||
|
};
|
||||||
|
for(int panels=0;panels<2;++panels) {
|
||||||
|
prepare();auto before=position();auto original=row();expand(L"rider .");
|
||||||
|
auto expected=original;expected.replace(before.X-2,2,L"rider .");expected.resize(original.size());
|
||||||
|
// Far's rightmost scroll indicator is redrawn rather than shifted.
|
||||||
|
require(row().substr(0,before.X+5)==expected.substr(0,before.X+5),"actual snippet replaces :r with rider .");
|
||||||
|
require(position().X==before.X+5,"successful expansion leaves cursor after replacement");
|
||||||
|
require(ks::readClipboard(owner).text()==sentinel.text(),"snippet restores original clipboard");
|
||||||
|
prepare(L'x');before=position();original=row();expand(L"rider .{enter}");
|
||||||
|
require(row()==original && position().X==before.X,"mismatched trigger restores original caret and never executes Enter");
|
||||||
|
prepare();before=position();original=row();expand(L"left{cursor}right");
|
||||||
|
require(row().substr(before.X-2,9)==L"leftright" && position().X==before.X+2,"cursor marker uses Far selection semantics");
|
||||||
|
prepare();before=position();original=row();expand(L"first\nsecond{enter}");
|
||||||
|
require(row()==original && position().X==before.X,"multiline console template rejected before changing text or caret");
|
||||||
|
prepare();before=position();original=row();expand(L"echo KSWITCHER_FAR_FIXTURE{enter}");
|
||||||
|
for(int wait=0;wait<30 && position().X!=before.X-2;++wait)Sleep(100);
|
||||||
|
require(position().X==before.X-2 && row().substr(before.X-2,2)==L" ",
|
||||||
|
"verified Enter runs only the harmless fixture echo and returns to an empty command line");
|
||||||
|
key('O',0,LEFT_CTRL_PRESSED);
|
||||||
|
}
|
||||||
|
app.window=nullptr;
|
||||||
|
}
|
||||||
|
log<<"Passed "<<checks<<" Far snippet integration checks\n";result=0;
|
||||||
|
}catch(const std::exception &e){log<<"FAIL "<<e.what()<<" win32="<<GetLastError()<<'\n';}
|
||||||
|
// A real user or clipboard synchronizer can copy during this explicit test.
|
||||||
|
// Never replace that newer content with the fixture's original snapshot.
|
||||||
|
const auto restoreSequence=GetClipboardSequenceNumber();
|
||||||
|
const bool restoreClipboard=GetClipboardOwner()==owner || (farWindow && ks::ownsClipboard(farWindow));
|
||||||
|
if(input!=INVALID_HANDLE_VALUE)CloseHandle(input);
|
||||||
|
if(output!=INVALID_HANDLE_VALUE)CloseHandle(output);
|
||||||
|
FreeConsole();
|
||||||
|
if(child.hProcess){TerminateProcess(child.hProcess,0);WaitForSingleObject(child.hProcess,5000);CloseHandle(child.hProcess);}
|
||||||
|
if(captured && restoreClipboard && GetClipboardSequenceNumber()==restoreSequence)
|
||||||
|
try{ks::writeClipboard(owner,saved);}catch(...){result=1;}
|
||||||
|
DestroyWindow(owner);
|
||||||
|
if(IsWindow(originalForeground))ks::activateWindow(originalForeground);
|
||||||
|
CoUninitialize();return result;
|
||||||
|
}
|
||||||
+3
-3
@@ -124,7 +124,7 @@ int main(int argc, char **argv) {
|
|||||||
return result.state == L"current" || result.state == L"available" ? 0 : 1;
|
return result.state == L"current" || result.state == L"available" ? 0 : 1;
|
||||||
} catch (const std::exception &e) { std::cerr << e.what() << '\n'; return 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()));
|
auto temp=std::filesystem::temp_directory_path()/(L"KSwitcher-input-tests-"+ks::newSnippetId());
|
||||||
int checks=0;
|
int checks=0;
|
||||||
auto require=[&](bool ok,const char* text) { if(!ok) throw std::runtime_error(text); ++checks; };
|
auto require=[&](bool ok,const char* text) { if(!ok) throw std::runtime_error(text); ++checks; };
|
||||||
try {
|
try {
|
||||||
@@ -901,7 +901,7 @@ int main(int argc, char **argv) {
|
|||||||
"personal dictionary field fits options window");
|
"personal dictionary field fits options window");
|
||||||
}
|
}
|
||||||
SetDlgItemTextW(autoOptions,412,L"Релізик\r\nрелізик, словечко");
|
SetDlgItemTextW(autoOptions,412,L"Релізик\r\nрелізик, словечко");
|
||||||
SetDlgItemTextW(autoOptions,416,L" Првиіт = Привіт\r\nhelo=hello");
|
SetDlgItemTextW(autoOptions,416,L" Првиіт = Привіт\r\nhelo=hello\r\n Мамамила = Мама мила ");
|
||||||
SendDlgItemMessageW(autoOptions,414,BM_SETCHECK,BST_CHECKED,0);
|
SendDlgItemMessageW(autoOptions,414,BM_SETCHECK,BST_CHECKED,0);
|
||||||
SendDlgItemMessageW(autoOptions,415,BM_SETCHECK,BST_CHECKED,0);
|
SendDlgItemMessageW(autoOptions,415,BM_SETCHECK,BST_CHECKED,0);
|
||||||
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
|
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
|
||||||
@@ -923,7 +923,7 @@ int main(int argc, char **argv) {
|
|||||||
"personal dictionary normalizes case, newlines and duplicate words");
|
"personal dictionary normalizes case, newlines and duplicate words");
|
||||||
wchar_t pairs[128]{};
|
wchar_t pairs[128]{};
|
||||||
GetDlgItemTextW(autoOptions,416,pairs,128);
|
GetDlgItemTextW(autoOptions,416,pairs,128);
|
||||||
require(std::wstring(pairs)==L"helo=hello\r\nпрвиіт=привіт\r\n" &&
|
require(std::wstring(pairs)==L"helo=hello\r\nмамамила=мама мила\r\nпрвиіт=привіт\r\n" &&
|
||||||
SendDlgItemMessageW(autoOptions,414,BM_GETCHECK,0,0)==BST_CHECKED &&
|
SendDlgItemMessageW(autoOptions,414,BM_GETCHECK,0,0)==BST_CHECKED &&
|
||||||
SendDlgItemMessageW(autoOptions,415,BM_GETCHECK,0,0)==BST_CHECKED,
|
SendDlgItemMessageW(autoOptions,415,BM_GETCHECK,0,0)==BST_CHECKED,
|
||||||
"typo options and normalized replacement pairs survive reopening");
|
"typo options and normalized replacement pairs survive reopening");
|
||||||
|
|||||||
@@ -802,14 +802,14 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case 22:
|
case 22:
|
||||||
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
|
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
|
||||||
app->settings.autoReplace = true;
|
app->settings.autoReplace = true;
|
||||||
app->settings.autoReplacements = {{L"helo", L"hello"}};
|
app->settings.autoReplacements = {{L"helo", L"hello world"}};
|
||||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
prepare(L"(Helo)! ", false);
|
prepare(L"(Helo)! ", false);
|
||||||
record(L"(Helo)! ");
|
record(L"(Helo)! ");
|
||||||
app->transform(ks::AutoCorrect);
|
app->transform(ks::AutoCorrect);
|
||||||
break;
|
break;
|
||||||
case 23:
|
case 23:
|
||||||
require(text() == L"(Hello)! ", "explicit typo pair replaces verified text with punctuation and capital");
|
require(text() == L"(Hello world)! ", "explicit phrase pair replaces verified text with punctuation and capital");
|
||||||
app->transform(ks::Undo);
|
app->transform(ks::Undo);
|
||||||
break;
|
break;
|
||||||
case 24:
|
case 24:
|
||||||
@@ -835,7 +835,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case 27:
|
case 27:
|
||||||
require(text() == L"kswithcer ", "Undo suppresses the rejected spelling correction");
|
require(text() == L"kswithcer ", "Undo suppresses the rejected spelling correction");
|
||||||
app->settings.autoReplace = true;
|
app->settings.autoReplace = true;
|
||||||
app->settings.autoReplacements = {{L"tehh", L"the"}};
|
app->settings.autoReplacements = {{L"tehh", L"the word"}};
|
||||||
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||||
prepare(L"", false);
|
prepare(L"", false);
|
||||||
{
|
{
|
||||||
@@ -852,7 +852,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 28:
|
case 28:
|
||||||
require(text() == L"the x", "custom replacement through keyboard hook preserves subsequent typing");
|
require(text() == L"the word x", "custom phrase replacement through keyboard hook preserves subsequent typing");
|
||||||
app->settings.autoReplace = false;
|
app->settings.autoReplace = false;
|
||||||
app->settings.autoTypos = false;
|
app->settings.autoTypos = false;
|
||||||
if (typosOnly) { finish(); break; }
|
if (typosOnly) { finish(); break; }
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
param([Parameter(Mandatory=$true)][string]$Executable)
|
||||||
|
$ErrorActionPreference='Stop'
|
||||||
|
function Command([string]$Argument) {
|
||||||
|
$commandProcess=Start-Process -FilePath $Executable -ArgumentList $Argument -WindowStyle Hidden -PassThru
|
||||||
|
try {
|
||||||
|
$null=$commandProcess.Handle
|
||||||
|
$commandProcess.WaitForExit()
|
||||||
|
return $commandProcess.ExitCode
|
||||||
|
} finally { $commandProcess.Dispose() }
|
||||||
|
}
|
||||||
|
if ((Command '--status') -ne 3) { throw 'An isolated StayHere test instance is already running' }
|
||||||
|
$process=Start-Process -FilePath $Executable -WindowStyle Hidden -PassThru
|
||||||
|
$null=$process.Handle
|
||||||
|
try {
|
||||||
|
$ready=$false
|
||||||
|
for($i=0;$i -lt 20;$i++) {
|
||||||
|
if((Command '--status') -eq 1){$ready=$true;break}
|
||||||
|
Start-Sleep -Milliseconds 50
|
||||||
|
}
|
||||||
|
if(!$ready){throw 'StayHere did not start enabled'}
|
||||||
|
if((Command '--pause') -ne 0 -or (Command '--status') -ne 2){throw 'StayHere pause failed'}
|
||||||
|
if((Command '--resume') -ne 0 -or (Command '--status') -ne 1){throw 'StayHere resume failed'}
|
||||||
|
if((Command '--exit') -ne 0 -or !$process.WaitForExit(3000)){throw 'StayHere exit failed'}
|
||||||
|
if((Command '--status') -ne 3){throw 'StayHere instance remained after exit'}
|
||||||
|
Write-Output 'Passed isolated StayHere start/status/pause/resume/exit checks'
|
||||||
|
} finally {
|
||||||
|
if(!$process.HasExited){$null=Command '--exit';if(!$process.WaitForExit(3000)){$process.Kill()}}
|
||||||
|
$process.Dispose()
|
||||||
|
}
|
||||||
+14
-2
@@ -590,6 +590,12 @@ int main(int argc, char **argv)
|
|||||||
options.autoNever = {L"hlelo"};
|
options.autoNever = {L"hlelo"};
|
||||||
require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs");
|
require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs");
|
||||||
options.autoNever.clear();
|
options.autoNever.clear();
|
||||||
|
options.autoReplacements[L"мамамила"] = L"мама мила";
|
||||||
|
correction = edited.suggest(L"(Мамамила)! ", 2, layouts, options);
|
||||||
|
require(correction && correction->text == L"(Мама мила)! " && correction->target == 2,
|
||||||
|
"joined words split while preserving capitalization, punctuation and layout");
|
||||||
|
edited.reject(L"мамамила ", options);
|
||||||
|
require(!edited.suggest(L"мамамила ", 2, layouts, options), "undo suppresses phrase replacements");
|
||||||
edited.reject(L"(Hlelo)! ", options);
|
edited.reject(L"(Hlelo)! ", options);
|
||||||
require(!edited.suggest(L"hlelo ", 0, layouts, options), "undo rejection suppresses custom replacements");
|
require(!edited.suggest(L"hlelo ", 0, layouts, options), "undo rejection suppresses custom replacements");
|
||||||
options.autoReplace = false;
|
options.autoReplace = false;
|
||||||
@@ -711,7 +717,7 @@ int main(int argc, char **argv)
|
|||||||
settings.elevatedAutostart = true;
|
settings.elevatedAutostart = true;
|
||||||
settings.autoCorrect = true;
|
settings.autoCorrect = true;
|
||||||
settings.autoReplace = settings.autoTypos = true;
|
settings.autoReplace = settings.autoTypos = true;
|
||||||
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}};
|
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}, {L"мамамила", L"мама мила"}};
|
||||||
settings.autoMaxRank = 10000;
|
settings.autoMaxRank = 10000;
|
||||||
settings.autoMinLength = 5;
|
settings.autoMinLength = 5;
|
||||||
settings.autoPrefixChars = L"(#";
|
settings.autoPrefixChars = L"(#";
|
||||||
@@ -726,7 +732,13 @@ int main(int argc, char **argv)
|
|||||||
require(restored.autoWords == settings.autoWords, "personal dictionaries survive settings roundtrip");
|
require(restored.autoWords == settings.autoWords, "personal dictionaries survive settings roundtrip");
|
||||||
require(restored.autoReplace && restored.autoTypos && restored.autoReplacements == settings.autoReplacements,
|
require(restored.autoReplace && restored.autoTypos && restored.autoReplacements == settings.autoReplacements,
|
||||||
"typo options and Unicode pairs survive settings roundtrip");
|
"typo options and Unicode pairs survive settings roundtrip");
|
||||||
for (const auto &pair : {std::pair{L"hello", L"привіт"}, {L"hello", L"hello"}, {L"hi!", L"hello"}})
|
for (const auto &pair : {std::pair{L"hello", L"привіт"}, {L"hello", L"hello"}, {L"hi!", L"hello"},
|
||||||
|
{L"helloworld", L" hello world"}, {L"helloworld", L"hello world "},
|
||||||
|
{L"helloworld", L"hello world"}, {L"helloworld", L"hello\tworld"},
|
||||||
|
{L"helloworld", L"hello\nworld"}, {L"hello world", L"helloworld"},
|
||||||
|
{L"helloworld", L"hello світ"}, {L"helloworld", L"hello world!"},
|
||||||
|
{L"helloworld", L"hello Hello"}, {L"helloworld", L""},
|
||||||
|
{L"helloworld", L"abcdefghijklmnopqrstuvwxyz abcdef"}})
|
||||||
{
|
{
|
||||||
auto invalid = settings;
|
auto invalid = settings;
|
||||||
invalid.autoReplacements = {{pair.first, pair.second}};
|
invalid.autoReplacements = {{pair.first, pair.second}};
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ try {
|
|||||||
foreach ($name in @('LICENSE','json-LICENSE.txt','README.md')) { [IO.File]::WriteAllText((Join-Path $source $name),'fixture') }
|
foreach ($name in @('LICENSE','json-LICENSE.txt','README.md')) { [IO.File]::WriteAllText((Join-Path $source $name),'fixture') }
|
||||||
[IO.Directory]::CreateDirectory((Join-Path $source 'dictionaries')) | Out-Null
|
[IO.Directory]::CreateDirectory((Join-Path $source 'dictionaries')) | Out-Null
|
||||||
[IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary')
|
[IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary')
|
||||||
|
[IO.Directory]::CreateDirectory((Join-Path $source 'tools')) | Out-Null
|
||||||
|
[IO.File]::WriteAllText((Join-Path $source 'tools/README.txt'),'portable utilities instructions')
|
||||||
|
[IO.File]::WriteAllText((Join-Path $source 'tools/StayHere.exe'),'bundled StayHere fixture')
|
||||||
function Write-TestManifest($Folder) {
|
function Write-TestManifest($Folder) {
|
||||||
$entries=@(Get-ChildItem -LiteralPath $Folder -File -Recurse | Where-Object Name -ne 'release-files.json' | ForEach-Object {
|
$entries=@(Get-ChildItem -LiteralPath $Folder -File -Recurse | Where-Object Name -ne 'release-files.json' | ForEach-Object {
|
||||||
@{path=$_.FullName.Substring($Folder.Length+1).Replace('\','/');size=$_.Length;sha256=(Get-FileHash -LiteralPath $_.FullName).Hash}
|
@{path=$_.FullName.Substring($Folder.Length+1).Replace('\','/');size=$_.Length;sha256=(Get-FileHash -LiteralPath $_.FullName).Hash}
|
||||||
@@ -92,8 +95,18 @@ try {
|
|||||||
$null=$z.CreateEntry('undeclared.dll'); $z.Dispose()
|
$null=$z.CreateEntry('undeclared.dll'); $z.Dispose()
|
||||||
Reject { Expand-Release $extra (Join-Path $root 'extrastage') $binaryVersion } 'undeclared file rejected'
|
Reject { Expand-Release $extra (Join-Path $root 'extrastage') $binaryVersion } 'undeclared file rejected'
|
||||||
$target=Join-Path $root 'install'; $backup=Join-Path $root 'backup'
|
$target=Join-Path $root 'install'; $backup=Join-Path $root 'backup'
|
||||||
|
$freshTarget=Join-Path $root 'fresh-install'
|
||||||
|
[IO.Directory]::CreateDirectory($freshTarget) | Out-Null
|
||||||
|
$freshChanges=[Collections.Generic.List[string]]::new()
|
||||||
|
$freshStage=Join-Path $root 'fresh-stage'
|
||||||
|
Copy-Item -LiteralPath $stage -Destination $freshStage -Recurse
|
||||||
|
Install-Files $freshStage $freshTarget (Join-Path $root 'fresh-backup') $freshChanges
|
||||||
|
Assert (Test-Path -LiteralPath (Join-Path $freshTarget 'tools/README.txt')) 'update creates portable tools folder'
|
||||||
[IO.Directory]::CreateDirectory((Join-Path $target 'data')) | Out-Null
|
[IO.Directory]::CreateDirectory((Join-Path $target 'data')) | Out-Null
|
||||||
[IO.Directory]::CreateDirectory((Join-Path $target 'dictionaries')) | Out-Null
|
[IO.Directory]::CreateDirectory((Join-Path $target 'dictionaries')) | Out-Null
|
||||||
|
[IO.Directory]::CreateDirectory((Join-Path $target 'tools')) | Out-Null
|
||||||
|
[IO.File]::WriteAllText((Join-Path $target 'tools/Personal.exe'),'personal utility fixture')
|
||||||
|
[IO.File]::WriteAllText((Join-Path $target 'tools/StayHere.exe'),'older StayHere fixture')
|
||||||
[IO.File]::WriteAllText((Join-Path $target 'data/settings.json'),'personal settings')
|
[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 'dictionaries/en.txt'),'personal dictionary')
|
||||||
[IO.File]::WriteAllText((Join-Path $target 'KSwitcher.exe'),'old binary fixture')
|
[IO.File]::WriteAllText((Join-Path $target 'KSwitcher.exe'),'old binary fixture')
|
||||||
@@ -105,9 +118,14 @@ try {
|
|||||||
}
|
}
|
||||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'future-tool.exe')) -eq 'future companion fixture') 'new companion name installs without changing updater'
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'future-tool.exe')) -eq 'future companion fixture') 'new companion name installs without changing updater'
|
||||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'data/settings.json')) -eq 'personal settings') 'settings preserved'
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'data/settings.json')) -eq 'personal settings') 'settings preserved'
|
||||||
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/Personal.exe')) -eq 'personal utility fixture') 'personal utilities preserved'
|
||||||
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/README.txt')) -eq 'portable utilities instructions') 'bundled tools documentation installed'
|
||||||
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/StayHere.exe')) -eq 'bundled StayHere fixture') 'bundled StayHere updated'
|
||||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'dictionaries/en.txt')) -eq 'personal dictionary') 'dictionary preserved'
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'dictionaries/en.txt')) -eq 'personal dictionary') 'dictionary preserved'
|
||||||
Undo-Files $target $backup $changed
|
Undo-Files $target $backup $changed
|
||||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'rollback restores original'
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'rollback restores original'
|
||||||
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/Personal.exe')) -eq 'personal utility fixture') 'rollback preserves personal utilities'
|
||||||
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/StayHere.exe')) -eq 'older StayHere fixture') 'rollback restores previous StayHere'
|
||||||
Assert (!(Test-Path -LiteralPath (Join-Path $target 'README.md'))) 'rollback removes newly introduced files'
|
Assert (!(Test-Path -LiteralPath (Join-Path $target 'README.md'))) 'rollback removes newly introduced files'
|
||||||
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Config.exe'))) 'rollback removes newly introduced configurator'
|
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Config.exe'))) 'rollback removes newly introduced configurator'
|
||||||
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Screenshot.exe'))) 'rollback removes newly introduced screenshot editor'
|
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Screenshot.exe'))) 'rollback removes newly introduced screenshot editor'
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#include <windows.h>
|
||||||
|
#include "../src/utilities.h"
|
||||||
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
auto root = fs::temp_directory_path() / (L"KSwitcher-utilities-" + std::to_wstring(GetCurrentProcessId()));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
std::error_code error;
|
||||||
|
if (!ks::discoverUtilities(root / L"missing", error).empty() || error)
|
||||||
|
throw std::runtime_error("Missing folder must be empty, not an error");
|
||||||
|
fs::create_directories(root / L"tools" / L"nested");
|
||||||
|
fs::create_directory(root / L"tools" / L"directory.exe");
|
||||||
|
for (auto name : {L"Zed.exe", L"alpha.EXE", L"A & B.exe", L"readme.txt", L"shortcut.lnk"})
|
||||||
|
std::ofstream(root / L"tools" / name).put('x');
|
||||||
|
std::ofstream(root / L"tools" / L"nested" / L"hidden.exe").put('x');
|
||||||
|
auto found = ks::discoverUtilities(root / L"tools", error);
|
||||||
|
if (error || found.size() != 3 || found[0].filename() != L"A & B.exe" ||
|
||||||
|
found[1].filename() != L"alpha.EXE" || found[2].filename() != L"Zed.exe")
|
||||||
|
throw std::runtime_error("Scan must filter direct executable files and sort names");
|
||||||
|
if (ks::utilityMenuLabel(found[0]) != L"A && B")
|
||||||
|
throw std::runtime_error("Ampersand must be displayed literally");
|
||||||
|
fs::remove(found[1]);
|
||||||
|
if (ks::discoverUtilities(root / L"tools", error).size() != 2)
|
||||||
|
throw std::runtime_error("Scan must refresh after a file is removed");
|
||||||
|
ks::discoverUtilities(root / L"tools" / L"readme.txt", error);
|
||||||
|
if (!error) throw std::runtime_error("Non-directory must report an error");
|
||||||
|
fs::remove_all(root);
|
||||||
|
std::cout << "Utilities regression tests passed\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (const std::exception &e)
|
||||||
|
{
|
||||||
|
std::error_code ignored;
|
||||||
|
fs::remove_all(root, ignored);
|
||||||
|
std::cerr << e.what() << '\n';
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# StayHere
|
||||||
|
|
||||||
|
Маленька портативна утиліта для Windows, без .NET та інсталяції.
|
||||||
|
|
||||||
|
У KSwitcher v0.6.46 і новіших виберіть **Utilities → StayHere** у меню трея
|
||||||
|
або запустіть `tools/StayHere.exe` поруч із KSwitcher.exe.
|
||||||
|
Зелена крапка в треї означає, що підтримка активності ввімкнена.
|
||||||
|
Клацання лівою кнопкою перемикає режим; правою — відкриває меню з перемикачем і виходом.
|
||||||
|
Після кожного запуску режим увімкнений. Автозапуск не додається.
|
||||||
|
|
||||||
|
Раз на 15 секунд перевіряється час бездіяльності. Після 3 хвилин без вводу
|
||||||
|
надсилається натискання й відпускання F15. Поки ви працюєте, клавіші не надсилаються.
|
||||||
|
Курсор миші не рухається. Увімкнений режим також утримує екран і систему від
|
||||||
|
автоматичного сну. На заблокованому екрані та у відключеному сеансі активність
|
||||||
|
не імітується. Ручне блокування Windows не обходиться.
|
||||||
|
|
||||||
|
Механізм перенесено за поведінкою з Typlo. Він підтримує системну активність,
|
||||||
|
але не керує статусом Teams через API: статус залежить також від Teams,
|
||||||
|
календаря, інших пристроїв та політик організації. F15 може мати власне
|
||||||
|
призначення в окремих програмах. На підвищених вікнах Windows може блокувати ввід;
|
||||||
|
тоді підказка іконки показує помилку. Утиліта не запитує права адміністратора.
|
||||||
|
|
||||||
|
## Збірка
|
||||||
|
|
||||||
|
У репозиторії KSwitcher виконайте `./build.ps1 -Stage` з кореня проєкту.
|
||||||
|
Утиліта збирається з `utilities/StayHere/main.cpp` у `build/staged/tools/StayHere.exe`
|
||||||
|
і включається пакувальником у реліз. CMake також має ціль `StayHere`.
|
||||||
|
Для запуску достатньо одного `StayHere.exe`; .NET не потрібний.
|
||||||
|
|
||||||
|
CLI: `--pause`, `--resume`, `--exit` керують запущеним екземпляром.
|
||||||
|
`--status` повертає код 1 для активного режиму, 2 для паузи, 3 якщо утиліта не запущена.
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
#include <windows.h>
|
||||||
|
#include <shellapi.h>
|
||||||
|
#include <wtsapi32.h>
|
||||||
|
#include <cwchar>
|
||||||
|
#include <initializer_list>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
#ifdef STAYHERE_TEST_INSTANCE
|
||||||
|
constexpr wchar_t ClassName[] = L"StayHere.Test.Tray.1";
|
||||||
|
constexpr wchar_t MutexName[] = L"Local\\StayHere.Test.Tray.1";
|
||||||
|
#else
|
||||||
|
constexpr wchar_t ClassName[] = L"StayHere.Tray.1";
|
||||||
|
constexpr wchar_t MutexName[] = L"Local\\StayHere.Tray.1";
|
||||||
|
#endif
|
||||||
|
constexpr UINT TrayMessage = WM_APP + 1;
|
||||||
|
constexpr UINT ControlMessage = WM_APP + 2;
|
||||||
|
constexpr UINT Toggle = 1, Quit = 2, Pause = 3, Resume = 4, Status = 5;
|
||||||
|
constexpr DWORD IdleThreshold = 180000;
|
||||||
|
bool enabled = true;
|
||||||
|
bool sessionLocked = false;
|
||||||
|
bool powerOk = true;
|
||||||
|
bool inputOk = true;
|
||||||
|
UINT taskbarCreated;
|
||||||
|
HICON activeIcon, pausedIcon;
|
||||||
|
NOTIFYICONDATAW tray{};
|
||||||
|
|
||||||
|
constexpr bool shouldSignal(bool active, bool unlocked, DWORD now, DWORD lastInput) {
|
||||||
|
return active && unlocked && DWORD(now - lastInput) >= IdleThreshold;
|
||||||
|
}
|
||||||
|
static_assert(!shouldSignal(true, true, 179999, 0));
|
||||||
|
static_assert(shouldSignal(true, true, 180000, 0));
|
||||||
|
static_assert(!shouldSignal(false, true, 180000, 0));
|
||||||
|
static_assert(!shouldSignal(true, false, 180000, 0));
|
||||||
|
static_assert(shouldSignal(true, true, 179000, 0xfffffc18)); // tick counter wrap
|
||||||
|
|
||||||
|
bool interactiveDesktop() {
|
||||||
|
if (sessionLocked) return false;
|
||||||
|
HDESK desktop = OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS);
|
||||||
|
if (!desktop) return false;
|
||||||
|
wchar_t name[128]{};
|
||||||
|
DWORD needed = 0;
|
||||||
|
bool ok = GetUserObjectInformationW(desktop, UOI_NAME, name, sizeof(name), &needed)
|
||||||
|
&& _wcsicmp(name, L"Default") == 0;
|
||||||
|
CloseDesktop(desktop);
|
||||||
|
if (!ok) return false;
|
||||||
|
DWORD session;
|
||||||
|
LPWSTR buffer = nullptr;
|
||||||
|
DWORD bytes = 0;
|
||||||
|
if (!ProcessIdToSessionId(GetCurrentProcessId(), &session)) return false;
|
||||||
|
if (!WTSQuerySessionInformationW(WTS_CURRENT_SERVER_HANDLE, session, WTSConnectState, &buffer, &bytes)) return false;
|
||||||
|
ok = bytes >= sizeof(WTS_CONNECTSTATE_CLASS)
|
||||||
|
&& *reinterpret_cast<WTS_CONNECTSTATE_CLASS*>(buffer) == WTSActive;
|
||||||
|
WTSFreeMemory(buffer);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
HICON makeIcon(COLORREF color) {
|
||||||
|
HDC screen = GetDC(nullptr);
|
||||||
|
HDC dc = CreateCompatibleDC(screen);
|
||||||
|
HBITMAP bitmap = CreateCompatibleBitmap(screen, 32, 32);
|
||||||
|
HGDIOBJ previous = SelectObject(dc, bitmap);
|
||||||
|
RECT rect{0, 0, 32, 32};
|
||||||
|
FillRect(dc, &rect, static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH)));
|
||||||
|
HBRUSH brush = CreateSolidBrush(color);
|
||||||
|
HGDIOBJ oldBrush = SelectObject(dc, brush);
|
||||||
|
HGDIOBJ oldPen = SelectObject(dc, GetStockObject(NULL_PEN));
|
||||||
|
Ellipse(dc, 3, 3, 29, 29);
|
||||||
|
SelectObject(dc, oldBrush);
|
||||||
|
SelectObject(dc, oldPen);
|
||||||
|
DeleteObject(brush);
|
||||||
|
SelectObject(dc, previous);
|
||||||
|
HBITMAP mask = CreateBitmap(32, 32, 1, 1, nullptr);
|
||||||
|
HGDIOBJ maskPrevious = SelectObject(dc, mask);
|
||||||
|
PatBlt(dc, 0, 0, 32, 32, WHITENESS);
|
||||||
|
oldBrush = SelectObject(dc, GetStockObject(BLACK_BRUSH));
|
||||||
|
oldPen = SelectObject(dc, GetStockObject(NULL_PEN));
|
||||||
|
Ellipse(dc, 3, 3, 29, 29);
|
||||||
|
SelectObject(dc, oldPen);
|
||||||
|
SelectObject(dc, oldBrush);
|
||||||
|
SelectObject(dc, maskPrevious);
|
||||||
|
ICONINFO info{TRUE, 0, 0, mask, bitmap};
|
||||||
|
HICON icon = CreateIconIndirect(&info);
|
||||||
|
DeleteObject(bitmap);
|
||||||
|
DeleteObject(mask);
|
||||||
|
DeleteDC(dc);
|
||||||
|
ReleaseDC(nullptr, screen);
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateTray() {
|
||||||
|
tray.hIcon = enabled ? activeIcon : pausedIcon;
|
||||||
|
const wchar_t* tip = !enabled ? L"StayHere — призупинено"
|
||||||
|
: !powerOk ? L"StayHere — помилка утримання від сну"
|
||||||
|
: !inputOk ? L"StayHere — не вдалося надіслати F15"
|
||||||
|
: L"StayHere — активно (F15 після 3 хв простою)";
|
||||||
|
lstrcpynW(tray.szTip, tip, ARRAYSIZE(tray.szTip));
|
||||||
|
Shell_NotifyIconW(NIM_MODIFY, &tray);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setEnabled(bool value) {
|
||||||
|
enabled = value;
|
||||||
|
inputOk = true;
|
||||||
|
powerOk = SetThreadExecutionState(ES_CONTINUOUS |
|
||||||
|
(enabled && interactiveDesktop() ? ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED : 0)) != 0;
|
||||||
|
updateTray();
|
||||||
|
}
|
||||||
|
|
||||||
|
void tick() {
|
||||||
|
const bool unlocked = interactiveDesktop();
|
||||||
|
powerOk = SetThreadExecutionState(ES_CONTINUOUS |
|
||||||
|
(enabled && unlocked ? ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED : 0)) != 0;
|
||||||
|
LASTINPUTINFO idle{sizeof(idle), 0};
|
||||||
|
if (GetLastInputInfo(&idle) && shouldSignal(enabled, unlocked, GetTickCount(), idle.dwTime)) {
|
||||||
|
// Avoid inserting a key into a chord being held while the user is away.
|
||||||
|
for (int key : {VK_SHIFT, VK_CONTROL, VK_MENU, VK_LWIN, VK_RWIN,
|
||||||
|
VK_LBUTTON, VK_RBUTTON, VK_MBUTTON}) {
|
||||||
|
if (GetAsyncKeyState(key) & 0x8000) return;
|
||||||
|
}
|
||||||
|
INPUT input[2]{};
|
||||||
|
input[0].type = input[1].type = INPUT_KEYBOARD;
|
||||||
|
input[0].ki.wVk = input[1].ki.wVk = VK_F15;
|
||||||
|
input[1].ki.dwFlags = KEYEVENTF_KEYUP;
|
||||||
|
inputOk = SendInput(2, input, sizeof(INPUT)) == 2;
|
||||||
|
if (!inputOk) { // If only key-down succeeded, make a best effort to release it.
|
||||||
|
SendInput(1, &input[1], sizeof(INPUT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateTray();
|
||||||
|
}
|
||||||
|
|
||||||
|
void showMenu(HWND window) {
|
||||||
|
HMENU menu = CreatePopupMenu();
|
||||||
|
AppendMenuW(menu, MF_STRING | (enabled ? MF_CHECKED : 0), Toggle, L"Підтримувати активність");
|
||||||
|
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||||
|
AppendMenuW(menu, MF_STRING, Quit, L"Вийти");
|
||||||
|
POINT pos;
|
||||||
|
GetCursorPos(&pos);
|
||||||
|
SetForegroundWindow(window);
|
||||||
|
UINT command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON, pos.x, pos.y, 0, window, nullptr);
|
||||||
|
DestroyMenu(menu);
|
||||||
|
if (command) SendMessageW(window, ControlMessage, command, 0);
|
||||||
|
PostMessageW(window, WM_NULL, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
LRESULT CALLBACK windowProc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
|
||||||
|
if (message == taskbarCreated) {
|
||||||
|
Shell_NotifyIconW(NIM_ADD, &tray);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
switch (message) {
|
||||||
|
case TrayMessage:
|
||||||
|
if (lp == WM_RBUTTONUP || lp == WM_CONTEXTMENU) showMenu(window);
|
||||||
|
else if (lp == WM_LBUTTONUP) setEnabled(!enabled);
|
||||||
|
return 0;
|
||||||
|
case ControlMessage:
|
||||||
|
if (wp == Status) return enabled ? 1 : 2;
|
||||||
|
if (wp == Toggle) setEnabled(!enabled);
|
||||||
|
else if (wp == Pause) setEnabled(false);
|
||||||
|
else if (wp == Resume) setEnabled(true);
|
||||||
|
else if (wp == Quit) DestroyWindow(window);
|
||||||
|
return 0;
|
||||||
|
case WM_TIMER: tick(); return 0;
|
||||||
|
case WM_WTSSESSION_CHANGE:
|
||||||
|
if (wp == WTS_SESSION_LOCK || wp == WTS_REMOTE_DISCONNECT || wp == WTS_CONSOLE_DISCONNECT)
|
||||||
|
sessionLocked = true;
|
||||||
|
else if (wp == WTS_SESSION_UNLOCK || wp == WTS_REMOTE_CONNECT || wp == WTS_CONSOLE_CONNECT)
|
||||||
|
sessionLocked = false;
|
||||||
|
setEnabled(enabled);
|
||||||
|
return 0;
|
||||||
|
case WM_DESTROY:
|
||||||
|
KillTimer(window, 1);
|
||||||
|
WTSUnRegisterSessionNotification(window);
|
||||||
|
SetThreadExecutionState(ES_CONTINUOUS);
|
||||||
|
Shell_NotifyIconW(NIM_DELETE, &tray);
|
||||||
|
PostQuitMessage(0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return DefWindowProcW(window, message, wp, lp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int) {
|
||||||
|
int argc{};
|
||||||
|
auto argv=CommandLineToArgvW(GetCommandLineW(),&argc);
|
||||||
|
if(!argv)return 10;
|
||||||
|
const std::wstring argument=argc==2?argv[1]:L"";
|
||||||
|
LocalFree(argv);
|
||||||
|
if(argc>2)return 10;
|
||||||
|
const auto commandLine=argument.c_str();
|
||||||
|
UINT command = 0;
|
||||||
|
if (wcscmp(commandLine, L"--pause") == 0) command = Pause;
|
||||||
|
else if (wcscmp(commandLine, L"--resume") == 0) command = Resume;
|
||||||
|
else if (wcscmp(commandLine, L"--exit") == 0) command = Quit;
|
||||||
|
else if (wcscmp(commandLine, L"--status") == 0) command = Status;
|
||||||
|
else if (*commandLine) return 10;
|
||||||
|
HWND existing = FindWindowW(ClassName, nullptr);
|
||||||
|
if (command) {
|
||||||
|
if (!existing) return 3;
|
||||||
|
DWORD_PTR result = 0;
|
||||||
|
if (!SendMessageTimeoutW(existing, ControlMessage, command, 0, SMTO_ABORTIFHUNG, 2000, &result)) return 4;
|
||||||
|
return command == Status ? static_cast<int>(result) : 0;
|
||||||
|
}
|
||||||
|
HANDLE mutex = CreateMutexW(nullptr, FALSE, MutexName);
|
||||||
|
if (!mutex) return 5;
|
||||||
|
if (GetLastError() == ERROR_ALREADY_EXISTS) { CloseHandle(mutex); return 0; }
|
||||||
|
taskbarCreated = RegisterWindowMessageW(L"TaskbarCreated");
|
||||||
|
activeIcon = makeIcon(RGB(40, 185, 100));
|
||||||
|
pausedIcon = makeIcon(RGB(135, 140, 150));
|
||||||
|
WNDCLASSW wc{};
|
||||||
|
wc.lpfnWndProc = windowProc;
|
||||||
|
wc.hInstance = instance;
|
||||||
|
wc.lpszClassName = ClassName;
|
||||||
|
if (!RegisterClassW(&wc)) return 6;
|
||||||
|
HWND window = CreateWindowExW(0, ClassName, L"StayHere", WS_OVERLAPPED,
|
||||||
|
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
|
||||||
|
if (!window) return 7;
|
||||||
|
tray.cbSize = sizeof(tray);
|
||||||
|
tray.hWnd = window;
|
||||||
|
tray.uID = 1;
|
||||||
|
tray.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
|
||||||
|
tray.uCallbackMessage = TrayMessage;
|
||||||
|
tray.hIcon = activeIcon;
|
||||||
|
lstrcpyW(tray.szTip, L"StayHere");
|
||||||
|
if (!Shell_NotifyIconW(NIM_ADD, &tray)) { DestroyWindow(window); return 8; }
|
||||||
|
WTSRegisterSessionNotification(window, NOTIFY_FOR_THIS_SESSION);
|
||||||
|
setEnabled(true);
|
||||||
|
if (!SetTimer(window, 1, 15000, nullptr)) { DestroyWindow(window); return 9; }
|
||||||
|
MSG msg{};
|
||||||
|
while (GetMessageW(&msg, nullptr, 0, 0) > 0) {
|
||||||
|
TranslateMessage(&msg);
|
||||||
|
DispatchMessageW(&msg);
|
||||||
|
}
|
||||||
|
DestroyIcon(activeIcon);
|
||||||
|
DestroyIcon(pausedIcon);
|
||||||
|
CloseHandle(mutex);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user