Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c78b51d341 | |||
| 0184b837f4 | |||
| ca553a5e7d | |||
| 55993c0f90 | |||
| 738c81dce2 | |||
| 22edece7cf | |||
| 908985d775 | |||
| 20f3b72287 | |||
| 9b231146cb | |||
| c0ab02df16 | |||
| 64a04b4382 | |||
| 4c5275631c | |||
| 34e07e7107 | |||
| 70ea9b10bc | |||
| 6603d28f68 | |||
| 0d3b2acae9 | |||
| 4eb9f6c671 | |||
| a830f55575 | |||
| 07e31228d7 | |||
| 39d3876fd2 | |||
| 4cdcc92eb7 | |||
| 77f27ea087 | |||
| c326db7dc5 | |||
| 8b5f632ad7 | |||
| 5c786e12a9 | |||
| 107d2f8852 | |||
| 95c7199027 | |||
| 8fedb54dfe | |||
| bbf20e8270 | |||
| b8bb14a985 | |||
| d28e28c03c | |||
| 6b180798f5 | |||
| 21a09e33be | |||
| 3dbd7317f1 | |||
| 510d0ed211 | |||
| dd9629f8fd | |||
| ccaa8dad3f | |||
| d1eabfca88 |
@@ -0,0 +1,35 @@
|
|||||||
|
# Project instructions
|
||||||
|
|
||||||
|
## Mandatory Wiki updates for every release
|
||||||
|
|
||||||
|
A release is not complete until the published project Wiki reflects that release.
|
||||||
|
Apply this rule to every release, including patch releases and bug-fix-only releases.
|
||||||
|
|
||||||
|
Wiki: https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki
|
||||||
|
|
||||||
|
1. Confirm the release version from `CMakeLists.txt`, the release tag and
|
||||||
|
`Release/README.md`. Use the complete version (for example, `v0.6.17`) and
|
||||||
|
preserve the release channel rules in `README.md`.
|
||||||
|
2. Update the latest-version information on the Wiki home pages and any other
|
||||||
|
pages identifying the current release. Update release dates, download links
|
||||||
|
and upgrade instructions where needed. Keep historical version references
|
||||||
|
and compatibility bridge instructions when they are still accurate.
|
||||||
|
3. Review the changes since the last release documented in the Wiki, not just
|
||||||
|
the immediately preceding release. Bring the relevant feature pages up to
|
||||||
|
date with the shipped behavior: new, changed and removed functionality,
|
||||||
|
settings, shortcuts, defaults, limitations and troubleshooting. Update
|
||||||
|
screenshots or examples when they no longer match the application.
|
||||||
|
For bug-fix-only releases, check whether documented behavior or workarounds
|
||||||
|
need correction; do not invent feature changes.
|
||||||
|
4. Keep English and Ukrainian pages synchronized, including `Home` and
|
||||||
|
`Home-uk`, and preserve working translation and navigation links.
|
||||||
|
5. Publish the Wiki changes as part of the release workflow. Read back the
|
||||||
|
published pages and verify the current version, feature descriptions,
|
||||||
|
download links and translation links. A local Wiki edit alone is not enough.
|
||||||
|
6. In the release completion report, link the updated Wiki pages and summarize
|
||||||
|
the documentation changes. If Wiki publication or verification is blocked,
|
||||||
|
explicitly report the outstanding step and do not claim the release workflow
|
||||||
|
is complete.
|
||||||
|
|
||||||
|
Updating only the release notes or changing only the Wiki version number does
|
||||||
|
not satisfy this rule; the current functionality must also be reviewed.
|
||||||
+32
-10
@@ -1,15 +1,15 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.4.0 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.6.30 LANGUAGES CXX RC)
|
||||||
if(NOT WIN32)
|
if(NOT WIN32)
|
||||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||||
endif()
|
endif()
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.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)
|
||||||
target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
target_link_libraries(kscore PUBLIC winhttp user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||||
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
|
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
|
||||||
target_link_libraries(KSwitcher PRIVATE kscore)
|
target_link_libraries(KSwitcher PRIVATE kscore)
|
||||||
add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
@@ -17,41 +17,63 @@ set_target_properties(KSwitcherConfig PROPERTIES OUTPUT_NAME "KSwitcher.Config")
|
|||||||
target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
|
target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
|
||||||
target_link_libraries(KSwitcherConfig PRIVATE kscore)
|
target_link_libraries(KSwitcherConfig PRIVATE kscore)
|
||||||
add_executable(KSwitcherTests tests/tests.cpp)
|
add_executable(KSwitcherTests tests/tests.cpp)
|
||||||
|
add_executable(KSwitcherSyncTests tests/sync.cpp)
|
||||||
|
target_include_directories(KSwitcherSyncTests PRIVATE vendor)
|
||||||
|
target_link_libraries(KSwitcherSyncTests PRIVATE kscore)
|
||||||
|
if(MSVC)
|
||||||
|
set_property(TARGET KSwitcherSyncTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
|
else()
|
||||||
|
target_link_options(KSwitcherSyncTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
|
endif()
|
||||||
|
add_executable(KSwitcherScrollTests tests/scroll.cpp)
|
||||||
|
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/app.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)
|
||||||
add_executable(KSwitcherInputTests tests/input.cpp src/app.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)
|
||||||
add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp src/app.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES)
|
||||||
|
add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp resources/app.rc)
|
||||||
target_link_libraries(KSwitcherScreenshotUITests PRIVATE kscore)
|
target_link_libraries(KSwitcherScreenshotUITests PRIVATE kscore)
|
||||||
|
target_compile_definitions(KSwitcherScreenshotUITests PRIVATE KS_SCREENSHOT_RESOURCES)
|
||||||
|
add_executable(KSwitcherScreenshot WIN32 src/screenshot_main.cpp src/screenshot_notifications.cpp src/screenshot_activation.cpp src/screenshot.cpp resources/app.rc)
|
||||||
|
set_target_properties(KSwitcherScreenshot PROPERTIES OUTPUT_NAME "KSwitcher.Screenshot")
|
||||||
|
target_compile_definitions(KSwitcherScreenshot PRIVATE KS_SCREENSHOT_RESOURCES)
|
||||||
|
target_link_libraries(KSwitcherScreenshot PRIVATE kscore)
|
||||||
if(MSVC)
|
if(MSVC)
|
||||||
target_compile_options(KSwitcherScreenshotUITests PRIVATE /utf-8)
|
target_compile_options(KSwitcherScreenshotUITests PRIVATE /utf-8)
|
||||||
set_property(TARGET KSwitcherScreenshotUITests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
set_property(TARGET KSwitcherScreenshotUITests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
else()
|
else()
|
||||||
target_link_options(KSwitcherScreenshotUITests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherScreenshotUITests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
endif()
|
endif()
|
||||||
add_executable(KSwitcherOcrTests tests/ocr.cpp)
|
add_executable(KSwitcherOcrTests tests/ocr.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(KSwitcherOcrTests PRIVATE kscore)
|
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
|
||||||
if(MSVC)
|
if(MSVC)
|
||||||
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherScrollTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
target_compile_options(kscore PRIVATE /W4 /utf-8)
|
target_compile_options(kscore PRIVATE /W4 /utf-8)
|
||||||
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
|
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
|
||||||
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
|
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
|
||||||
|
target_compile_options(KSwitcherScreenshot PRIVATE /W4 /utf-8)
|
||||||
else()
|
else()
|
||||||
target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||||
|
target_link_options(KSwitcherScreenshot PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
|
target_link_options(KSwitcherScrollTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
endif()
|
endif()
|
||||||
enable_testing()
|
enable_testing()
|
||||||
|
add_test(NAME sync-regressions COMMAND KSwitcherSyncTests)
|
||||||
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
||||||
|
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)
|
||||||
|
add_test(NAME screenshot-save-regressions COMMAND KSwitcherScreenshotUITests --save-only)
|
||||||
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
|
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
|
||||||
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||||
install(TARGETS KSwitcher KSwitcherConfig RUNTIME DESTINATION .)
|
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .)
|
||||||
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
|
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
|
||||||
|
|
||||||
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
|
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ Native C++20 / Win32 rewrite of the Windows functionality of [Typlo](https://git
|
|||||||
No .NET, Avalonia, WebView, Qt, external graphics engine, or separately installed C++ runtime.
|
No .NET, Avalonia, WebView, Qt, external graphics engine, or separately installed C++ runtime.
|
||||||
The original [Typlo](https://github.com/bitcodepro/Typlo) project and its data remain separate.
|
The original [Typlo](https://github.com/bitcodepro/Typlo) project and its data remain separate.
|
||||||
|
|
||||||
**[User guide / Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki)** — setup, features, shortcuts, dictionaries, screenshots and troubleshooting (Ukrainian).
|
**[User guide / Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki)** — setup, features, shortcuts, dictionaries, screenshots and troubleshooting in **English** and **[Українська](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Home-uk)**. Each wiki page links to its translation.
|
||||||
|
|
||||||
|
[](https://send.monobank.ua/jar/88h2TddKfS)
|
||||||
|
|
||||||
|
[Donate via Monobank](https://send.monobank.ua/jar/88h2TddKfS) · Thank you for supporting KSwitcher.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -12,16 +16,34 @@ Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the e
|
|||||||
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
|
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
|
||||||
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
|
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
|
||||||
Double-click the tray icon to open Settings, the bold default menu item.
|
Double-click the tray icon to open Settings, the bold default menu item.
|
||||||
Settings and the snippet editor run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
Menus support access keys: press the underlined letter while the menu is open (for example,
|
||||||
|
S for Settings or X for Exit in the tray menu). In forms, press Alt+the underlined letter
|
||||||
|
to activate a button, toggle a checkbox or focus a labeled field. Press Alt to reveal
|
||||||
|
the underlines when Windows hides them. Shortcut-capture fields still record Alt combinations;
|
||||||
|
press Tab to leave a shortcut field before using form access keys.
|
||||||
|
Settings, the snippet editor and log viewer run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
||||||
Repeated launches activate the existing configurator; closing its last window exits that process.
|
Repeated launches activate the existing configurator; closing its last window exits that process.
|
||||||
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
|
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
|
||||||
load clipboard history, dictionaries or global input hooks. Clipboard history and the log viewer remain
|
load clipboard history, dictionaries or global input hooks. Clipboard history remains
|
||||||
in the resident app. Keep both executables together when copying or updating the application.
|
in the resident app. Screen capture and all screenshot editors run on demand in
|
||||||
|
**KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same
|
||||||
|
process. Closing the last editor (or cancelling the only selection) exits that process. It does not
|
||||||
|
load resident settings, dictionaries, clipboard history or keyboard hooks. Editor windows have
|
||||||
|
stable numbered titles within that process. Keep all three executables together when copying or
|
||||||
|
updating the application. Close screenshot editors before installing an update.
|
||||||
If settings change in the tray while the configurator is open, a conflicting Save is rejected; reopen
|
If settings change in the tray while the configurator is open, a conflicting Save is rejected; reopen
|
||||||
the configurator to load the latest settings.
|
the configurator to load the latest settings.
|
||||||
The first upgrade from v0.2.3 or earlier to this two-executable build requires manual extraction:
|
Updates from v0.3.x–v0.4.1 first install the compatible **v0.4.2 bridge** through
|
||||||
the older updater rejects additional executable files. Exit KSwitcher, replace both executables,
|
Update in the tray menu. Select Update again to install the full current release;
|
||||||
and retain `data/` and customized dictionaries. Subsequent updates support both executables.
|
manual extraction is not required. Future full releases use stable `release-vX.Y.Z`
|
||||||
|
tags, which the bridge and newer clients recognize. Older clients ignore those tags
|
||||||
|
and continue to find the bridge instead of skipping it. Versions v0.2.3 and earlier
|
||||||
|
still require the previously documented initial configurator migration.
|
||||||
|
|
||||||
|
Modern ZIPs contain `release-files.json` (schema, version, file paths, sizes and SHA-256
|
||||||
|
hashes). The updater validates that every packaged file is declared, all hashes match,
|
||||||
|
and paths stay within the installation directory. `data/` is protected; existing
|
||||||
|
dictionaries are preserved. Adding a new program file no longer needs a new updater.
|
||||||
In Settings, enable **Start with Windows** and **Start with Windows as administrator**
|
In Settings, enable **Start with Windows** and **Start with Windows as administrator**
|
||||||
to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing
|
to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing
|
||||||
the elevated task requests UAC approval if the app is not already elevated. No password is stored;
|
the elevated task requests UAC approval if the app is not already elevated. No password is stored;
|
||||||
@@ -58,9 +80,33 @@ physical key positions and therefore reset recent-word tracking; selection conve
|
|||||||
Press **Win+Shift+S**, choose **Capture screen region…** in the tray menu, or run
|
Press **Win+Shift+S**, choose **Capture screen region…** in the tray menu, or run
|
||||||
`KSwitcher.exe --capture`. Drag a rectangle on the dimmed, frozen desktop; the selected area stays
|
`KSwitcher.exe --capture`. Drag a rectangle on the dimmed, frozen desktop; the selected area stays
|
||||||
at normal brightness. Escape cancels. Selection and annotation frames are buffered to avoid flicker.
|
at normal brightness. Escape cancels. Selection and annotation frames are buffered to avoid flicker.
|
||||||
The editor opens with the selected area. Press **Ctrl+C** or **Enter** to copy it immediately,
|
The selected area is copied immediately, the fullscreen selection is hidden, and a Windows notification
|
||||||
or annotate first: **N** places sequential numbered red markers with a click, and **A** draws
|
is sent. Click the notification to open the editor. Enable **Open editor after screen capture** in settings
|
||||||
red arrows by dragging from the tail to the tip. Click an existing marker or arrow to select it;
|
to open it automatically; this option is off by default. An immediate notification error still opens the editor.
|
||||||
|
Existing saved preferences are preserved. Windows Do Not Disturb can send notifications directly to the
|
||||||
|
notification center without showing a banner.
|
||||||
|
The preference applies to each new capture, including while other editors are open.
|
||||||
|
KSwitcher also sends a notification with a thumbnail. Notification identity and activation are registered
|
||||||
|
at resident startup and refreshed before sending; registration/submission errors are written to the log.
|
||||||
|
Click the notification to reopen the original screenshot, including after the screenshot process has exited.
|
||||||
|
Originals in the local notification cache are DPAPI-encrypted. Thumbnail PNGs live in a separate
|
||||||
|
per-user cache under ProgramData, readable only by that user and SYSTEM, so the Windows notification
|
||||||
|
broker can load them even when the profile directory is inaccessible to it.
|
||||||
|
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
|
||||||
|
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
|
||||||
|
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
|
||||||
|
**Save… / Ctrl+S** saves the full-resolution image and annotations as PNG. The first save asks
|
||||||
|
for a file; later saves update that file. **Ctrl+Shift+S (Save As…)** always asks for a new
|
||||||
|
destination. Saving keeps the editor open and leaves the clipboard unchanged. Further edits
|
||||||
|
must be saved or copied again before closing without a warning.
|
||||||
|
The editor starts with **Pencil / Freehand (F)** for drawing. Choose **Pointer (V)** to select objects, move them, and edit geometry, thickness and
|
||||||
|
other properties using handles and controls. Clicking empty space clears selection; dragging empty
|
||||||
|
space adds nothing. Choose a drawing tool to create objects, then return to **V** to edit them.
|
||||||
|
The cursor identifies the active tool: pointer, text caret, pencil, or a crosshair with the drawing
|
||||||
|
tool symbol. Move/resize/thickness/rotation handles override it with their action cursor. Custom
|
||||||
|
cursors scale with display DPI and use a contrasting outline for light and dark images.
|
||||||
|
To annotate: **N** places sequential numbered red markers with a click, and **A** draws
|
||||||
|
red arrows by dragging from the tail to the tip. With **Pointer (V)**, click an existing marker or arrow to select it;
|
||||||
drag its body to move it. Drag the square handle on a number's right edge to change its diameter
|
drag its body to move it. Drag the square handle on a number's right edge to change its diameter
|
||||||
(the text scales with it), or either endpoint handle of an arrow to change its length and direction.
|
(the text scales with it), or either endpoint handle of an arrow to change its length and direction.
|
||||||
Select an arrow and drag the extra handle beside its midpoint perpendicular to the line to change
|
Select an arrow and drag the extra handle beside its midpoint perpendicular to the line to change
|
||||||
@@ -70,6 +116,14 @@ change its diameter. Wheel up increases the size; wheel down decreases it. These
|
|||||||
and update linked arrows. **Ctrl+D** clones the selected object with a small offset. A cloned number
|
and update linked arrows. **Ctrl+D** clones the selected object with a small offset. A cloned number
|
||||||
gets the smallest available positive number, just like a newly placed number: gaps left by deletion
|
gets the smallest available positive number, just like a newly placed number: gaps left by deletion
|
||||||
are filled first, without renumbering existing objects. A cloned arrow keeps its style but starts without endpoint links.
|
are filled first, without renumbering existing objects. A cloned arrow keeps its style but starts without endpoint links.
|
||||||
|
Arrow and line endpoints can attach to numbered markers, rectangles, circles, ellipses, triangles, trapezoids, text blocks,
|
||||||
|
and other arrows or strokes. In Arrow or Straight line mode, hold **Ctrl** and drag from one object
|
||||||
|
to another to draw over them and automatically attach both endpoints. Without Ctrl, existing objects
|
||||||
|
can still be selected and edited as before. With Pointer, drag an existing endpoint onto a target to attach it. Attached endpoints follow movement and resizing; shape and text attachments
|
||||||
|
also follow rotation. Arrow-to-arrow attachments retain their relative position along the target line.
|
||||||
|
Clicking a connector or adding/editing its caption preserves its links. Moving the whole connector
|
||||||
|
intentionally detaches its endpoints; moving one endpoint detaches only that end. Deleting a target
|
||||||
|
leaves connected endpoints at their last position. Self-links and circular dependency chains are rejected.
|
||||||
The compact toolbar uses [Lucide](https://lucide.dev) icons with hover tooltips showing each action
|
The compact toolbar uses [Lucide](https://lucide.dev) icons with hover tooltips showing each action
|
||||||
and its keyboard shortcut.
|
and its keyboard shortcut.
|
||||||
Drop either arrow endpoint onto a number to link it to the circle's edge. Linked endpoints follow
|
Drop either arrow endpoint onto a number to link it to the circle's edge. Linked endpoints follow
|
||||||
@@ -98,6 +152,8 @@ The mouse wheel over text adjusts its font size. Text supports color, dragging,
|
|||||||
Delete and Undo/Redo. Arrow keys move the selected object by one source-image pixel;
|
Delete and Undo/Redo. Arrow keys move the selected object by one source-image pixel;
|
||||||
selecting an arrow caption moves only that caption. Repositioning a caption preserves its
|
selecting an arrow caption moves only that caption. Repositioning a caption preserves its
|
||||||
automatic angle; only dragging the rotation handle switches to a fixed angle.
|
automatic angle; only dragging the rotation handle switches to a fixed angle.
|
||||||
|
New standalone text starts horizontally, even after editing an angled arrow caption. Font styling
|
||||||
|
is reused; rotation belongs to each individual object.
|
||||||
Text has a rotation handle above its selection. Drag to rotate; hold Shift to snap to 15-degree steps.
|
Text has a rotation handle above its selection. Drag to rotate; hold Shift to snap to 15-degree steps.
|
||||||
Double-click an arrow, press F2, or use the label icon to add/edit its caption. Captions follow
|
Double-click an arrow, press F2, or use the label icon to add/edit its caption. Captions follow
|
||||||
the arrow when moved or resized; drag the caption to adjust its offset. By default, the caption
|
the arrow when moved or resized; drag the caption to adjust its offset. By default, the caption
|
||||||
@@ -111,17 +167,51 @@ resize; circles keep equal width and height. Move shapes by their outlines, adju
|
|||||||
the mouse wheel or side handle, recolor, clone with Ctrl+D, and undo/redo as with other objects.
|
the mouse wheel or side handle, recolor, clone with Ctrl+D, and undo/redo as with other objects.
|
||||||
Hold Ctrl to start drawing over an existing object. Line style applies to outlines; Ends is disabled
|
Hold Ctrl to start drawing over an existing object. Line style applies to outlines; Ends is disabled
|
||||||
for shapes.
|
for shapes.
|
||||||
|
**Canvas zoom:** use **Ctrl++ / Ctrl+-** (including numpad keys) or **Ctrl+mouse wheel**.
|
||||||
|
Each step changes the view by 25%; the wheel zooms around the cursor, keyboard shortcuts around
|
||||||
|
the viewport center. **Ctrl+0** restores the initial fit. Drag with the **middle mouse button**
|
||||||
|
to pan an enlarged image. Zoom ranges from 0.1× to 16× the initial fit; the status bar shows the
|
||||||
|
actual image scale. Zoom and pan do not change annotations, export resolution or undo history.
|
||||||
|
The wheel without Ctrl still adjusts the object under the cursor.
|
||||||
|
|
||||||
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
|
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
|
||||||
|
after mouse release, nearly straight strokes become editable straight lines at their drawn angle,
|
||||||
|
preserving endpoints, color, thickness, line style and arrowheads. Short strokes, broad curves and
|
||||||
|
retraced strokes are not straightened. Other strokes receive light smoothing to reduce jitter.
|
||||||
|
Confidently recognized closed outlines become
|
||||||
|
rectangles, circles, ellipses, triangles or trapezoids (including tilted outlines), stored as shape objects.
|
||||||
|
Drag either bounding-box corner handle to resize the entire recognized outline; circles keep equal
|
||||||
|
width and height. Shapes can also be moved, recolored and cloned.
|
||||||
|
Every shape has a round rotation handle above its outline. Drag it to rotate around the center;
|
||||||
|
hold **Shift** for 15-degree steps. The handle works for toolbar shapes and recognized shapes,
|
||||||
|
including triangles. Resize handles and attached connectors follow the rotation; Ctrl+Z/Ctrl+Y
|
||||||
|
undo and redo it. The square handle still controls outline thickness.
|
||||||
|
**Triangle (G)** is also available directly on the toolbar: drag to create a triangle.
|
||||||
|
Both drawn and recognized triangles have three independent vertex handles instead of bounding-box
|
||||||
|
resize handles. Drag any vertex to reshape the triangle while keeping the other two fixed, even
|
||||||
|
after rotation. Vertex edits support undo/redo, and the rotation and thickness handles remain available.
|
||||||
|
**Trapezoid (B)** on the toolbar creates a trapezoid with parallel bases. Pencil recognition also
|
||||||
|
fits nearly parallel, unequal bases into a clean trapezoid. Trapezoids support bounding-box resizing,
|
||||||
|
rotation, styling and undo/redo; Ctrl+Z after recognition restores the original pencil stroke.
|
||||||
|
Oval fitting tolerates uneven contours and small closing gaps or overlaps; broad open arcs and
|
||||||
|
repeated loops remain freehand strokes.
|
||||||
|
Press **Ctrl+Z** once to restore the original stroke, again to remove it; **Ctrl+Y** reapplies the improvement.
|
||||||
|
Small strokes, strokes linked to other objects, and any stroke drawn partly with Shift are left unchanged.
|
||||||
|
Ambiguous shapes are only smoothed. To edit the resulting curve,
|
||||||
move it, change its color or thickness, clone it, or drag its endpoints. Hold Ctrl to start a new
|
move it, change its color or thickness, clone it, or drag its endpoints. Hold Ctrl to start a new
|
||||||
stroke over an existing object. The **Line style** selector supports Solid, Dotted, Dashed and
|
stroke over an existing object. Hold **Shift** while drawing to constrain the current segment
|
||||||
|
to a horizontal, vertical or 45-degree diagonal line; release Shift to resume freehand drawing in the same stroke.
|
||||||
|
While holding Shift, moving more than 50 image pixels away from the current line selects a new
|
||||||
|
direction at the last drawn endpoint, preserving earlier segments. Shift can be pressed and
|
||||||
|
released repeatedly within one stroke. The **Line style** selector supports Solid, Dotted, Dashed and
|
||||||
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
|
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
|
||||||
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
|
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
|
||||||
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
|
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
|
||||||
The window close button asks for confirmation before discarding an uncopied screenshot.
|
The window close button asks for confirmation before discarding changes that have not been saved or copied.
|
||||||
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy closes the editor and publishes the image
|
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy keeps the editor open and publishes the image
|
||||||
at its original pixel size, ready to paste. The preview fits the window without changing export size.
|
at its original pixel size, ready to paste. The preview fits the window without changing export size.
|
||||||
The shortcut is configurable in Settings under **Screen capture**.
|
The shortcut is configurable in Settings under **Screen capture**.
|
||||||
Each capture opens an independent editor; use the shortcut again to take another screenshot while
|
Each capture opens an independent editor available through Alt+Tab; use the shortcut again to take another screenshot while
|
||||||
keeping earlier editors and their annotations open.
|
keeping earlier editors and their annotations open.
|
||||||
Captures use ordinary clipboard history settings and limits. Protected video and secure desktops cannot be captured.
|
Captures use ordinary clipboard history settings and limits. Protected video and secure desktops cannot be captured.
|
||||||
|
|
||||||
@@ -129,8 +219,23 @@ Captures use ordinary clipboard history settings and limits. Protected video and
|
|||||||
|
|
||||||
Select **Update** in the tray menu to check the stable Releases of
|
Select **Update** in the tray menu to check the stable Releases of
|
||||||
[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases).
|
[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases).
|
||||||
A startup check also reports an available version, but never installs it automatically.
|
A background check runs shortly after startup and every 6 hours while KSwitcher is running.
|
||||||
Confirm the offered version to download and install it. The updater uses the existing
|
It reports each available version once per session and never installs automatically.
|
||||||
|
Update availability uses the same Windows toast notification channel as screenshot notifications.
|
||||||
|
Click the notification or **Review update** to check the latest release and confirm installation;
|
||||||
|
**Later** dismisses it. Windows Do Not Disturb may send it directly to Notification Center.
|
||||||
|
Failed notification submissions are logged and retried after five minutes instead of being marked
|
||||||
|
as delivered. Check results and successful submissions are recorded in the log when logging is enabled.
|
||||||
|
Network failures stay in diagnostics; the next scheduled check retries. You can also check manually at any time.
|
||||||
|
Confirm the offered version to download and install it. A progress window shows
|
||||||
|
download percentage and received/total size, package verification,
|
||||||
|
installation and restart. After the new process confirms startup, that window shows the installed
|
||||||
|
version and a Done button; it does not send a separate completion notification. If installation
|
||||||
|
fails after KSwitcher closes, the same window shows recovery and the error. Background checks
|
||||||
|
remain silent unless a new version is available. This progress UI is supplied by the updater in
|
||||||
|
the currently installed build, so it becomes available for subsequent updates after installing it.
|
||||||
|
|
||||||
|
The updater uses the existing
|
||||||
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
|
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
|
||||||
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
|
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
|
||||||
before the application exits. If replacement or startup fails, it restores the previous files;
|
before the application exits. If replacement or startup fails, it restores the previous files;
|
||||||
@@ -148,19 +253,37 @@ controls and fonts. Settings has a fixed form size, without manual resizing or m
|
|||||||
utility windows remain resizable. Windows stay within a remaining monitor's work area after display
|
utility windows remain resizable. Windows stay within a remaining monitor's work area after display
|
||||||
changes and provide scrollbars when their content does not fit. Tab navigation scrolls the focused
|
changes and provide scrollbars when their content does not fit. Tab navigation scrolls the focused
|
||||||
field into view; Shift+wheel scrolls horizontally.
|
field into view; Shift+wheel scrolls horizontally.
|
||||||
|
Clipboard history also updates its preview row heights when DPI changes, keeping text,
|
||||||
|
thumbnails and source labels aligned with the scaled fonts and spacing.
|
||||||
|
|
||||||
## Implemented
|
## Implemented
|
||||||
|
|
||||||
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
|
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
|
||||||
|
Text conversion cycles skip layouts that leave the text unchanged and use the next enabled layout
|
||||||
|
that changes it. If none changes the text, the operation keeps the text and advances one layout.
|
||||||
|
Partial copied suffixes can be reselected with paced keys; replacement still requires verification
|
||||||
|
of the complete expected text. Browser/editor compatibility depends on its handling of simulated input.
|
||||||
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary).
|
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary).
|
||||||
- Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix.
|
- Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix.
|
||||||
- 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.
|
||||||
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
||||||
|
A silently dropped copy is retried up to three total attempts while the clipboard and input
|
||||||
|
context remain unchanged. Temporary transaction contents are excluded from Windows clipboard
|
||||||
|
history and cloud synchronization. Paste is not blindly retried: with manual paste readback
|
||||||
|
enabled by Write log, a native Windows EDIT gets at most one retry when readback reports the
|
||||||
|
original and fresh native queries confirm that exactly the original range is still selected.
|
||||||
|
Other controls and unknown/changed selections are not retried. A verified failed paste clears
|
||||||
|
repeat-conversion state and does not switch the Windows layout.
|
||||||
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
||||||
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
||||||
`{{cursor}}` inserts the literal marker. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
|
`{enter}` presses Enter after insertion and cursor placement (once per marker), before buffered typing resumes.
|
||||||
|
For example, `rider .{enter}` inserts `rider .` and presses Enter.
|
||||||
|
Automatic correction is suppressed during snippet expansion and for typing buffered by that expansion; the global setting is preserved.
|
||||||
|
If the trigger cannot be verified after the copy attempts, it is preserved, queued typing resumes,
|
||||||
|
and no Enter action is sent.
|
||||||
|
`{{cursor}}` and `{{enter}}` insert literal markers. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
|
||||||
- Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste;
|
- Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste;
|
||||||
full-text search, pinning, deletion, full text viewing and image previews.
|
full-text search, pinning, deletion, full text viewing and image previews.
|
||||||
- Image thumbnails and text/file icons appear directly in the history list. Only visible rows
|
- Image thumbnails and text/file icons appear directly in the history list. Only visible rows
|
||||||
@@ -172,19 +295,46 @@ field into view; Shift+wheel scrolls horizontally.
|
|||||||
stylesheet rules are not reproduced; preview text uses the list's colors for readability.
|
stylesheet rules are not reproduced; preview text uses the list's colors for readability.
|
||||||
- Consecutive screenshot copies are compared by image content, ignoring changing OLE transport
|
- Consecutive screenshot copies are compared by image content, ignoring changing OLE transport
|
||||||
metadata (including Snipping Tool's repeated clipboard publication).
|
metadata (including Snipping Tool's repeated clipboard publication).
|
||||||
- Windows OCR, entirely native and offline. Right-click an image in history and select
|
- New images are recognized automatically in a separate background worker using native, offline
|
||||||
**Copy text from image**. Requires a Windows OCR language installed for the user profile.
|
Windows OCR. Recognized text is saved in a per-user DPAPI-encrypted sidecar and included in search,
|
||||||
|
without changing the image or clipboard. Completed results (including empty text) are reused;
|
||||||
|
pending work resumes after restart. Older images without OCR are processed once in the background,
|
||||||
|
starting with the newest entries.
|
||||||
|
Right-click an image and select **Copy text from image** to copy the saved text or explicitly
|
||||||
|
retry recognition. Requires a Windows OCR language installed for the user profile; automatic
|
||||||
|
failures are logged quietly. OCR sidecars count toward the history size limit and are removed
|
||||||
|
with their images.
|
||||||
|
- Optional Tesseract 5 support is available in **Settings → OCR options…**. Windows OCR remains
|
||||||
|
the default and has no Tesseract dependency. Install the 64-bit Tesseract 5 Windows package
|
||||||
|
separately, then enable it and choose language codes such as `ukr+eng+rus`. Leave the executable
|
||||||
|
path blank to detect a standard installation, or select `tesseract.exe`; its neighboring
|
||||||
|
`libtesseract-5.dll`, dependencies and `tessdata` language files are required. **Check installation**
|
||||||
|
validates the selected models. **Download Tesseract…** opens the Windows installer page in your
|
||||||
|
browser. No packages or models are downloaded automatically.
|
||||||
|
Tesseract processes screenshots locally in memory with a 30-second recognition deadline.
|
||||||
|
Changing engine, executable path or languages refreshes cached image text in the background.
|
||||||
- History exclusion flags and excluded processes, pause/resume, count/size limits.
|
- History exclusion flags and excluded processes, pause/resume, count/size limits.
|
||||||
- Per-user Windows DPAPI encryption for both history payloads and the preview index.
|
- Per-user Windows DPAPI encryption for both history payloads and the preview index.
|
||||||
- Autostart, keep-screen-awake, idle-only F15 activity signal, settings import/export.
|
- Autostart, keep-screen-awake, idle-only F15 activity signal, settings import/export.
|
||||||
- Single instance and `--history`, `--convert`, `--case`, `--transliterate`, `--settings`,
|
- Single instance and `--history`, `--convert`, `--case`, `--transliterate`, `--settings`,
|
||||||
`--snippets`, `--exit` commands. `--convert` cycles layouts.
|
`--snippets`, `--exit` commands. `--convert` cycles layouts.
|
||||||
|
|
||||||
Default shortcuts match [Typlo](https://github.com/bitcodepro/Typlo): Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
|
Default text shortcuts match [Typlo](https://github.com/bitcodepro/Typlo): Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
|
||||||
Z (undo), 1/2/3 (first layouts), and Ctrl+Shift+V (history).
|
Z (undo), and 1/2/3 (first layouts). Clipboard history defaults to **Win+V** for new settings;
|
||||||
|
existing saved shortcuts are preserved. While KSwitcher runs, this binding opens its history instead of Windows clipboard history.
|
||||||
|
|
||||||
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
|
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
|
||||||
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
|
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
|
||||||
|
|
||||||
|
**Clear history… / Alt+H** in the history window clears all saved entries after confirmation,
|
||||||
|
including pinned entries and entries hidden by search. No is the default choice. The current
|
||||||
|
Windows clipboard and future recording are unchanged. Settings also retains its Clear history action.
|
||||||
|
For an image, **Edit image**, **Ctrl+O**, or **Edit image in screenshot editor**
|
||||||
|
in the context menu opens the original image in the built-in screenshot editor.
|
||||||
|
Annotate it there, then copy the result. Opening the editor leaves the
|
||||||
|
clipboard and original history entry unchanged. Text items still open as text;
|
||||||
|
Enter and double-click still paste the selected item.
|
||||||
|
|
||||||
Pressing the history shortcut a second time pastes the second-most-recent item.
|
Pressing the history shortcut a second time pastes the second-most-recent item.
|
||||||
|
|
||||||
## Data and migration
|
## Data and migration
|
||||||
@@ -194,25 +344,45 @@ the executable's directory is not writable. Each clipboard payload is an encrypt
|
|||||||
only bounded previews/metadata stay in memory. Image data is loaded on demand and released when
|
only bounded previews/metadata stay in memory. Image data is loaded on demand and released when
|
||||||
the history popup is hidden. Settings **Write log** (`writeLog`, enabled by default) controls all
|
the history popup is hidden. Settings **Write log** (`writeLog`, enabled by default) controls all
|
||||||
log writes. Disabling it also clears diagnostic context from memory; existing log files remain.
|
log writes. Disabling it also clears diagnostic context from memory; existing log files remain.
|
||||||
Successful operations stay in a memory buffer of at most 40 diagnostic events for 30 seconds.
|
Diagnostic stages stay in a memory buffer of at most 128 events for 30 seconds.
|
||||||
Only failures flush this context to `kswitcher.log`; flushed
|
Selection diagnostics include the expected text, each copied candidate before and after suffix
|
||||||
context is discarded. Ordinary application errors are also logged when logging is enabled.
|
recovery, paced Shift+Left progress or batch delivery counts, and clipboard sequence changes,
|
||||||
|
retry counts and elapsed times. Focus, caret geometry and keyboard layout are recorded when
|
||||||
|
Windows exposes them; selection offsets are available for native EDIT controls and explicitly
|
||||||
|
marked unavailable for other editors. Input interruptions identify keyboard, mouse, settings
|
||||||
|
or layout-refresh events during an operation. Failed selection verification includes the expected
|
||||||
|
selection and actual copied text in the operation details, without implying that a paste occurred.
|
||||||
|
Only problem operations write a structured summary to `kswitcher.log`, with their preceding
|
||||||
|
context. Successful operations stay in that bounded memory buffer and are included only when
|
||||||
|
a subsequent problem flushes it; they do not create separate rows. Flushed context is discarded. Ordinary application
|
||||||
|
errors are also logged when logging is enabled. This works in normal Release builds.
|
||||||
Incident context contains tracked physical input, the expected word, copied text, conversion result,
|
Incident context contains tracked physical input, the expected word, copied text, conversion result,
|
||||||
and layout IDs, correlated by request ID even when verification fails. Text fields use quoted,
|
and layout IDs, correlated by request ID even when verification fails. Text fields use quoted,
|
||||||
escaped UTF-16 (`\uXXXX` for non-ASCII and control characters) for exact reproduction, with
|
escaped UTF-16 (`\uXXXX` for non-ASCII and control characters) for exact reproduction, with
|
||||||
original length and a truncation flag; each field is limited to 512 UTF-16 code units.
|
original length and a truncation flag; context fields are limited to 512 UTF-16 code units.
|
||||||
|
Structured summaries retain up to 4096 UTF-16 code units per text field and mark truncation.
|
||||||
Manual conversion attempts in `kswitcher.log` have request IDs and explicit outcomes, including
|
Manual conversion attempts in `kswitcher.log` have request IDs and explicit outcomes, including
|
||||||
busy, empty copy, verification mismatch, unchanged conversion, and cancelled paste. Requests
|
busy, empty copy, verification mismatch, unchanged conversion, and cancelled paste. Requests
|
||||||
record the process, window, input version, and repeated attempts within ten seconds without new
|
record the process, window, input version, and repeated attempts within ten seconds without new
|
||||||
input. Successful repeated commands do not flush the log; intentional layout cycling is normal.
|
input. An unchanged manual conversion is recorded as a problem even when the detected source
|
||||||
An unchanged result containing no letters, or a direct command
|
and target layouts coincide. After a changed result is pasted, read-only Windows Accessibility
|
||||||
to the detected current layout, is treated as expected. `paste-sent-unverified` means Windows
|
checks the focused editor without moving the caret, changing selection, or copying again.
|
||||||
accepted the paste shortcut; it does not prove that the editor applied the replacement.
|
The log distinguishes **Paste verified**, **Original text remained**, **Readback differs**, and
|
||||||
|
**Paste not verified**. Unsupported/protected controls, changed focus, and readback limits are
|
||||||
|
explicit verification reasons, never proof of success. Readback is bounded to 4096 characters;
|
||||||
|
provider-specific character units or delayed editor updates may prevent verification.
|
||||||
|
With logging disabled, this additional accessibility check is skipped.
|
||||||
The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates.
|
The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates.
|
||||||
Open **View log…** from Settings or the tray menu to read logs inside KSwitcher.
|
Open **View log…** from Settings or the tray menu to read logs inside KSwitcher.
|
||||||
|
The viewer runs in the configurator process so log loading, painting and pointer interaction do not
|
||||||
|
block the resident keyboard/mouse hook thread. Clear requests are still serialized by the resident.
|
||||||
Choose the current or previous log and press **Refresh** to reload. The ListView shows Time,
|
Choose the current or previous log and press **Refresh** to reload. The ListView shows Time,
|
||||||
Attempt, Event, and Details columns. Select rows and use **Copy selected** or Ctrl+C to copy
|
Program, Command, Source layout, Target layout, and Result. Click column headings to sort;
|
||||||
tab-separated entries; Ctrl+A selects all rows. Viewing saved logs also works when logging is disabled.
|
filter by program, command, or result, or enable **Only problems**. **Reset filters** restores all entries.
|
||||||
|
Select a row to inspect original, expected and read-back text, verification status and stages
|
||||||
|
in the panel below. **Copy selected** or Ctrl+C copies the selected rows with their details;
|
||||||
|
Ctrl+A selects all rows. Older unstructured log entries remain readable. Viewing saved logs
|
||||||
|
also works when logging is disabled.
|
||||||
**Clear log** clears only the selected current or previous file and refreshes the table.
|
**Clear log** clears only the selected current or previous file and refreshes the table.
|
||||||
Clearing the current log also discards buffered diagnostic context; new problems can still be logged.
|
Clearing the current log also discards buffered diagnostic context; new problems can still be logged.
|
||||||
|
|
||||||
@@ -233,6 +403,119 @@ This is a working native implementation, not a claim of complete UI/release pari
|
|||||||
The Windows focus/injection rules still apply: an unelevated process cannot edit an elevated app.
|
The Windows focus/injection rules still apply: an unelevated process cannot edit an elevated app.
|
||||||
Conversion is cancelled rather than pasting into a different window or replacing unverified recent text.
|
Conversion is cancelled rather than pasting into a different window or replacing unverified recent text.
|
||||||
|
|
||||||
|
## OpenBao synchronization (experimental, opt-in)
|
||||||
|
|
||||||
|
The **Sync...** button is available in Settings for everyone. Synchronization remains disabled
|
||||||
|
until you configure your own OpenBao and enable it. Connection configuration is saved separately
|
||||||
|
in `sync.json`. The portable data directory is normally `data/` beside the executable; installations
|
||||||
|
without a writable application directory use the existing local-app-data fallback.
|
||||||
|
No server address or account is included in the application.
|
||||||
|
|
||||||
|
The first provider is **OpenBao KV v2**. The dialog accepts an HTTPS server origin, username,
|
||||||
|
password, KV mount (default `kswitcher`), profile path (default `personal/settings`) and userpass
|
||||||
|
authentication mount (default `userpass`). Use the same profile on each of your PCs. This is
|
||||||
|
administrator-provisioned private access, not public account registration. Non-root namespaces,
|
||||||
|
reverse-proxy URL prefixes, MFA login flows and other providers are not implemented yet.
|
||||||
|
|
||||||
|
Enable synchronization and choose **Save / Sign in / Sync**. The dialog saves connection changes
|
||||||
|
immediately; the main Settings window's Save/Cancel does not control this separate configuration.
|
||||||
|
When synchronization is disabled, connection fields and sync actions are inactive. Check **Enable
|
||||||
|
synchronization** to make them available. To disable an existing connection, uncheck it and click
|
||||||
|
**Save**; this preserves the saved connection and password without starting a sync request.
|
||||||
|
**Disconnect / forget password** remains available while synchronization is disabled.
|
||||||
|
A blank password keeps the existing Windows credential for that server, login mount and username.
|
||||||
|
The password is saved in Windows Credential Manager, not in settings exports, logs or the remote
|
||||||
|
profile. HTTPS certificate validation is mandatory and redirects are refused. Login tokens are
|
||||||
|
kept in memory for one sync attempt, with best-effort self-revocation afterwards. Configure bounded
|
||||||
|
token lifetimes on the server as well (for example a 5-minute default and 10-minute maximum).
|
||||||
|
**Disconnect / forget password** disables synchronization and removes the current connection's
|
||||||
|
saved credential. A request already sent to the server may still complete; its downloaded settings
|
||||||
|
will not be applied after the connection changes. Old credentials for other connection identities
|
||||||
|
can be removed in Windows Credential Manager under `KSwitcher.Sync/`.
|
||||||
|
|
||||||
|
On the first connection, **Save / Sign in / Sync** merges the existing profiles without
|
||||||
|
requiring a whole-profile replacement. Independent entries (such as snippets, exclusions and
|
||||||
|
replacement pairs) are combined. Different values for the same setting remain on their respective
|
||||||
|
sides and appear in **Resolve individual conflicts...**; no value is guessed without a common history.
|
||||||
|
Nonconflicting changes synchronize immediately, and unresolved conflicts survive subsequent syncs.
|
||||||
|
|
||||||
|
- **Upload local** creates the remote profile or replaces it with this PC's saved shared settings.
|
||||||
|
- **Download remote** replaces this PC's shared settings with the server profile.
|
||||||
|
- If the server has no profile yet, choose **Upload local** to create it.
|
||||||
|
- Unsaved edits in Settings are not synchronized. Close and reopen Settings after a download;
|
||||||
|
the existing stale-editor check prevents an old Settings window overwriting downloaded changes.
|
||||||
|
|
||||||
|
Once connected, the resident checks at startup, every **10 minutes**, and about two seconds after saving settings, on a separate
|
||||||
|
network thread. Errors back off for 15 minutes; the Sync button allows a manual retry. Local use
|
||||||
|
continues offline. Independent changes to different settings fields are merged against the last
|
||||||
|
successful baseline. **Resolve individual conflicts...** shows the name and local/server values
|
||||||
|
for each conflict. Choose either value for selected settings and apply your choices; unselected
|
||||||
|
conflicts stay pending. Other, nonconflicting changes continue to synchronize. Choices are checked
|
||||||
|
against freshly read values before writing, so a stale dialog cannot overwrite a newer edit.
|
||||||
|
Conflicting shortcuts are separate settings, including bindings for individual keyboard layouts.
|
||||||
|
Exclusions and personal dictionary words merge by membership. Ordered layout/dictionary lists
|
||||||
|
remain single settings because their ordering affects behavior.
|
||||||
|
|
||||||
|
Snippets have persistent IDs that survive renaming. Different snippets merge independently;
|
||||||
|
trigger and text edits within a snippet can merge too. Deleting a snippet retains a deletion
|
||||||
|
marker, so an unchanged offline copy cannot bring it back. Deletion versus a concurrent edit
|
||||||
|
is an explicit conflict, offering the deleted or edited version. Duplicate triggers and shortcut
|
||||||
|
collisions created by independent edits are also presented for resolution. Deletion markers are
|
||||||
|
retained without automatic expiry; recreating a snippet in the editor gives it a new ID.
|
||||||
|
|
||||||
|
Existing profiles migrate automatically: legacy snippet IDs derive deterministically from the
|
||||||
|
original trigger, then are stored permanently. Sync writes use envelope version 2; older clients
|
||||||
|
refuse this version instead of dropping IDs/deletions. Update all participating PCs before using
|
||||||
|
the new merge behavior. Existing version-1 profiles and local baselines remain readable.
|
||||||
|
|
||||||
|
Download/Upload are still available for deliberate whole-profile recovery/initialization, not
|
||||||
|
required for ordinary conflicts. Every server write uses the version just read as a CAS
|
||||||
|
condition, including explicit Upload. A racing write is rejected rather than silently overwritten.
|
||||||
|
Deleted server versions are not silently recreated.
|
||||||
|
|
||||||
|
Hotkeys, snippets, application exclusions, layout order, conversion, screenshot and autocorrection
|
||||||
|
preferences are shared. Startup/elevation, keep-awake, logging, history enablement/limits,
|
||||||
|
Tesseract executable/engine selection, connection details and credentials stay local. Clipboard
|
||||||
|
history, screenshots and dictionary files are never uploaded. Full synchronization identifies
|
||||||
|
missing keyboard layouts by language (when Windows can identify it) and exact ID.
|
||||||
|
Install the same layout variant, or select **Keep this PC's layouts and layout shortcuts (sync
|
||||||
|
other settings)** and click **Save / Sign in / Sync**. This device-local option keeps the entire
|
||||||
|
local layout cycle and its direct shortcuts unchanged while synchronizing the other shared settings.
|
||||||
|
The server's layout cycle and direct shortcuts are preserved on automatic sync and explicit Upload;
|
||||||
|
Download also keeps the local layout configuration. Later syncs cannot erase missing server layouts
|
||||||
|
just because this PC does not have them installed. Uncheck the option to resume full layout sync.
|
||||||
|
Settings are JSON inside OpenBao; this is not end-to-end encryption from the vault administrator.
|
||||||
|
|
||||||
|
The last common profile (`sync-base.bin`) and the settings preceding the most recent downloaded
|
||||||
|
change (`settings-before-sync.bin`) are protected with Windows DPAPI and belong to this Windows
|
||||||
|
account/machine. Reinstallation recovery uses the remote profile and a fresh login; copying these
|
||||||
|
local DPAPI files to a new machine does not recover them. For an older server snapshot, use OpenBao
|
||||||
|
KV version history to restore that snapshot as the latest version, then choose Download remote.
|
||||||
|
The app does not yet provide a server-history browser. Keep ordinary server backups too.
|
||||||
|
|
||||||
|
OpenBao administrator setup:
|
||||||
|
|
||||||
|
1. Enable a KV **version 2** engine at `kswitcher`; enable **Require Check-and-Set** and set
|
||||||
|
maximum versions to a suitable value, such as 30.
|
||||||
|
2. Create a dedicated userpass account, with a policy restricted to the chosen profile. Do not
|
||||||
|
use the administrator account. For the defaults, the application needs only:
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
path "kswitcher/data/personal/settings" {
|
||||||
|
capabilities = ["create", "read", "update"]
|
||||||
|
}
|
||||||
|
path "auth/token/revoke-self" {
|
||||||
|
capabilities = ["update"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Set bounded token lifetimes on that account/auth mount and enter its credentials in KSwitcher.
|
||||||
|
Keep the server's address and TLS certificate reachable/trusted on each participating PC.
|
||||||
|
|
||||||
|
`SyncProvider` separates versioned read/conditional-write operations from merging, local state
|
||||||
|
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.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
The easiest reproducible local route uses the pinned portable w64devkit toolchain:
|
The easiest reproducible local route uses the pinned portable w64devkit toolchain:
|
||||||
@@ -252,13 +535,40 @@ Alternatively open this directory as a CMake project in Visual Studio with the C
|
|||||||
and Windows SDK installed, or use CMake with a Windows GCC toolchain. CMake/MSVC is provided but the
|
and Windows SDK installed, or use CMake with a Windows GCC toolchain. CMake/MSVC is provided but the
|
||||||
verified local build uses GCC/w64devkit.
|
verified local build uses GCC/w64devkit.
|
||||||
|
|
||||||
## Automatic layout correction (experimental)
|
## Automatic correction (experimental)
|
||||||
|
|
||||||
Enable **Automatically correct layout (experimental)** in Settings, then use **Auto-correction
|
Enable **Automatic correction (experimental)** in Settings, then use **Auto-correction
|
||||||
options…** to select dictionary languages and edit excluded applications or words to never correct.
|
options…** to select dictionary languages and edit excluded applications or words to never correct.
|
||||||
The feature is off by default. English and Ukrainian are selected initially; Russian is optional.
|
The feature is off by default. English and Ukrainian are selected initially; Russian is optional.
|
||||||
Target layouts must also be enabled in the layout cycle.
|
Target layouts must also be enabled in the layout cycle.
|
||||||
|
|
||||||
|
### Custom replacements and spelling typos
|
||||||
|
|
||||||
|
Two optional modes in **Auto-correction options…** extend layout correction. Both are off by
|
||||||
|
default and require the main **Automatic correction (experimental)** checkbox to be enabled.
|
||||||
|
|
||||||
|
- **Use custom replacements**: enter one `typo=correction` pair per line, for example
|
||||||
|
`првиіт=привіт` 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
|
||||||
|
spaces or punctuation. Explicit pairs take priority over dictionary recognition, including
|
||||||
|
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
|
||||||
|
after no layout correction is found. It accepts exactly one dictionary candidate reachable
|
||||||
|
by inserting, deleting or replacing one letter, or swapping two adjacent letters. Both input
|
||||||
|
and target must have at least four letters. Multiple candidates leave the input unchanged,
|
||||||
|
even if a frequency limit would exclude one of them. Personal dictionary words participate.
|
||||||
|
This is word-level spelling correction, not grammar or sentence-context correction.
|
||||||
|
|
||||||
|
Both modes run after Space, preserve surrounding punctuation and an initial capital, and keep
|
||||||
|
the current layout. The selected dictionary languages, enabled layout cycle, minimum input
|
||||||
|
length, excluded applications, never-correct list, protected fields and Undo behavior apply.
|
||||||
|
ALL CAPS, mixed-case identifiers, numbers, URLs and email addresses are skipped. A replacement
|
||||||
|
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
|
||||||
|
and suppresses its correction until restart.
|
||||||
|
|
||||||
|
### Layout correction and shared options
|
||||||
|
|
||||||
**Characters before a word** and **Characters after a word** configure punctuation to preserve around
|
**Characters before a word** and **Characters after a word** configure punctuation to preserve around
|
||||||
the corrected word. Enter characters directly, without spaces or separators; a comma or semicolon
|
the corrected word. Enter characters directly, without spaces or separators; a comma or semicolon
|
||||||
is itself a character. Defaults include parentheses, square/curly brackets, quotation marks and common
|
is itself a character. Defaults include parentheses, square/curly brackets, quotation marks and common
|
||||||
@@ -270,15 +580,29 @@ after Space, not immediately after a punctuation key. Save Settings to apply cha
|
|||||||
**Frequency rank limit** defaults to **0 (entire dictionary)**. A positive value allows corrections
|
**Frequency rank limit** defaults to **0 (entire dictionary)**. A positive value allows corrections
|
||||||
only to words with a rank at or below that number (for example, 10000). All dictionary words still
|
only to words with a rank at or below that number (for example, 10000). All dictionary words still
|
||||||
protect already valid input, and ambiguous alternatives remain unchanged regardless of this limit.
|
protect already valid input, and ambiguous alternatives remain unchanged regardless of this limit.
|
||||||
|
**My dictionary** provides separate English, Ukrainian and Russian fields for correct words missing
|
||||||
|
from the bundled dictionaries. Enter words separated by commas or new lines, then click OK and Save
|
||||||
|
in Settings. Case and duplicates are normalized automatically; no file editing or ranks are needed.
|
||||||
|
The v0.6.23 bundled Ukrainian dictionary includes `поточні`. The updater preserves existing dictionary files;
|
||||||
|
if upgrading from an older dictionary, add this word in My dictionary → Ukrainian and click OK → Save.
|
||||||
|
These entries are stored in settings and survive dictionary updates. They protect correct input and
|
||||||
|
are eligible as correction targets at any frequency limit. The language and target layout must still
|
||||||
|
be enabled. **Never correct these words** is a separate exception list and takes precedence.
|
||||||
**Open dictionary folder** opens the files beside the running executable. After editing, click OK
|
**Open dictionary folder** opens the files beside the running executable. After editing, click OK
|
||||||
in the options window and Save in Settings to validate and reload the dictionaries without restarting.
|
in the options window and Save in Settings to validate and reload the dictionaries without restarting.
|
||||||
Use UTF-8, one lowercase word followed by a Tab and a positive rank per line; keep words sorted and
|
Use UTF-8, one lowercase word followed by a Tab and a positive rank per line. Entries can be appended;
|
||||||
unique. For a personal addition, rank 1 makes it eligible at every positive limit. Back up your edited
|
loading sorts them and merges duplicates using the lowest rank. Back up your edited
|
||||||
files before replacing dictionaries with a new distribution.
|
files before replacing dictionaries with a new distribution.
|
||||||
|
|
||||||
After Space and a 250 ms typing pause, KSwitcher checks the last physically typed word against local
|
On Space, KSwitcher checks the last physically typed word against preloaded local dictionaries.
|
||||||
dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type cancels pending work.
|
For example, `ghbdsn ` can become `привіт `. A confirmed candidate immediately queues subsequent
|
||||||
It only corrects unknown words when exactly one alternative is a dictionary word.
|
key presses and releases; no typing pause is required. Queued input is replayed after
|
||||||
|
clipboard restoration and layout switching, preserving typing order, modifiers and Backspace.
|
||||||
|
If the active window changes, queued typing is not sent to another window and a warning is shown.
|
||||||
|
It only corrects unknown words when the dictionary alternatives agree on the resulting text.
|
||||||
|
Identical Ukrainian/Russian spellings such as `ntcn` → `тест` are accepted, using the first matching
|
||||||
|
language in the configured dictionary order (the UI uses English, Ukrainian, Russian).
|
||||||
|
Different valid spellings remain ambiguous and are left unchanged.
|
||||||
**Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation
|
**Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation
|
||||||
does not count toward this minimum; the maximum word length is 32 letters.
|
does not count toward this minimum; the maximum word length is 32 letters.
|
||||||
Known words, ambiguous alternatives, ALL CAPS, mixed-case identifiers, numbers, URLs and email
|
Known words, ambiguous alternatives, ALL CAPS, mixed-case identifiers, numbers, URLs and email
|
||||||
@@ -346,3 +670,22 @@ It still uses the normal instance lock; close a running KSwitcher first if measu
|
|||||||
|
|
||||||
Application code is MIT licensed; preserves the original [Typlo](https://github.com/bitcodepro/Typlo) attribution in `LICENSE`.
|
Application code is MIT licensed; preserves the original [Typlo](https://github.com/bitcodepro/Typlo) attribution in `LICENSE`.
|
||||||
Dictionary data has its own CC BY-SA 4.0 license as described above.
|
Dictionary data has its own CC BY-SA 4.0 license as described above.
|
||||||
|
|
||||||
|
The screenshot process lifecycle can be checked interactively with `tests/screenshot_process.ps1`
|
||||||
|
against a staged build. It briefly opens real selection/editor windows without changing the clipboard.
|
||||||
|
|
||||||
|
### Release channel rule
|
||||||
|
|
||||||
|
Every release must also update the published [Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki):
|
||||||
|
the latest version and relevant feature documentation in both English and Ukrainian.
|
||||||
|
Review all changes since the last documented release, update affected instructions,
|
||||||
|
shortcuts, settings and screenshots, and verify the published pages and links.
|
||||||
|
The release workflow is incomplete until this is done. Follow the mandatory
|
||||||
|
[Wiki release checklist](AGENTS.md#mandatory-wiki-updates-for-every-release).
|
||||||
|
|
||||||
|
Keep `v0.4.2` as the last legacy `vX.Y.Z` release. Publish full packages as
|
||||||
|
`release-vX.Y.Z` (stable, not prerelease), including `release-files.json`,
|
||||||
|
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt`. Never publish a manifest package under a
|
||||||
|
legacy version tag: clients that have not installed the bridge would select it.
|
||||||
|
Keep the compatibility bridge in the first 100 releases returned by Gitea while
|
||||||
|
supporting old clients; their immutable update code only reads that page.
|
||||||
|
|||||||
Binary file not shown.
+15
-17
@@ -1,24 +1,23 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.4.0 — 2026-10-28
|
## v0.6.30 — 2026-11-02
|
||||||
|
|
||||||
- Capture a rectangular screen region with Win+Shift+S (configurable), with dimmed selection and independent editor windows.
|
## Changes
|
||||||
- Annotate screenshots with numbered markers, arrows, straight/freehand lines, highlights, rectangles, circles, ellipses and inline text.
|
- A manual paste confirmed unsuccessful by readback no longer records the expected replacement as a previous result or switches the Windows layout. This prevents repeated commands from searching for text that was never inserted.
|
||||||
- Edit color, line style, arrowheads, thickness, font and size. Link arrow endpoints to markers and attach captions.
|
- A previous-result verification mismatch clears stale repeat-conversion state.
|
||||||
- Captions follow line angle and keep their relative offset until manually rotated. Text rotation supports Shift snapping in 15-degree steps.
|
- A writable native Windows EDIT field may receive one guarded paste retry: readback must equal the original text, and fresh native checks must confirm the exact original selection, unchanged focus and input context. Other controls, protected/read-only fields and unknown/changed selections are not retried.
|
||||||
- Move objects one pixel with arrow keys; clone with Ctrl+D; undo/redo edits. Numbered markers reuse gaps.
|
- The guarded retry and failed-paste readback handling require **Write log**, which enables manual-conversion readback. Clipboard restoration remains conditional on ownership.
|
||||||
- Compact Lucide toolbar, contextual status bar, pencil cursor and confirmation before discarding an uncopied screenshot. Escape no longer closes the editor.
|
|
||||||
- Preserve the separate on-demand configurator and configurable capture shortcut.
|
|
||||||
|
|
||||||
Verified: 173 native checks, 373 input/rendering checks, screenshot editor UI integration checks,
|
## Validation
|
||||||
28 updater checks and package validation using the unchanged v0.3.0 updater.
|
The final isolated conversion integration run passed: permanently blocked paste, unexpected editor content, clearing fictional previous results, ignored-first-paste recovery, clipboard restoration, repeat conversion and partial-selection recovery. Typo integration also passed, including Undo and uninterrupted typing. 541 input/UI checks (240 DPI), 105 sync/protocol checks, 483 native regressions and 48 updater checks passed. All release executable versions and manifest hashes were verified. Earlier interactive attempts were interrupted by concurrent input or a transient clipboard-restoration assertion; the final isolated rerun passed.
|
||||||
|
|
||||||
**Upgrade from v0.3.0:** use Update in the tray menu. Both executables are supported by that updater.
|
## Limitations
|
||||||
**Upgrade from v0.2.3 or earlier:** install manually once because those updaters reject the additional configurator EXE.
|
The cause of every ignored paste is not established. This release does not claim to solve all asynchronous selection failures in ChatGPT/browser editors. Keep Write log enabled and send complete operation details if the issue recurs. Native retry is bounded to one attempt; it is not a general retry policy for other applications. All existing dictionary-correction and synchronization limitations remain.
|
||||||
Exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
|
||||||
|
|
||||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains both executables,
|
**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.
|
||||||
offline dictionaries, documentation and license notices; no personal settings or clipboard history.
|
For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||||
|
|
||||||
|
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package includes all three executables, a release manifest, offline dictionaries, documentation and license notices. No personal settings, passwords, server address or clipboard history are included.
|
||||||
|
|
||||||
Build and package:
|
Build and package:
|
||||||
|
|
||||||
@@ -27,5 +26,4 @@ Build and package:
|
|||||||
.\package.ps1 -PublishRelease
|
.\package.ps1 -PublishRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
Commit the archive, checksum and sources together. Publish both assets on a stable release whose
|
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
||||||
tag matches the executable version.
|
|
||||||
@@ -1 +1 @@
|
|||||||
c2ad230f9946fddcf6faf17378814cba474c104fa155f63cd1a9ac61fc489ee7 KSwitcher-win-x64.zip
|
7e557851ab68a37a8211ea39de41e79314f460ef8ac6471be723d0cfe03cfba9 KSwitcher-win-x64.zip
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ $outDir = Join-Path $root 'build'
|
|||||||
New-Item -ItemType Directory -Force $outDir | Out-Null
|
New-Item -ItemType Directory -Force $outDir | Out-Null
|
||||||
$executablePath = if ($Stage) { Join-Path $outDir 'staged\KSwitcher.exe' } else { Join-Path $outDir 'KSwitcher.exe' }
|
$executablePath = if ($Stage) { Join-Path $outDir 'staged\KSwitcher.exe' } else { Join-Path $outDir 'KSwitcher.exe' }
|
||||||
New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
|
New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
|
||||||
|
Set-Content -LiteralPath (Join-Path $outDir '.local-dev') -Value 'Local development build' -Encoding ascii
|
||||||
|
Set-Content -LiteralPath (Join-Path (Split-Path $executablePath) '.local-dev') -Value 'Local development build' -Encoding ascii
|
||||||
Push-Location $root
|
Push-Location $root
|
||||||
try {
|
try {
|
||||||
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
||||||
$libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
$libs = @('-lwinhttp','-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','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" }
|
||||||
}
|
}
|
||||||
@@ -24,38 +26,64 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Configurator UI compilation failed' }
|
if ($LASTEXITCODE) { throw 'Configurator UI compilation failed' }
|
||||||
& windres --codepage=65001 resources/app.rc -O coff -o build/app-res.o
|
& windres --codepage=65001 resources/app.rc -O coff -o build/app-res.o
|
||||||
if ($LASTEXITCODE) { throw 'Resource compilation failed' }
|
if ($LASTEXITCODE) { throw 'Resource compilation failed' }
|
||||||
|
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
|
||||||
|
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
|
||||||
New-Item -ItemType Directory -Force build/release | Out-Null
|
New-Item -ItemType Directory -Force build/release | Out-Null
|
||||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
|
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
|
||||||
if ($LASTEXITCODE) { throw 'Link failed' }
|
if ($LASTEXITCODE) { throw 'Link failed' }
|
||||||
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
||||||
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
|
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
|
||||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
|
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
|
||||||
if ($LASTEXITCODE) { throw 'Configurator link failed' }
|
if ($LASTEXITCODE) { throw 'Configurator link failed' }
|
||||||
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
|
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
|
||||||
|
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
|
||||||
|
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
|
||||||
|
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||||
|
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||||
if ($Test) {
|
if ($Test) {
|
||||||
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
|
& $compiler @common tests/sync.cpp build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o @libs -static -s -o build/KSwitcherSyncTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Sync test build failed' }
|
||||||
|
& .\build\KSwitcherSyncTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Sync tests failed' }
|
||||||
|
& $compiler @common tests/scroll.cpp @libs -static -s -o build/KSwitcherScrollTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Scroll test build failed' }
|
||||||
|
& .\build\KSwitcherScrollTests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Scroll tests failed' }
|
||||||
|
& $compiler @common tests/tests.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 @libs -static -s -o build/KSwitcherTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Test build failed' }
|
if ($LASTEXITCODE) { throw 'Test build failed' }
|
||||||
& .\build\KSwitcherTests.exe
|
& .\build\KSwitcherTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Tests failed' }
|
if ($LASTEXITCODE) { throw 'Tests failed' }
|
||||||
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherInputTests.exe
|
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/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 -static -s -o build/KSwitcherInputTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Input test build failed' }
|
if ($LASTEXITCODE) { throw 'Input test build failed' }
|
||||||
& .\build\KSwitcherInputTests.exe
|
& .\build\KSwitcherInputTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
||||||
& $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
|
& $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
|
||||||
& .\build\KSwitcherScreenshotUITests.exe
|
& .\build\KSwitcherScreenshotUITests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
|
||||||
|
& .\build\KSwitcherScreenshotUITests.exe --save-only
|
||||||
|
if ($LASTEXITCODE) { throw 'Screenshot save tests failed' }
|
||||||
|
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
|
||||||
|
try {
|
||||||
|
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
|
||||||
|
if ($LASTEXITCODE) { throw 'Screenshot registration test build failed' }
|
||||||
|
& $registrationTest
|
||||||
|
if ($LASTEXITCODE) { throw 'Screenshot registration tests failed' }
|
||||||
|
} finally { Remove-Item -LiteralPath $registrationTest -ErrorAction SilentlyContinue }
|
||||||
|
|
||||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
|
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
|
||||||
if ($LASTEXITCODE) { throw 'Updater tests failed' }
|
if ($LASTEXITCODE) { throw 'Updater tests failed' }
|
||||||
|
& "$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 ($Integration) {
|
if ($Integration) {
|
||||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/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 -static -s -o build/KSwitcherIntegration.exe
|
||||||
if ($LASTEXITCODE) { throw 'Integration test build failed' }
|
if ($LASTEXITCODE) { throw 'Integration test build failed' }
|
||||||
& .\build\KSwitcherIntegration.exe
|
& .\build\KSwitcherIntegration.exe
|
||||||
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
||||||
}
|
}
|
||||||
if ($Ocr) {
|
if ($Ocr) {
|
||||||
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/ocr.o @libs -static -s -o build/KSwitcherOcrTests.exe
|
& $compiler @common tests/ocr.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 -static -s -o build/KSwitcherOcrTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'OCR test build failed' }
|
if ($LASTEXITCODE) { throw 'OCR test build failed' }
|
||||||
& .\build\KSwitcherOcrTests.exe
|
& .\build\KSwitcherOcrTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'OCR tests failed' }
|
if ($LASTEXITCODE) { throw 'OCR tests failed' }
|
||||||
|
|||||||
+12
-2
@@ -17,9 +17,19 @@ replace counts with original frequency ranks, sort lexically. Reproduce with
|
|||||||
hashes. The lists contain word forms from subtitles, not a complete spelling
|
hashes. The lists contain word forms from subtitles, not a complete spelling
|
||||||
or grammar dictionary. Recognition can miss names, jargon and uncommon forms.
|
or grammar dictionary. Recognition can miss names, jargon and uncommon forms.
|
||||||
|
|
||||||
File format: UTF-8 word, TAB, positive original frequency rank, newline.
|
Supplemental Ukrainian vocabulary: `реліз`, `поточні`. These additions use rank 50001,
|
||||||
|
after the upstream 50k list, rather than claiming a measured corpus frequency.
|
||||||
|
They are included with the default unlimited rank setting and recorded in sources.json.
|
||||||
|
|
||||||
|
File format: UTF-8 word, TAB, positive frequency rank (or supplemental rank), newline.
|
||||||
All entries protect known words. The frequency rank limit in Auto-correction options defaults to 0
|
All entries protect known words. The frequency rank limit in Auto-correction options defaults to 0
|
||||||
(entire dictionary); a positive limit only permits correction candidates at or below that rank.
|
(entire dictionary); a positive limit only permits correction candidates at or below that rank.
|
||||||
Personal words may use rank 1. Keep entries lowercase, unique and sorted, with a Tab separator.
|
Prefer Settings > Auto-correction options > My dictionary for personal words.
|
||||||
|
Enter correct spellings in the matching language field, separated by commas or new lines.
|
||||||
|
These words are stored in settings, survive dictionary updates, and need no ranks or sorting.
|
||||||
|
Click OK, then Save in Settings to apply them.
|
||||||
|
|
||||||
|
For advanced file edits, personal words may use rank 1. Keep entries lowercase with a Tab separator.
|
||||||
|
Words may be appended: loading sorts entries and merges duplicates using the lowest rank.
|
||||||
Save the UTF-8 file, then click OK in Auto-correction options and Save in Settings to reload it.
|
Save the UTF-8 file, then click OK in Auto-correction options and Save in Settings to reload it.
|
||||||
Back up edited files before replacing dictionaries with a new distribution.
|
Back up edited files before replacing dictionaries with a new distribution.
|
||||||
|
|||||||
@@ -9,9 +9,13 @@
|
|||||||
"uk": {
|
"uk": {
|
||||||
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
|
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
|
||||||
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
|
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
|
||||||
"words": 42549,
|
"words": 42551,
|
||||||
"bytes": 898210,
|
"bytes": 898248,
|
||||||
"sha256": "b4104898dcccbc1331ec01e9cd690e30285fe58481460e47438e973964d09802"
|
"sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86",
|
||||||
|
"supplementalWords": {
|
||||||
|
"\u0440\u0435\u043b\u0456\u0437": 50001,
|
||||||
|
"\u043f\u043e\u0442\u043e\u0447\u043d\u0456": 50001
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"ru": {
|
"ru": {
|
||||||
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/ru/ru_50k.txt",
|
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/ru/ru_50k.txt",
|
||||||
|
|||||||
@@ -27337,6 +27337,7 @@
|
|||||||
поторопись 9062
|
поторопись 9062
|
||||||
поторопитесь 20656
|
поторопитесь 20656
|
||||||
поторопиться 15390
|
поторопиться 15390
|
||||||
|
поточні 50001
|
||||||
потрапив 4168
|
потрапив 4168
|
||||||
потрапила 7383
|
потрапила 7383
|
||||||
потрапили 6631
|
потрапили 6631
|
||||||
@@ -31207,6 +31208,7 @@
|
|||||||
релігію 26138
|
релігію 26138
|
||||||
релігія 22202
|
релігія 22202
|
||||||
релігії 23427
|
релігії 23427
|
||||||
|
реліз 50001
|
||||||
рембо 28603
|
рембо 28603
|
||||||
ремень 23872
|
ремень 23872
|
||||||
ремені 28385
|
ремені 28385
|
||||||
|
|||||||
+11
-1
@@ -5,12 +5,22 @@ $destination = Join-Path $root 'dist\KSwitcher'
|
|||||||
New-Item -ItemType Directory -Force $destination | Out-Null
|
New-Item -ItemType Directory -Force $destination | Out-Null
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination
|
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination
|
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination
|
||||||
|
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Screenshot.exe') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
|
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
|
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
|
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
|
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
|
||||||
# Enumerate the distributable files explicitly; do not include a user's data directory.
|
# Enumerate the distributable files explicitly; do not include a user's data directory.
|
||||||
$files = @('KSwitcher.exe','KSwitcher.Config.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') | ForEach-Object { Join-Path $destination $_ }
|
||||||
|
$entries = foreach ($path in $files) {
|
||||||
|
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()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$version=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $destination 'KSwitcher.exe')).FileVersion
|
||||||
|
$manifest=Join-Path $destination 'release-files.json'
|
||||||
|
@{schema=1; version=$version; files=@($entries)} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifest -Encoding utf8
|
||||||
|
$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
|
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
|
||||||
Get-FileHash -LiteralPath $archive -Algorithm SHA256
|
Get-FileHash -LiteralPath $archive -Algorithm SHA256
|
||||||
|
|||||||
+9
-3
@@ -2,12 +2,15 @@
|
|||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
#include "resource.h"
|
#include "resource.h"
|
||||||
IDI_KSWITCHER ICON "kswitcher.ico"
|
IDI_KSWITCHER ICON "kswitcher.ico"
|
||||||
|
#ifdef KS_SCREENSHOT_RESOURCES
|
||||||
|
IDI_SCREENSHOT_QUESTION ICON "icons/question.ico"
|
||||||
IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||||
|
#endif
|
||||||
1 RT_MANIFEST "app.manifest"
|
1 RT_MANIFEST "app.manifest"
|
||||||
201 RCDATA "update.ps1"
|
201 RCDATA "update.ps1"
|
||||||
1 VERSIONINFO
|
1 VERSIONINFO
|
||||||
FILEVERSION 0,4,0,0
|
FILEVERSION 0,6,30,0
|
||||||
PRODUCTVERSION 0,4,0,0
|
PRODUCTVERSION 0,6,30,0
|
||||||
FILEOS VOS_NT_WINDOWS32
|
FILEOS VOS_NT_WINDOWS32
|
||||||
FILETYPE VFT_APP
|
FILETYPE VFT_APP
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -17,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.4.0"
|
VALUE "FileVersion", "0.6.30"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
@@ -25,6 +28,7 @@ BEGIN
|
|||||||
VALUE "Translation", 0x409, 1200
|
VALUE "Translation", 0x409, 1200
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
|
#ifdef KS_SCREENSHOT_RESOURCES
|
||||||
300 ICON "icons/lucide/circle-dot-normal.ico"
|
300 ICON "icons/lucide/circle-dot-normal.ico"
|
||||||
310 ICON "icons/lucide/circle-dot-active.ico"
|
310 ICON "icons/lucide/circle-dot-active.ico"
|
||||||
320 ICON "icons/lucide/circle-dot-disabled.ico"
|
320 ICON "icons/lucide/circle-dot-disabled.ico"
|
||||||
@@ -73,3 +77,5 @@ END
|
|||||||
405 ICON "icons/lucide/a-large-small-normal.ico"
|
405 ICON "icons/lucide/a-large-small-normal.ico"
|
||||||
415 ICON "icons/lucide/a-large-small-active.ico"
|
415 ICON "icons/lucide/a-large-small-active.ico"
|
||||||
425 ICON "icons/lucide/a-large-small-disabled.ico"
|
425 ICON "icons/lucide/a-large-small-disabled.ico"
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="blue" x1="8" y1="4" x2="36" y2="44" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#38bdf8"/>
|
||||||
|
<stop offset=".55" stop-color="#1687e8"/>
|
||||||
|
<stop offset="1" stop-color="#2563eb"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="24" cy="25" r="21" fill="#1e40af" opacity=".12"/>
|
||||||
|
<circle cx="24" cy="23" r="21" fill="url(#blue)"/>
|
||||||
|
<circle cx="24" cy="23" r="20.5" fill="none" stroke="white" stroke-opacity=".25"/>
|
||||||
|
<path d="M18 17.5c0-3.1 2.5-5 6-5s6 2 6 5c0 4.8-6 4.8-6 9" fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="24" cy="33" r="2" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 711 B |
@@ -1,3 +1,4 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#define IDI_KSWITCHER 101
|
#define IDI_KSWITCHER 101
|
||||||
|
#define IDI_SCREENSHOT_QUESTION 102
|
||||||
#define IDC_SCREENSHOT_PENCIL 500
|
#define IDC_SCREENSHOT_PENCIL 500
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="330" viewBox="0 0 960 330">
|
||||||
|
<rect x="1" y="1" width="958" height="328" rx="24" fill="#F5F3FF" stroke="#DDD6FE" stroke-width="2"/>
|
||||||
|
<g font-family="Segoe UI" fill="#241B46">
|
||||||
|
<text x="40" y="58" font-size="20" font-weight="600" fill="#4F46E5">KSwitcher / Support development</text>
|
||||||
|
<text x="40" y="115" font-size="35" font-weight="700">KSwitcher part of your day?</text>
|
||||||
|
<text x="40" y="159" font-size="35" font-weight="700">Buy the developer a coffee.</text>
|
||||||
|
<text x="40" y="202" font-size="20" fill="#655B7C">Your donation helps the tool keep growing.</text>
|
||||||
|
<rect x="40" y="238" width="350" height="54" rx="12" fill="#4F46E5"/>
|
||||||
|
<text x="215" y="272" text-anchor="middle" font-size="20" font-weight="600" fill="white">Support via monobank</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(759 48) rotate(10 66 66)">
|
||||||
|
<rect y="8" width="132" height="132" rx="26" fill="#C4B5FD"/>
|
||||||
|
<rect width="132" height="132" rx="26" fill="#4F46E5"/>
|
||||||
|
<g transform="translate(14 14) scale(3.25)"><path d="M8 7H12V25H8ZM11 15L22 6L25 9L14 18Z" fill="white"/><path d="M11 14L25 24L22 27L10 18Z" fill="#5EEAD4"/></g>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(699 172) rotate(-8 67 60)">
|
||||||
|
<rect y="7" width="142" height="115" rx="22" fill="#C4B5FD"/>
|
||||||
|
<rect width="142" height="115" rx="22" fill="white" stroke="#DDD6FE" stroke-width="2"/>
|
||||||
|
<g stroke="#4F46E5" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M43 41H92V66Q92 83 75 83H60Q43 83 43 66Z"/>
|
||||||
|
<path d="M92 46H99Q115 46 111 58Q108 67 92 66M37 92H100M56 28V18M69 28V18M82 28V18"/>
|
||||||
|
</g></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="330" viewBox="0 0 960 330">
|
||||||
|
<rect x="1" y="1" width="958" height="328" rx="24" fill="#F5F3FF" stroke="#DDD6FE" stroke-width="2"/>
|
||||||
|
<g font-family="Segoe UI" fill="#241B46">
|
||||||
|
<text x="40" y="58" font-size="20" font-weight="600" fill="#4F46E5">KSwitcher / Підтримати розробку</text>
|
||||||
|
<text x="40" y="115" font-size="35" font-weight="700">KSwitcher щодня під рукою?</text>
|
||||||
|
<text x="40" y="159" font-size="35" font-weight="700">Пригостіть розробника кавою.</text>
|
||||||
|
<text x="40" y="202" font-size="20" fill="#655B7C">Ваш донат допомагає розвивати інструмент.</text>
|
||||||
|
<rect x="40" y="238" width="350" height="54" rx="12" fill="#4F46E5"/>
|
||||||
|
<text x="215" y="272" text-anchor="middle" font-size="20" font-weight="600" fill="white">Підтримати через monobank</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(759 48) rotate(10 66 66)">
|
||||||
|
<rect y="8" width="132" height="132" rx="26" fill="#C4B5FD"/>
|
||||||
|
<rect width="132" height="132" rx="26" fill="#4F46E5"/>
|
||||||
|
<g transform="translate(14 14) scale(3.25)"><path d="M8 7H12V25H8ZM11 15L22 6L25 9L14 18Z" fill="white"/><path d="M11 14L25 24L22 27L10 18Z" fill="#5EEAD4"/></g>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(699 172) rotate(-8 67 60)">
|
||||||
|
<rect y="7" width="142" height="115" rx="22" fill="#C4B5FD"/>
|
||||||
|
<rect width="142" height="115" rx="22" fill="white" stroke="#DDD6FE" stroke-width="2"/>
|
||||||
|
<g stroke="#4F46E5" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M43 41H92V66Q92 83 75 83H60Q43 83 43 66Z"/>
|
||||||
|
<path d="M92 46H99Q115 46 111 58Q108 67 92 66M37 92H100M56 28V18M69 28V18M82 28V18"/>
|
||||||
|
</g></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
+299
-12
@@ -3,14 +3,196 @@
|
|||||||
[string]$CurrentVersion, [string]$ExpectedVersion,
|
[string]$CurrentVersion, [string]$ExpectedVersion,
|
||||||
[string]$InstallDirectory, [string]$WorkDirectory,
|
[string]$InstallDirectory, [string]$WorkDirectory,
|
||||||
[int]$ParentProcessId, [string]$ReadyEvent,
|
[int]$ParentProcessId, [string]$ReadyEvent,
|
||||||
|
[switch]$ShowProgress,
|
||||||
[switch]$Library
|
[switch]$Library
|
||||||
)
|
)
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
|
$script:progressWindow = $null
|
||||||
|
|
||||||
|
function Start-UpdateProgress {
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
if (!('KSwitcherUpdateProgress' -as [type])) {
|
||||||
|
Add-Type -ReferencedAssemblies System.Windows.Forms,System.Drawing -TypeDefinition @'
|
||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public sealed class UpdateMeter : Control {
|
||||||
|
public int Percent = -1;
|
||||||
|
private int position;
|
||||||
|
private readonly System.Windows.Forms.Timer timer = new System.Windows.Forms.Timer { Interval = 35 };
|
||||||
|
public UpdateMeter() {
|
||||||
|
DoubleBuffered = true;
|
||||||
|
timer.Tick += delegate { if (Percent < 0) { position = (position + 5) % Math.Max(1,Width); Invalidate(); } };
|
||||||
|
timer.Start();
|
||||||
|
}
|
||||||
|
protected override void OnPaint(PaintEventArgs e) {
|
||||||
|
using(var track = new SolidBrush(Color.FromArgb(232,234,241))) e.Graphics.FillRectangle(track,0,0,Width,Height);
|
||||||
|
using(var fill = new SolidBrush(Color.FromArgb(79,70,229))) {
|
||||||
|
if(Percent < 0) e.Graphics.FillRectangle(fill,position-Width/4,0,Width/4,Height);
|
||||||
|
else e.Graphics.FillRectangle(fill,0,0,Width*Percent/100,Height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protected override void Dispose(bool disposing) { if(disposing) timer.Dispose(); base.Dispose(disposing); }
|
||||||
|
}
|
||||||
|
public sealed class UpdateSteps : Control {
|
||||||
|
public int Current = -1;
|
||||||
|
public bool Complete;
|
||||||
|
public UpdateSteps() { DoubleBuffered = true; }
|
||||||
|
protected override void OnPaint(PaintEventArgs e) {
|
||||||
|
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||||
|
string[] names = {"Download","Verify","Install","Restart"};
|
||||||
|
for(int i=0;i<4;i++) {
|
||||||
|
int x = i*(Width-80)/3;
|
||||||
|
Color color = Complete || i<=Current ? Color.FromArgb(79,70,229) : Color.FromArgb(135,141,158);
|
||||||
|
using(var pen = new Pen(Color.FromArgb(231,233,240)))
|
||||||
|
if(i<3) e.Graphics.DrawLine(pen,x+22,8,x+(Width-80)/3-10,8);
|
||||||
|
using(var brush = new SolidBrush(color)) {
|
||||||
|
if(Complete || i<Current) {
|
||||||
|
e.Graphics.FillEllipse(brush,x,0,16,16);
|
||||||
|
using(var pen = new Pen(Color.White,1.6f)) e.Graphics.DrawLines(pen,new Point[]{new Point(x+4,8),new Point(x+7,11),new Point(x+12,5)});
|
||||||
|
} else {
|
||||||
|
using(var pen = new Pen(color,i==Current?2:1)) e.Graphics.DrawEllipse(pen,x+1,1,14,14);
|
||||||
|
if(i==Current) e.Graphics.FillEllipse(brush,x+5,5,6,6);
|
||||||
|
}
|
||||||
|
e.Graphics.DrawString(names[i],Font,brush,x,26);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public sealed class KSwitcherUpdateProgress : IDisposable {
|
||||||
|
private Form form;
|
||||||
|
private Label stage, detail, percentage, footer;
|
||||||
|
private UpdateMeter progress;
|
||||||
|
private UpdateSteps steps;
|
||||||
|
private Button done, close;
|
||||||
|
private bool closing;
|
||||||
|
private Exception failure;
|
||||||
|
private readonly ManualResetEvent ready = new ManualResetEvent(false);
|
||||||
|
private readonly Thread thread;
|
||||||
|
[DllImport("user32.dll")] private static extern bool ReleaseCapture();
|
||||||
|
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr h,int m,IntPtr w,IntPtr l);
|
||||||
|
[DllImport("dwmapi.dll")] private static extern int DwmSetWindowAttribute(IntPtr h,int a,ref int v,int size);
|
||||||
|
private void Drag(object sender,MouseEventArgs e) { if(e.Button==MouseButtons.Left) { ReleaseCapture(); SendMessage(form.Handle,0xA1,(IntPtr)2,IntPtr.Zero); } }
|
||||||
|
public KSwitcherUpdateProgress() {
|
||||||
|
thread = new Thread(delegate() {
|
||||||
|
try {
|
||||||
|
Application.EnableVisualStyles();
|
||||||
|
form = new Form { Text = "KSwitcher Update", ClientSize = new Size(540,350), BackColor = Color.White,
|
||||||
|
ForeColor = Color.FromArgb(32,36,48), StartPosition = FormStartPosition.CenterScreen,
|
||||||
|
FormBorderStyle = FormBorderStyle.None, Font = new Font("Segoe UI",10), AutoScaleMode = AutoScaleMode.Dpi };
|
||||||
|
var logo = new Panel { Location = new Point(28,24), Size = new Size(34,34), BackColor = Color.FromArgb(79,70,229) };
|
||||||
|
logo.Paint += delegate(object sender,PaintEventArgs e) {
|
||||||
|
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||||
|
using(var pen = new Pen(Color.White,4)) { e.Graphics.DrawLine(pen,10,8,10,26); e.Graphics.DrawLine(pen,11,17,25,7); }
|
||||||
|
using(var pen = new Pen(Color.FromArgb(94,234,212),4)) e.Graphics.DrawLine(pen,12,17,25,27);
|
||||||
|
};
|
||||||
|
var brand = new Label { Text = "KSwitcher", Location = new Point(74,22), Size = new Size(260,24), Font = new Font("Segoe UI Semibold",11) };
|
||||||
|
var caption = new Label { Text = "Application update", Location = new Point(74,45), Size = new Size(260,20), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
|
||||||
|
var minimize = new Button { Text = "\u2013", AccessibleName = "Minimize", Location = new Point(452,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, TabStop = false };
|
||||||
|
minimize.FlatAppearance.BorderSize = 0; minimize.Click += delegate { form.WindowState = FormWindowState.Minimized; };
|
||||||
|
close = new Button { Text = "\u00d7", AccessibleName = "Close", Location = new Point(490,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, Enabled = false };
|
||||||
|
close.FlatAppearance.BorderSize = 0; close.Click += delegate { form.Close(); };
|
||||||
|
stage = new Label { Text = "Checking for updates", Location = new Point(28,98), Size = new Size(414,36), Font = new Font("Segoe UI Semibold",20) };
|
||||||
|
percentage = new Label { Location = new Point(440,104), Size = new Size(72,30), TextAlign = ContentAlignment.MiddleRight, ForeColor = Color.FromArgb(79,70,229), Font = new Font("Segoe UI Semibold",16) };
|
||||||
|
detail = new Label { Text = "Connecting to the release server", Location = new Point(28,148), Size = new Size(484,42), ForeColor = Color.FromArgb(111,117,134) };
|
||||||
|
progress = new UpdateMeter { Location = new Point(28,202), Size = new Size(484,6), AccessibleName = "Update progress" };
|
||||||
|
steps = new UpdateSteps { Location = new Point(28,230), Size = new Size(484,48), Font = new Font("Segoe UI",9) };
|
||||||
|
var bottom = new Panel { Location = new Point(1,296), Size = new Size(538,53), BackColor = Color.FromArgb(247,248,251) };
|
||||||
|
footer = new Label { Text = "Your settings and history stay with you.", Location = new Point(27,18), Size = new Size(350,22), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
|
||||||
|
done = new Button { Text = "Done", Location = new Point(410,9), Size = new Size(100,34), BackColor = Color.FromArgb(79,70,229), ForeColor = Color.White, FlatStyle = FlatStyle.Flat, Visible = false };
|
||||||
|
done.FlatAppearance.BorderSize = 0; done.Click += delegate { form.Close(); };
|
||||||
|
bottom.Controls.AddRange(new Control[]{footer,done});
|
||||||
|
form.Controls.AddRange(new Control[]{logo,brand,caption,minimize,close,stage,percentage,detail,progress,steps,bottom});
|
||||||
|
form.MouseDown += Drag; logo.MouseDown += Drag; brand.MouseDown += Drag; caption.MouseDown += Drag;
|
||||||
|
form.Paint += delegate(object sender,PaintEventArgs e) { using(var pen = new Pen(Color.FromArgb(220,223,232))) e.Graphics.DrawRectangle(pen,0,0,form.ClientSize.Width-1,form.ClientSize.Height-1); };
|
||||||
|
form.FormClosing += delegate(object sender, FormClosingEventArgs e) { if (!closing) e.Cancel = true; };
|
||||||
|
form.Shown += delegate { int rounded=2; try { DwmSetWindowAttribute(form.Handle,33,ref rounded,4); } catch { } ready.Set(); };
|
||||||
|
Application.Run(form);
|
||||||
|
} catch(Exception e) { failure = e; ready.Set(); }
|
||||||
|
});
|
||||||
|
thread.IsBackground = true;
|
||||||
|
thread.SetApartmentState(ApartmentState.STA);
|
||||||
|
thread.Start();
|
||||||
|
if (!ready.WaitOne(10000)) throw new Exception("Update progress window did not start.");
|
||||||
|
if (failure != null) throw new Exception("Cannot display update progress.",failure);
|
||||||
|
}
|
||||||
|
public void Report(string title, string text, int percent) {
|
||||||
|
if (form == null || form.IsDisposed) return;
|
||||||
|
form.BeginInvoke((Action)delegate {
|
||||||
|
stage.Text = title; detail.Text = text;
|
||||||
|
progress.Percent = percent < 0 ? -1 : Math.Max(0,Math.Min(100,percent));
|
||||||
|
percentage.Text = percent < 0 ? "" : progress.Percent+"%";
|
||||||
|
int separator = text.IndexOf("% - ");
|
||||||
|
if(separator>=0) detail.Text = text.Substring(separator+4)+" downloaded";
|
||||||
|
if(title.StartsWith("Downloading")) steps.Current=0;
|
||||||
|
else if(title.StartsWith("Verifying") || title.StartsWith("Preparing")) steps.Current=1;
|
||||||
|
else if(title.StartsWith("Installing") || title.StartsWith("Closing")) steps.Current=2;
|
||||||
|
else if(title.StartsWith("Restarting")) steps.Current=3;
|
||||||
|
progress.Invalidate(); steps.Invalidate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public void SavePreview(string path) {
|
||||||
|
form.Invoke((Action)delegate {
|
||||||
|
using(var bitmap = new Bitmap(form.Width,form.Height)) {
|
||||||
|
form.DrawToBitmap(bitmap,new Rectangle(0,0,bitmap.Width,bitmap.Height));
|
||||||
|
bitmap.Save(path,System.Drawing.Imaging.ImageFormat.Png);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public string Status { get { return (string)form.Invoke((Func<string>)delegate { return stage.Text; }); } }
|
||||||
|
public int Percent { get { return (int)form.Invoke((Func<int>)delegate { return progress.Percent; }); } }
|
||||||
|
public void Finish(string title, string text, bool success) {
|
||||||
|
form.Invoke((Action)delegate {
|
||||||
|
closing = true;
|
||||||
|
stage.Text = title; detail.Text = text;
|
||||||
|
progress.Percent = success ? 100 : 0; percentage.Text = success ? "100%" : "";
|
||||||
|
steps.Complete = success;
|
||||||
|
if(!success) {
|
||||||
|
detail.Visible = false; steps.Visible = false;
|
||||||
|
var result = new TextBox { Text = text, Location = new Point(28,148), Size = new Size(484,126),
|
||||||
|
Multiline = true, ReadOnly = true, BorderStyle = BorderStyle.None,
|
||||||
|
BackColor = Color.White, ScrollBars = ScrollBars.Vertical };
|
||||||
|
form.Controls.Add(result); result.BringToFront(); progress.Visible = false;
|
||||||
|
}
|
||||||
|
footer.Text = success ? "Your settings and history are preserved." : "Review the details above.";
|
||||||
|
close.Enabled = true; done.Visible = true;
|
||||||
|
form.AcceptButton = done; form.CancelButton = done;
|
||||||
|
progress.Invalidate(); steps.Invalidate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public void WaitForClose() { thread.Join(); }
|
||||||
|
public void Dispose() {
|
||||||
|
if (form != null && !form.IsDisposed)
|
||||||
|
form.BeginInvoke((Action)delegate { closing = true; form.Close(); });
|
||||||
|
thread.Join(3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
}
|
||||||
|
$script:progressWindow = New-Object KSwitcherUpdateProgress
|
||||||
|
}
|
||||||
|
function Show-UpdateProgress([string]$Stage,[string]$Detail,[int]$Percent = -1) {
|
||||||
|
if ($script:progressWindow) {
|
||||||
|
try { $script:progressWindow.Report($Stage,$Detail,$Percent) }
|
||||||
|
catch { Write-Warning 'Could not refresh update progress.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function Complete-UpdateProgress([string]$Title,[string]$Detail,[bool]$Success) {
|
||||||
|
if ($script:progressWindow) {
|
||||||
|
try { $script:progressWindow.Finish($Title,$Detail,$Success); return $true }
|
||||||
|
catch { Write-Warning 'Could not display the update result.' }
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
function Get-StableVersion([string]$Tag) {
|
function Get-StableVersion([string]$Tag) {
|
||||||
if ($Tag -notmatch '^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
||||||
try { return [version]($Tag -replace '^v','') } catch { return $null }
|
try { return [version]($Tag -replace '^(?:release-)?v','') } catch { return $null }
|
||||||
}
|
}
|
||||||
function Assert-ReleaseUrl([string]$Url) {
|
function Assert-ReleaseUrl([string]$Url) {
|
||||||
$uri = [uri]$Url
|
$uri = [uri]$Url
|
||||||
@@ -20,7 +202,7 @@ function Assert-ReleaseUrl([string]$Url) {
|
|||||||
throw 'Release asset URL is outside the KSwitcher repository.'
|
throw 'Release asset URL is outside the KSwitcher repository.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
|
function Receive-File([string]$Url,[string]$Path,[long]$Limit,[string]$ProgressLabel = '') {
|
||||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
$request = [Net.HttpWebRequest]::Create($Url)
|
$request = [Net.HttpWebRequest]::Create($Url)
|
||||||
$request.AllowAutoRedirect = $false
|
$request.AllowAutoRedirect = $false
|
||||||
@@ -36,11 +218,20 @@ function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
|
|||||||
$buffer = New-Object byte[] 65536
|
$buffer = New-Object byte[] 65536
|
||||||
$total = 0L
|
$total = 0L
|
||||||
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
||||||
|
$lastProgress = [DateTime]::MinValue
|
||||||
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
|
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
|
||||||
$total += $count
|
$total += $count
|
||||||
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
|
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
|
||||||
$outputStream.Write($buffer,0,$count)
|
$outputStream.Write($buffer,0,$count)
|
||||||
|
if ($ProgressLabel -and ([DateTime]::UtcNow - $lastProgress).TotalMilliseconds -ge 100) {
|
||||||
|
$percent = if ($response.ContentLength -gt 0) { [int][Math]::Min(100,100.0*$total/$response.ContentLength) } else { -1 }
|
||||||
|
$detail = if ($percent -ge 0) { '{0}% - {1:N1} / {2:N1} MB' -f $percent,($total/1MB),($response.ContentLength/1MB) } else { '{0:N1} MB received' -f ($total/1MB) }
|
||||||
|
Show-UpdateProgress $ProgressLabel $detail $percent
|
||||||
|
$lastProgress = [DateTime]::UtcNow
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if ($response.ContentLength -ge 0 -and $total -ne $response.ContentLength) { throw 'The download ended before the complete file was received.' }
|
||||||
|
if ($ProgressLabel) { Show-UpdateProgress $ProgressLabel ('Downloaded {0:N1} MB' -f ($total/1MB)) 100 }
|
||||||
} finally {
|
} finally {
|
||||||
if ($outputStream) { $outputStream.Dispose() }
|
if ($outputStream) { $outputStream.Dispose() }
|
||||||
if ($inputStream) { $inputStream.Dispose() }
|
if ($inputStream) { $inputStream.Dispose() }
|
||||||
@@ -63,9 +254,15 @@ function Assert-Checksum([string]$Archive,[string]$Checksum) {
|
|||||||
$matchesFound = @([IO.File]::ReadAllLines($Checksum) | Where-Object { $_ -cmatch '^([a-fA-F0-9]{64})\s+\*?KSwitcher-win-x64\.zip$' })
|
$matchesFound = @([IO.File]::ReadAllLines($Checksum) | Where-Object { $_ -cmatch '^([a-fA-F0-9]{64})\s+\*?KSwitcher-win-x64\.zip$' })
|
||||||
if ($matchesFound.Count -ne 1) { throw 'Missing or ambiguous archive checksum.' }
|
if ($matchesFound.Count -ne 1) { throw 'Missing or ambiguous archive checksum.' }
|
||||||
$expected = ($matchesFound[0] -split '\s+')[0]
|
$expected = ($matchesFound[0] -split '\s+')[0]
|
||||||
if ((Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash -ne $expected) { throw 'Release checksum verification failed.' }
|
# Do not depend on PowerShell module autoloading: hosts can inherit a
|
||||||
|
# PowerShell 7 PSModulePath while this helper runs in Windows PowerShell 5.1.
|
||||||
|
$stream = [IO.File]::OpenRead($Archive)
|
||||||
|
$sha = [Security.Cryptography.SHA256]::Create()
|
||||||
|
try { $actual = [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','') }
|
||||||
|
finally { $sha.Dispose(); $stream.Dispose() }
|
||||||
|
if ($actual -ne $expected) { throw 'Release checksum verification failed.' }
|
||||||
}
|
}
|
||||||
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
|
function Expand-LegacyRelease([string]$Archive,[string]$Destination,[version]$Expected) {
|
||||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
|
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
|
||||||
try {
|
try {
|
||||||
@@ -74,7 +271,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
|
|||||||
foreach ($entry in $zip.Entries) {
|
foreach ($entry in $zip.Entries) {
|
||||||
$name = $entry.FullName.Replace('\','/')
|
$name = $entry.FullName.Replace('\','/')
|
||||||
if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue }
|
if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue }
|
||||||
if ($name -cnotmatch '^(KSwitcher(?:\.Config)?\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or
|
if ($name -cnotmatch '^(KSwitcher(?:\.(?:Config|Screenshot))?\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or
|
||||||
$name.EndsWith('/.') -or $name.EndsWith('/..') -or $name.EndsWith('.') -or
|
$name.EndsWith('/.') -or $name.EndsWith('/..') -or $name.EndsWith('.') -or
|
||||||
(($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($name)) {
|
(($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($name)) {
|
||||||
throw "Unexpected or unsafe archive entry: $name"
|
throw "Unexpected or unsafe archive entry: $name"
|
||||||
@@ -92,7 +289,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
|
|||||||
throw 'Incomplete release: KSwitcher.Config.exe'
|
throw 'Incomplete release: KSwitcher.Config.exe'
|
||||||
}
|
}
|
||||||
} finally { $zip.Dispose() }
|
} finally { $zip.Dispose() }
|
||||||
foreach ($binary in @('KSwitcher.exe','KSwitcher.Config.exe')) {
|
foreach ($binary in @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe')) {
|
||||||
$exe = Join-Path $Destination $binary
|
$exe = Join-Path $Destination $binary
|
||||||
if (!(Test-Path -LiteralPath $exe)) { continue }
|
if (!(Test-Path -LiteralPath $exe)) { continue }
|
||||||
$version = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe)
|
$version = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe)
|
||||||
@@ -106,6 +303,75 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
|
|||||||
[BitConverter]::ToUInt16($bytes,$pe+4) -ne 0x8664) { throw 'Release is not a Windows x64 executable.' }
|
[BitConverter]::ToUInt16($bytes,$pe+4) -ne 0x8664) { throw 'Release is not a Windows x64 executable.' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function Get-Sha256([IO.Stream]$Stream) {
|
||||||
|
$sha=[Security.Cryptography.SHA256]::Create()
|
||||||
|
try { return [BitConverter]::ToString($sha.ComputeHash($Stream)).Replace('-','').ToLowerInvariant() }
|
||||||
|
finally { $sha.Dispose() }
|
||||||
|
}
|
||||||
|
function Assert-PackagePath([string]$Name) {
|
||||||
|
if (!$Name -or $Name.Length -gt 220 -or $Name -match '[\\:<>"|?*]' -or $Name.StartsWith('/')) { throw "Unsafe package path: $Name" }
|
||||||
|
$parts=$Name.Split('/')
|
||||||
|
foreach ($part in $parts) {
|
||||||
|
if (!$part -or $part -in @('.','..') -or $part -match '[. ]$|[\x00-\x1f]' -or
|
||||||
|
$part -match '^(?i:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\.|$)') { throw "Unsafe package path: $Name" }
|
||||||
|
}
|
||||||
|
if ($parts[0] -ieq 'data' -or $parts[0] -like '.kswitcher-*') { throw "Protected user path: $Name" }
|
||||||
|
}
|
||||||
|
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
|
||||||
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
|
$zip=[IO.Compression.ZipFile]::OpenRead($Archive)
|
||||||
|
try {
|
||||||
|
$manifests=@($zip.Entries | Where-Object FullName -CEQ 'release-files.json')
|
||||||
|
if (!$manifests.Count) {
|
||||||
|
if ($Expected -gt [version]'0.4.2') { throw 'Release manifest is missing.' }
|
||||||
|
$zip.Dispose(); $zip=$null
|
||||||
|
Expand-LegacyRelease $Archive $Destination $Expected
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ($manifests.Count -ne 1 -or $manifests[0].Length -gt 256KB) { throw 'Invalid release manifest.' }
|
||||||
|
$reader=[IO.StreamReader]::new($manifests[0].Open())
|
||||||
|
try { $manifest=$reader.ReadToEnd() | ConvertFrom-Json } finally { $reader.Dispose() }
|
||||||
|
if ($manifest.schema -ne 1 -or (Get-StableVersion $manifest.version) -ne $Expected -or
|
||||||
|
@($manifest.files).Count -lt 1 -or @($manifest.files).Count -gt 1000) { throw 'Unsupported or mismatched release manifest.' }
|
||||||
|
$declared=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||||
|
foreach ($file in $manifest.files) {
|
||||||
|
Assert-PackagePath $file.path
|
||||||
|
if ($file.path -ieq 'release-files.json' -or $declared.ContainsKey($file.path) -or
|
||||||
|
$file.sha256 -notmatch '^[a-fA-F0-9]{64}$' -or $file.size -lt 0 -or $file.size -gt 100MB) { throw 'Invalid manifest file entry.' }
|
||||||
|
$declared.Add($file.path,$file)
|
||||||
|
}
|
||||||
|
if (!$declared.ContainsKey('KSwitcher.exe')) { throw 'Main executable is missing from the manifest.' }
|
||||||
|
$seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||||
|
$total=0L
|
||||||
|
foreach ($entry in $zip.Entries) {
|
||||||
|
$name=$entry.FullName.Replace('\','/')
|
||||||
|
if ($name.EndsWith('/')) { Assert-PackagePath $name.TrimEnd('/'); continue }
|
||||||
|
Assert-PackagePath $name
|
||||||
|
if (!$seen.Add($name) -or (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000) { throw 'Duplicate or linked archive entry.' }
|
||||||
|
if ($name -ceq 'release-files.json') { continue }
|
||||||
|
if (!$declared.ContainsKey($name)) { throw "Undeclared package file: $name" }
|
||||||
|
$file=$declared[$name]
|
||||||
|
if ($entry.Length -ne $file.size) { throw "Package file size mismatch: $name" }
|
||||||
|
$total+=$entry.Length
|
||||||
|
if ($total -gt 100MB -or $zip.Entries.Count -gt 1100) { throw 'Release expands beyond allowed limits.' }
|
||||||
|
$stream=$entry.Open()
|
||||||
|
try { $hash=Get-Sha256 $stream } finally { $stream.Dispose() }
|
||||||
|
if ($hash -ne $file.sha256) { throw "Package file checksum mismatch: $name" }
|
||||||
|
}
|
||||||
|
if ($seen.Count -ne $declared.Count+1) { throw 'Archive does not contain every manifest file.' }
|
||||||
|
[IO.Directory]::CreateDirectory($Destination) | Out-Null
|
||||||
|
Assert-PlainDirectory $Destination
|
||||||
|
foreach ($entry in $zip.Entries) {
|
||||||
|
if ($entry.FullName.EndsWith('/')) { continue }
|
||||||
|
$target=Join-Path $Destination $entry.FullName
|
||||||
|
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($target)) | Out-Null
|
||||||
|
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($target))
|
||||||
|
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry,$target,$false)
|
||||||
|
}
|
||||||
|
$info=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $Destination 'KSwitcher.exe'))
|
||||||
|
if ($info.ProductName -ne 'KSwitcher' -or (Get-StableVersion $info.FileVersion) -ne $Expected) { throw 'Main executable identity/version mismatch.' }
|
||||||
|
} finally { if ($zip) { $zip.Dispose() } }
|
||||||
|
}
|
||||||
function Assert-PlainDirectory([string]$Directory) {
|
function Assert-PlainDirectory([string]$Directory) {
|
||||||
$item = Get-Item -LiteralPath $Directory -Force
|
$item = Get-Item -LiteralPath $Directory -Force
|
||||||
while ($item) {
|
while ($item) {
|
||||||
@@ -118,7 +384,7 @@ function Install-Files([string]$Stage,[string]$Target,[string]$Backup,[Collectio
|
|||||||
foreach ($file in @(Get-ChildItem -LiteralPath $Stage -File -Recurse)) {
|
foreach ($file in @(Get-ChildItem -LiteralPath $Stage -File -Recurse)) {
|
||||||
$relative = $file.FullName.Substring($Stage.Length+1)
|
$relative = $file.FullName.Substring($Stage.Length+1)
|
||||||
$destination = Join-Path $Target $relative
|
$destination = Join-Path $Target $relative
|
||||||
if ($relative.StartsWith('dictionaries\') -and (Test-Path -LiteralPath $destination)) { continue }
|
if ($relative.StartsWith('dictionaries\',[StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $destination)) { continue }
|
||||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
|
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
|
||||||
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($destination))
|
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($destination))
|
||||||
if (Test-Path -LiteralPath $destination) {
|
if (Test-Path -LiteralPath $destination) {
|
||||||
@@ -145,8 +411,9 @@ function Write-Result($Value) {
|
|||||||
if ($Library) { return }
|
if ($Library) { return }
|
||||||
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
|
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
|
||||||
$changed = [Collections.Generic.List[string]]::new()
|
$changed = [Collections.Generic.List[string]]::new()
|
||||||
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null
|
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null; $uiFinished = $false
|
||||||
try {
|
try {
|
||||||
|
if ($ShowProgress -or $Mode -eq 'Install') { Start-UpdateProgress }
|
||||||
$current = Get-StableVersion $CurrentVersion
|
$current = Get-StableVersion $CurrentVersion
|
||||||
if (!$current) { throw 'Invalid installed version.' }
|
if (!$current) { throw 'Invalid installed version.' }
|
||||||
if ($Mode -eq 'Install') {
|
if ($Mode -eq 'Install') {
|
||||||
@@ -167,23 +434,35 @@ try {
|
|||||||
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
|
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
|
||||||
[IO.Directory]::CreateDirectory($stage) | Out-Null
|
[IO.Directory]::CreateDirectory($stage) | Out-Null
|
||||||
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt'
|
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt'
|
||||||
Receive-File $release.zip $archive 50MB
|
Show-UpdateProgress 'Downloading update' $release.version 0
|
||||||
|
Receive-File $release.zip $archive 50MB 'Downloading update'
|
||||||
|
Show-UpdateProgress 'Verifying download' 'Checking the archive SHA-256 checksum'
|
||||||
Receive-File $release.checksum $checksum 64KB
|
Receive-File $release.checksum $checksum 64KB
|
||||||
Assert-Checksum $archive $checksum
|
Assert-Checksum $archive $checksum
|
||||||
|
Show-UpdateProgress 'Preparing installation' 'Validating and unpacking the release files'
|
||||||
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
||||||
|
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
|
||||||
|
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
|
||||||
Write-Result @{state='prepared';version=$release.version}
|
Write-Result @{state='prepared';version=$release.version}
|
||||||
|
Show-UpdateProgress 'Closing KSwitcher' 'Waiting for the running application to exit safely'
|
||||||
$ready.Set() | Out-Null
|
$ready.Set() | Out-Null
|
||||||
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
||||||
$parentExited = $true
|
$parentExited = $true
|
||||||
|
Show-UpdateProgress 'Installing update' 'Replacing application files; keeping your personal data'
|
||||||
Install-Files $stage $InstallDirectory $backup $changed
|
Install-Files $stage $InstallDirectory $backup $changed
|
||||||
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
||||||
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
|
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
|
||||||
|
Show-UpdateProgress 'Restarting KSwitcher' 'Waiting for the updated application to confirm startup'
|
||||||
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
|
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
|
||||||
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
|
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
|
||||||
$success = $true
|
$success = $true
|
||||||
|
if ($script:progressWindow) {
|
||||||
|
$uiFinished = Complete-UpdateProgress 'Update completed' "KSwitcher $(Get-StableVersion $release.version) is now running." $true
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
$message = $_.Exception.Message
|
$message = $_.Exception.Message
|
||||||
if ($parentExited) {
|
if ($parentExited) {
|
||||||
|
Show-UpdateProgress 'Restoring previous version' 'The update failed; recovering the previous installation'
|
||||||
try {
|
try {
|
||||||
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
|
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
|
||||||
Undo-Files $InstallDirectory $backup $changed
|
Undo-Files $InstallDirectory $backup $changed
|
||||||
@@ -196,8 +475,12 @@ try {
|
|||||||
}
|
}
|
||||||
Write-Result @{state='error';error=$message}
|
Write-Result @{state='error';error=$message}
|
||||||
if ($ready -and $ready.WaitOne(0)) {
|
if ($ready -and $ready.WaitOne(0)) {
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
if ($script:progressWindow) {
|
||||||
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
|
$uiFinished = Complete-UpdateProgress 'Update failed' $message $false
|
||||||
|
} else {
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
exit 1
|
exit 1
|
||||||
} finally {
|
} finally {
|
||||||
@@ -214,4 +497,8 @@ try {
|
|||||||
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ($script:progressWindow) {
|
||||||
|
if ($uiFinished) { $script:progressWindow.WaitForClose() }
|
||||||
|
$script:progressWindow.Dispose(); $script:progressWindow = $null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+590
-92
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
|||||||
#include "autocorrect.h"
|
#include "autocorrect.h"
|
||||||
#include "storage.h"
|
#include "storage.h"
|
||||||
#include "update.h"
|
#include "update.h"
|
||||||
|
#include "sync.h"
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <condition_variable>
|
#include <condition_variable>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
@@ -16,6 +17,7 @@ inline constexpr UINT TrayMessage = WM_APP + 1, ErrorMessage = WM_APP + 2, Histo
|
|||||||
inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, AutoCorrect = 6,
|
inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, AutoCorrect = 6,
|
||||||
Screenshot = 7, Direct = 100;
|
Screenshot = 7, Direct = 100;
|
||||||
inline constexpr UINT AutoCorrectMessage = WM_APP + 6;
|
inline constexpr UINT AutoCorrectMessage = WM_APP + 6;
|
||||||
|
inline constexpr UINT ReplayTypingMessage = WM_APP + 30;
|
||||||
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
|
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
|
||||||
struct LastTransform
|
struct LastTransform
|
||||||
{
|
{
|
||||||
@@ -37,6 +39,7 @@ class App
|
|||||||
Settings settings;
|
Settings settings;
|
||||||
std::unique_ptr<History> history;
|
std::unique_ptr<History> history;
|
||||||
AutoCorrector autoCorrector;
|
AutoCorrector autoCorrector;
|
||||||
|
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
|
||||||
std::atomic<bool> busy{false}, suspended{false};
|
std::atomic<bool> busy{false}, suspended{false};
|
||||||
std::atomic<DWORD> ownSequence{0};
|
std::atomic<DWORD> ownSequence{0};
|
||||||
std::atomic<uint64_t> inputVersion{0};
|
std::atomic<uint64_t> inputVersion{0};
|
||||||
@@ -61,6 +64,7 @@ class App
|
|||||||
void recognizeEntry(std::wstring);
|
void recognizeEntry(std::wstring);
|
||||||
void captureHistory();
|
void captureHistory();
|
||||||
void enqueue(std::function<void()>);
|
void enqueue(std::function<void()>);
|
||||||
|
void enqueueHistorySearch(std::function<void()>);
|
||||||
void report(const std::wstring &);
|
void report(const std::wstring &);
|
||||||
void trayMenu();
|
void trayMenu();
|
||||||
void addTray();
|
void addTray();
|
||||||
@@ -68,12 +72,16 @@ class App
|
|||||||
void applySettings(const Settings &);
|
void applySettings(const Settings &);
|
||||||
void reloadLayouts();
|
void reloadLayouts();
|
||||||
void clearHistory();
|
void clearHistory();
|
||||||
|
void clearLog(bool previous);
|
||||||
void setHotkeyCapture(HWND);
|
void setHotkeyCapture(HWND);
|
||||||
bool isHotkey(DWORD, UINT);
|
bool isHotkey(DWORD, UINT);
|
||||||
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
||||||
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
|
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
|
||||||
void resetInput();
|
void resetInput();
|
||||||
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
|
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
|
||||||
|
std::wstring syncCommand(int action = 0, const std::vector<SyncChoice> &choices = {});
|
||||||
|
Bytes syncConflictData();
|
||||||
|
void pollSync();
|
||||||
static App *current;
|
static App *current;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -83,24 +91,50 @@ class App
|
|||||||
std::condition_variable jobsReady;
|
std::condition_variable jobsReady;
|
||||||
std::deque<std::function<void()>> jobs;
|
std::deque<std::function<void()>> jobs;
|
||||||
std::thread worker;
|
std::thread worker;
|
||||||
|
std::thread historySearchWorker;
|
||||||
|
std::condition_variable historySearchReady;
|
||||||
|
std::function<void()> pendingHistorySearch;
|
||||||
|
std::thread historyOcrWorker;
|
||||||
|
std::condition_variable historyOcrReady;
|
||||||
|
bool historyOcrRequested{};
|
||||||
|
Settings historyOcrSettings;
|
||||||
|
void scheduleHistoryOcr(const Settings &);
|
||||||
std::thread updateWorker;
|
std::thread updateWorker;
|
||||||
|
std::thread syncWorker;
|
||||||
|
std::atomic<bool> syncDone{false};
|
||||||
|
bool syncRunning{};
|
||||||
|
ULONGLONG nextSync{}, syncRetryAfter{};
|
||||||
|
SyncResult syncResult;
|
||||||
|
SyncConfig syncConnection;
|
||||||
|
SyncConfig conflictConnection;
|
||||||
|
std::vector<SyncConflict> pendingSyncConflicts;
|
||||||
|
Bytes syncSnapshot;
|
||||||
|
std::wstring syncStatus = L"Not connected.";
|
||||||
bool updateRunning{}, installingUpdate{};
|
bool updateRunning{}, installingUpdate{};
|
||||||
std::wstring availableUpdate;
|
std::wstring availableUpdate, notifiedUpdate;
|
||||||
|
bool (*updateNotifier)(const std::wstring &){};
|
||||||
bool stop{};
|
bool stop{};
|
||||||
UINT taskbarCreated{};
|
UINT taskbarCreated{};
|
||||||
uint64_t pendingAutoVersion{};
|
|
||||||
uint64_t transformAttempt{}, previousAttemptVersion{};
|
uint64_t transformAttempt{}, previousAttemptVersion{};
|
||||||
ULONGLONG previousAttemptAt{};
|
ULONGLONG previousAttemptAt{};
|
||||||
HWND previousAttemptWindow{};
|
HWND previousAttemptWindow{};
|
||||||
int previousAttemptCommand{}, repeatAttempts{};
|
int previousAttemptCommand{}, repeatAttempts{};
|
||||||
std::vector<std::pair<int, Hotkey>> hookBindings;
|
std::vector<std::pair<int, Hotkey>> hookBindings;
|
||||||
HotkeyHook hotkeyHook;
|
HotkeyHook hotkeyHook;
|
||||||
|
HWND typingTarget{};
|
||||||
|
bool autoPending{};
|
||||||
|
int snippetPending = -1;
|
||||||
|
bool snippetTyping{};
|
||||||
|
std::vector<INPUT> deferredTyping;
|
||||||
|
bool beginTypingDeferral(HWND, uint64_t, bool snippet = false);
|
||||||
|
void replayTyping();
|
||||||
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
||||||
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
||||||
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
|
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
|
||||||
};
|
};
|
||||||
void showHistory(App &);
|
void showHistory(App &, bool visible = true);
|
||||||
void showScreenshot(App &);
|
void launchScreenshot(App &);
|
||||||
|
void launchScreenshotEditor(App &, const Clip &);
|
||||||
void refreshHistory(App &);
|
void refreshHistory(App &);
|
||||||
void showSettings(App &, bool visible = true);
|
void showSettings(App &, bool visible = true);
|
||||||
void showLogs(App &);
|
void showLogs(App &);
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Included by app.cpp; all App mutations happen on its window thread.
|
||||||
|
Bytes App::syncConflictData()
|
||||||
|
{
|
||||||
|
if (loadSyncConfig(data) != conflictConnection) return encodeSyncConflicts({});
|
||||||
|
return encodeSyncConflicts(pendingSyncConflicts);
|
||||||
|
}
|
||||||
|
std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices)
|
||||||
|
{
|
||||||
|
if (action == 0) return syncStatus;
|
||||||
|
if (syncRunning) return L"Synchronization is already running.";
|
||||||
|
auto c = loadSyncConfig(data);
|
||||||
|
if (!c.enabled) return syncStatus = L"Synchronization is disabled.";
|
||||||
|
validateSyncConfig(c);
|
||||||
|
if (action == 4 && (c != conflictConnection || choices.empty()))
|
||||||
|
throw std::runtime_error("Connection changed or no choices selected. Sync again.");
|
||||||
|
if (busy) return L"Wait for the current text operation to finish.";
|
||||||
|
syncRetryAfter = 0; // Explicit retries may bypass the automatic error backoff.
|
||||||
|
if (syncWorker.joinable()) syncWorker.join();
|
||||||
|
syncConnection = c;
|
||||||
|
syncSnapshot = encodeSettings(settings);
|
||||||
|
auto snapshot = settings;
|
||||||
|
auto availableLayouts = layouts;
|
||||||
|
syncRunning = true;
|
||||||
|
syncDone = false;
|
||||||
|
syncStatus = L"Connecting to OpenBao...";
|
||||||
|
nextSync = GetTickCount64() + SyncIntervalMs;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
syncWorker = std::thread([this, c, snapshot, availableLayouts, action, choices] {
|
||||||
|
SyncResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
auto provider = makeSyncProvider(c);
|
||||||
|
// An explicit choice also repairs a missing/corrupt local baseline.
|
||||||
|
auto base = action == 1 || action == 4 ? loadSyncBase(data, c) : Bytes{};
|
||||||
|
result = synchronize(*provider, base, sharedSyncSettings(snapshot),
|
||||||
|
action == 2 ? SyncAction::Download : action == 3 ? SyncAction::Upload : SyncAction::Automatic,
|
||||||
|
[&](const Bytes &b) { restoreSyncSettings(b, snapshot, availableLayouts, c.keepLocalLayouts); }, choices,
|
||||||
|
c.keepLocalLayouts);
|
||||||
|
if (result.success && c.keepLocalLayouts)
|
||||||
|
result.status += L" Partial sync: this PC's layouts and their shortcuts were kept; server layouts were preserved.";
|
||||||
|
}
|
||||||
|
catch (const nlohmann::json::exception &) { result.status = L"Invalid sync data or server response."; }
|
||||||
|
catch (const std::exception &e) { result.status = wide(e.what()); }
|
||||||
|
syncResult = std::move(result);
|
||||||
|
syncDone.store(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (...) { syncRunning = false; throw; }
|
||||||
|
return syncStatus;
|
||||||
|
}
|
||||||
|
void App::pollSync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (syncRunning && syncDone.load())
|
||||||
|
{
|
||||||
|
if (busy) return;
|
||||||
|
syncWorker.join();
|
||||||
|
syncRunning = false;
|
||||||
|
syncDone = false;
|
||||||
|
syncStatus = syncResult.status;
|
||||||
|
if (syncResult.success)
|
||||||
|
{
|
||||||
|
if (loadSyncConfig(data) != syncConnection || encodeSettings(settings) != syncSnapshot)
|
||||||
|
{
|
||||||
|
syncStatus = L"Local settings or connection changed during sync. Checking again shortly.";
|
||||||
|
nextSync = GetTickCount64() + 2000;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto next = restoreSyncSettings(syncResult.settings, settings, layouts, syncConnection.keepLocalLayouts);
|
||||||
|
if (encodeSettings(next) != syncSnapshot)
|
||||||
|
{
|
||||||
|
atomicWrite(data / L"settings-before-sync.bin", protect(syncSnapshot));
|
||||||
|
applySettings(next);
|
||||||
|
}
|
||||||
|
saveSyncBase(data, syncConnection, syncResult.base.empty() ? syncResult.settings : syncResult.base);
|
||||||
|
pendingSyncConflicts = syncResult.conflicts;
|
||||||
|
conflictConnection = syncConnection;
|
||||||
|
SYSTEMTIME time{}; GetLocalTime(&time);
|
||||||
|
wchar_t stamp[32]{};
|
||||||
|
swprintf(stamp, std::size(stamp), L" (%02u:%02u:%02u)", time.wHour, time.wMinute, time.wSecond);
|
||||||
|
syncStatus += stamp;
|
||||||
|
}
|
||||||
|
// Back off after errors (including bad credentials) instead of repeatedly
|
||||||
|
// hitting userpass lockout. The explicit Sync button remains available.
|
||||||
|
syncRetryAfter = syncResult.success ? 0 : GetTickCount64() + 15 * 60000;
|
||||||
|
nextSync = syncResult.success ? GetTickCount64() + SyncIntervalMs : syncRetryAfter;
|
||||||
|
}
|
||||||
|
if (!syncRunning && GetTickCount64() >= nextSync)
|
||||||
|
{
|
||||||
|
nextSync = GetTickCount64() + SyncIntervalMs;
|
||||||
|
if (loadSyncConfig(data).enabled) syncCommand(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (const nlohmann::json::exception &) { syncStatus = L"Invalid local sync configuration or cached profile."; nextSync = GetTickCount64() + SyncIntervalMs; }
|
||||||
|
catch (const std::exception &e) { syncStatus = wide(e.what()); nextSync = GetTickCount64() + SyncIntervalMs; }
|
||||||
|
}
|
||||||
+102
-10
@@ -70,9 +70,18 @@ AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path)
|
|||||||
}
|
}
|
||||||
if (offsets.empty())
|
if (offsets.empty())
|
||||||
throw std::runtime_error("Empty auto-correction dictionary");
|
throw std::runtime_error("Empty auto-correction dictionary");
|
||||||
for (size_t i = 1; i < offsets.size(); ++i)
|
auto entry = [&](uint32_t offset) { return std::string_view(bytes).substr(offset, bytes.find('\t', offset) - offset); };
|
||||||
if (word(i - 1) >= word(i))
|
auto frequency = [&](uint32_t offset)
|
||||||
throw std::runtime_error("Dictionary must be uniquely sorted");
|
{
|
||||||
|
int rank{};
|
||||||
|
auto start = offset + entry(offset).size() + 1;
|
||||||
|
std::from_chars(bytes.data() + start, bytes.data() + bytes.size(), rank);
|
||||||
|
return rank;
|
||||||
|
};
|
||||||
|
std::sort(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
|
||||||
|
{ return entry(a) == entry(b) ? frequency(a) < frequency(b) : entry(a) < entry(b); });
|
||||||
|
offsets.erase(std::unique(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
|
||||||
|
{ return entry(a) == entry(b); }), offsets.end());
|
||||||
offsets.shrink_to_fit();
|
offsets.shrink_to_fit();
|
||||||
bytes.shrink_to_fit();
|
bytes.shrink_to_fit();
|
||||||
}
|
}
|
||||||
@@ -143,7 +152,11 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
suffix.insert(suffix.begin(), word.back());
|
suffix.insert(suffix.begin(), word.back());
|
||||||
word.pop_back();
|
word.pop_back();
|
||||||
}
|
}
|
||||||
if (!safeAutoWord(word, settings.autoMinLength) || suffix.size() > 6 || rejected.contains(lower(word)))
|
// Punctuation keys can be letters in the target layout (for example ',' -> 'б').
|
||||||
|
// Validate letters and capitalization after conversion, not before it.
|
||||||
|
if (settings.autoMinLength < 1 || settings.autoMinLength > 32 ||
|
||||||
|
word.size() < (size_t)settings.autoMinLength || word.size() > 32 ||
|
||||||
|
suffix.size() > 6 || rejected.contains(lower(word)))
|
||||||
return {};
|
return {};
|
||||||
for (auto &never : settings.autoNever)
|
for (auto &never : settings.autoNever)
|
||||||
if (lower(never) == lower(word))
|
if (lower(never) == lower(word))
|
||||||
@@ -151,12 +164,42 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
auto sourceLanguage = layoutLanguage(layouts[source]);
|
auto sourceLanguage = layoutLanguage(layouts[source]);
|
||||||
if (!dictionaries.contains(sourceLanguage))
|
if (!dictionaries.contains(sourceLanguage))
|
||||||
return {};
|
return {};
|
||||||
|
const std::wstring alphabet = sourceLanguage == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
|
||||||
|
sourceLanguage == L"uk" ? L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя" : L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя";
|
||||||
|
auto small = lower(word);
|
||||||
|
bool sameLanguage = safeAutoWord(word, settings.autoMinLength) &&
|
||||||
|
small.find_first_not_of(alphabet) == small.npos &&
|
||||||
|
std::find(settings.cycle.begin(), settings.cycle.end(), layouts[source].id) != settings.cycle.end();
|
||||||
|
auto sameLayout = [&](std::wstring text)
|
||||||
|
{
|
||||||
|
if (word != small) text[0] = upper(text.substr(0, 1))[0];
|
||||||
|
return AutoSuggestion{source, source, prefix + text + suffix};
|
||||||
|
};
|
||||||
|
if (settings.autoReplace && sameLanguage)
|
||||||
|
{
|
||||||
|
auto replacement = settings.autoReplacements.find(small);
|
||||||
|
if (replacement != settings.autoReplacements.end() &&
|
||||||
|
replacement->second.find_first_not_of(alphabet) == small.npos)
|
||||||
|
return sameLayout(replacement->second);
|
||||||
|
}
|
||||||
|
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
|
||||||
|
{
|
||||||
|
auto found = settings.autoWords.find(language);
|
||||||
|
return found != settings.autoWords.end() &&
|
||||||
|
std::any_of(found->second.begin(), found->second.end(), [&](const std::wstring &known)
|
||||||
|
{ return lower(known) == lower(candidate); });
|
||||||
|
};
|
||||||
// Valid words from either selected language are never changed.
|
// Valid words from either selected language are never changed.
|
||||||
for (auto &[language, d] : dictionaries)
|
for (auto &[language, d] : dictionaries)
|
||||||
if (d.rank(word))
|
if (d.rank(word) || personal(language, word))
|
||||||
return {};
|
return {};
|
||||||
std::optional<AutoSuggestion> candidate;
|
std::optional<AutoSuggestion> candidate;
|
||||||
int matches = 0;
|
std::optional<std::wstring> matchedText;
|
||||||
|
auto priority = [&](int layout)
|
||||||
|
{
|
||||||
|
auto language = layoutLanguage(layouts[layout]);
|
||||||
|
return std::find(settings.autoLanguages.begin(), settings.autoLanguages.end(), language) - settings.autoLanguages.begin();
|
||||||
|
};
|
||||||
for (int i = 0; i < (int)layouts.size(); ++i)
|
for (int i = 0; i < (int)layouts.size(); ++i)
|
||||||
{
|
{
|
||||||
if (i == source || !layouts[i].convertible ||
|
if (i == source || !layouts[i].convertible ||
|
||||||
@@ -168,15 +211,64 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
auto converted = convert(word, layouts[source], layouts[i], true);
|
auto converted = convert(word, layouts[source], layouts[i], true);
|
||||||
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
|
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
|
||||||
continue;
|
continue;
|
||||||
int rank = dictionary->second.rank(converted);
|
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
|
||||||
if (!rank)
|
if (!rank)
|
||||||
continue;
|
continue;
|
||||||
if (++matches > 1)
|
if (matchedText && *matchedText != converted)
|
||||||
return {};
|
return {};
|
||||||
if (settings.autoMaxRank == 0 || rank <= settings.autoMaxRank)
|
matchedText = converted;
|
||||||
|
if ((settings.autoMaxRank == 0 || rank <= settings.autoMaxRank) &&
|
||||||
|
(!candidate || priority(i) < priority(candidate->target)))
|
||||||
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
|
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
|
||||||
}
|
}
|
||||||
return candidate;
|
if (candidate || matchedText || !settings.autoTypos || !sameLanguage || word.size() < 4)
|
||||||
|
return candidate;
|
||||||
|
// Generate only single edits. Stop at a second distinct dictionary hit,
|
||||||
|
// including rare hits: frequency limits must never hide ambiguity.
|
||||||
|
std::wstring found;
|
||||||
|
int foundRank = 0;
|
||||||
|
std::set<std::wstring> personalTargets;
|
||||||
|
if (auto it = settings.autoWords.find(sourceLanguage); it != settings.autoWords.end())
|
||||||
|
for (const auto &text : it->second) personalTargets.insert(lower(text));
|
||||||
|
auto consider = [&](const std::wstring &text)
|
||||||
|
{
|
||||||
|
if (text == small || text == found || text.size() < 4 || text.size() > 32) return true;
|
||||||
|
int rank = personalTargets.contains(text) ? 1 : dictionaries.at(sourceLanguage).rank(text);
|
||||||
|
if (!rank) return true;
|
||||||
|
if (!found.empty()) return false;
|
||||||
|
found = text;
|
||||||
|
foundRank = rank;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
for (size_t i = 0; i < small.size(); ++i)
|
||||||
|
{
|
||||||
|
auto text = small;
|
||||||
|
text.erase(i, 1);
|
||||||
|
if (!consider(text)) return {};
|
||||||
|
if (i + 1 < small.size())
|
||||||
|
{
|
||||||
|
text = small;
|
||||||
|
std::swap(text[i], text[i + 1]);
|
||||||
|
if (!consider(text)) return {};
|
||||||
|
}
|
||||||
|
for (wchar_t c : alphabet)
|
||||||
|
{
|
||||||
|
text = small;
|
||||||
|
text[i] = c;
|
||||||
|
if (!consider(text)) return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (small.size() < 32)
|
||||||
|
for (size_t i = 0; i <= small.size(); ++i)
|
||||||
|
for (wchar_t c : alphabet)
|
||||||
|
{
|
||||||
|
auto text = small;
|
||||||
|
text.insert(i, 1, c);
|
||||||
|
if (!consider(text)) return {};
|
||||||
|
}
|
||||||
|
if (!found.empty() && (settings.autoMaxRank == 0 || foundRank <= settings.autoMaxRank))
|
||||||
|
return sameLayout(found);
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
int matchAutoInput(const Run &run, const std::wstring &text, const std::vector<Layout> &layouts,
|
int matchAutoInput(const Run &run, const std::wstring &text, const std::vector<Layout> &layouts,
|
||||||
const Layout &physical, const Settings &settings)
|
const Layout &physical, const Settings &settings)
|
||||||
|
|||||||
+26
-6
@@ -57,6 +57,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (wp == 21) showSnippets(*app);
|
if (wp == 21) showSnippets(*app);
|
||||||
|
else if (wp == 22) showLogs(*app);
|
||||||
else showSettings(*app);
|
else showSettings(*app);
|
||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
catch (const std::exception &e)
|
||||||
@@ -69,6 +70,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
|||||||
{
|
{
|
||||||
if (app->settingsWindow) DestroyWindow(app->settingsWindow);
|
if (app->settingsWindow) DestroyWindow(app->settingsWindow);
|
||||||
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
|
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
|
||||||
|
if (app->logWindow) DestroyWindow(app->logWindow);
|
||||||
PostQuitMessage(0);
|
PostQuitMessage(0);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -80,12 +82,14 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
|||||||
App::App(HINSTANCE h, bool, const std::filesystem::path &) : instance(h)
|
App::App(HINSTANCE h, bool, const std::filesystem::path &) : instance(h)
|
||||||
{
|
{
|
||||||
current = this;
|
current = this;
|
||||||
|
data = dataDirectory();
|
||||||
layouts = readLayouts();
|
layouts = readLayouts();
|
||||||
}
|
}
|
||||||
App::~App()
|
App::~App()
|
||||||
{
|
{
|
||||||
if (settingsWindow) DestroyWindow(settingsWindow);
|
if (settingsWindow) DestroyWindow(settingsWindow);
|
||||||
if (snippetsWindow) DestroyWindow(snippetsWindow);
|
if (snippetsWindow) DestroyWindow(snippetsWindow);
|
||||||
|
if (logWindow) DestroyWindow(logWindow);
|
||||||
if (window) DestroyWindow(window);
|
if (window) DestroyWindow(window);
|
||||||
current = nullptr;
|
current = nullptr;
|
||||||
}
|
}
|
||||||
@@ -107,6 +111,13 @@ void App::reloadLayouts()
|
|||||||
settings = decodeSettings(bytes, layouts);
|
settings = decodeSettings(bytes, layouts);
|
||||||
}
|
}
|
||||||
void App::clearHistory() { request(*this, ConfigClearHistory); }
|
void App::clearHistory() { request(*this, ConfigClearHistory); }
|
||||||
|
Bytes App::syncConflictData() { return request(*this, ConfigSyncConflicts); }
|
||||||
|
std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices)
|
||||||
|
{
|
||||||
|
auto result = action == 4 ? request(*this, ConfigSyncResolve, encodeSyncChoices(choices)) :
|
||||||
|
request(*this, ConfigSync, Bytes{static_cast<unsigned char>(action)});
|
||||||
|
return wide(std::string(result.begin(), result.end()));
|
||||||
|
}
|
||||||
void App::setHotkeyCapture(HWND field)
|
void App::setHotkeyCapture(HWND field)
|
||||||
{
|
{
|
||||||
hotkeyCapture = field;
|
hotkeyCapture = field;
|
||||||
@@ -120,9 +131,9 @@ void App::setHotkeyCapture(HWND field)
|
|||||||
if (field) MessageBoxW(settingsWindow, wide(e.what()).c_str(), L"Shortcut capture unavailable", MB_OK | MB_ICONWARNING);
|
if (field) MessageBoxW(settingsWindow, wide(e.what()).c_str(), L"Shortcut capture unavailable", MB_OK | MB_ICONWARNING);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void showLogs(App &app) { request(app, ConfigLogs); }
|
void App::clearLog(bool previous) { request(*this, ConfigClearLog, Bytes{static_cast<unsigned char>(previous)}); }
|
||||||
|
|
||||||
int runConfigurator(HINSTANCE instance, bool snippets)
|
int runConfigurator(HINSTANCE instance, bool snippets, bool logs)
|
||||||
{
|
{
|
||||||
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
|
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
|
||||||
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock.");
|
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock.");
|
||||||
@@ -140,7 +151,7 @@ int runConfigurator(HINSTANCE instance, bool snippets)
|
|||||||
throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly.");
|
throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly.");
|
||||||
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
|
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
|
||||||
AllowSetForegroundWindow(pid);
|
AllowSetForegroundWindow(pid);
|
||||||
PostMessageW(window, WM_APP + 20, snippets ? 21 : 20, 0);
|
PostMessageW(window, WM_APP + 20, logs ? 22 : snippets ? 21 : 20, 0);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
||||||
@@ -169,17 +180,25 @@ int runConfigurator(HINSTANCE instance, bool snippets)
|
|||||||
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
|
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
|
||||||
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
|
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
|
||||||
app.settings = decodeSettings(request(app, ConfigRead), app.layouts);
|
app.settings = decodeSettings(request(app, ConfigRead), app.layouts);
|
||||||
if (snippets) showSnippets(app); else showSettings(app);
|
setLoggingEnabled(app.settings.writeLog);
|
||||||
|
if (logs) showLogs(app); else if (snippets) showSnippets(app); else showSettings(app);
|
||||||
MSG message;
|
MSG message;
|
||||||
while ((app.settingsWindow || app.snippetsWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
while ((app.settingsWindow || app.snippetsWindow || app.logWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
||||||
{
|
{
|
||||||
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
|
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
|
||||||
{
|
{
|
||||||
HWND popup = GetLastActivePopup(app.settingsWindow);
|
HWND popup = GetLastActivePopup(app.settingsWindow);
|
||||||
|
while (!IsWindowEnabled(popup))
|
||||||
|
{
|
||||||
|
HWND next = GetLastActivePopup(popup);
|
||||||
|
if (next == popup) break;
|
||||||
|
popup = next;
|
||||||
|
}
|
||||||
if (popup != app.settingsWindow && IsDialogMessageW(popup, &message)) continue;
|
if (popup != app.settingsWindow && IsDialogMessageW(popup, &message)) continue;
|
||||||
}
|
}
|
||||||
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
|
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
|
||||||
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
|
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
|
||||||
|
if (app.logWindow && IsDialogMessageW(app.logWindow, &message)) continue;
|
||||||
TranslateMessage(&message); DispatchMessageW(&message);
|
TranslateMessage(&message); DispatchMessageW(&message);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
@@ -193,8 +212,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
|||||||
{
|
{
|
||||||
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
|
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
|
||||||
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
|
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
|
||||||
|
bool logs = count > 1 && std::wstring(args[1]) == L"--logs";
|
||||||
LocalFree(args);
|
LocalFree(args);
|
||||||
result = ks::runConfigurator(instance, snippets);
|
result = ks::runConfigurator(instance, snippets, logs);
|
||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
catch (const std::exception &e)
|
||||||
{
|
{
|
||||||
|
|||||||
+26
-2
@@ -14,12 +14,12 @@ bool configurationPeer(HWND window, const wchar_t *filename)
|
|||||||
auto expected = std::filesystem::path(executable()).parent_path() / filename;
|
auto expected = std::filesystem::path(executable()).parent_path() / filename;
|
||||||
return ok && _wcsicmp(path, expected.c_str()) == 0;
|
return ok && _wcsicmp(path, expected.c_str()) == 0;
|
||||||
}
|
}
|
||||||
void launchConfiguration(App &app, bool snippets)
|
void launchConfiguration(App &app, bool snippets, bool logs)
|
||||||
{
|
{
|
||||||
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
|
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
|
||||||
if (!std::filesystem::exists(path))
|
if (!std::filesystem::exists(path))
|
||||||
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package.");
|
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package.");
|
||||||
auto command = L"\"" + path.wstring() + L"\"" + (snippets ? L" --snippets" : L"");
|
auto command = L"\"" + path.wstring() + L"\"" + (logs ? L" --logs" : snippets ? L" --snippets" : L"");
|
||||||
STARTUPINFOW startup{sizeof(startup)};
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
PROCESS_INFORMATION process{};
|
PROCESS_INFORMATION process{};
|
||||||
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
||||||
@@ -40,6 +40,25 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
|
|||||||
{
|
{
|
||||||
switch (message.dwData)
|
switch (message.dwData)
|
||||||
{
|
{
|
||||||
|
case ConfigSyncConflicts:
|
||||||
|
reply = app.syncConflictData();
|
||||||
|
break;
|
||||||
|
case ConfigSyncResolve:
|
||||||
|
{
|
||||||
|
if (!message.cbData) throw std::runtime_error("Empty conflict choices.");
|
||||||
|
auto first = static_cast<const unsigned char *>(message.lpData);
|
||||||
|
auto status = utf8(app.syncCommand(4, decodeSyncChoices(Bytes(first, first + message.cbData))));
|
||||||
|
reply.assign(status.begin(), status.end());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ConfigSync:
|
||||||
|
{
|
||||||
|
if (message.cbData != 1 || *static_cast<const unsigned char *>(message.lpData) > 3)
|
||||||
|
throw std::runtime_error("Invalid sync command.");
|
||||||
|
auto status = utf8(app.syncCommand(*static_cast<const unsigned char *>(message.lpData)));
|
||||||
|
reply.assign(status.begin(), status.end());
|
||||||
|
break;
|
||||||
|
}
|
||||||
case ConfigSave:
|
case ConfigSave:
|
||||||
{
|
{
|
||||||
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
|
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
|
||||||
@@ -80,6 +99,11 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
|
|||||||
case ConfigLogs:
|
case ConfigLogs:
|
||||||
PostMessageW(app.window, WM_HOTKEY, 22, 0);
|
PostMessageW(app.window, WM_HOTKEY, 22, 0);
|
||||||
break;
|
break;
|
||||||
|
case ConfigClearLog:
|
||||||
|
if (message.cbData != 1 || *static_cast<const unsigned char *>(message.lpData) > 1)
|
||||||
|
throw std::runtime_error("Invalid log selection.");
|
||||||
|
app.clearLog(*static_cast<const unsigned char *>(message.lpData) != 0);
|
||||||
|
break;
|
||||||
default: return FALSE;
|
default: return FALSE;
|
||||||
}
|
}
|
||||||
ok = true;
|
ok = true;
|
||||||
|
|||||||
+4
-2
@@ -5,9 +5,11 @@ namespace ks
|
|||||||
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
||||||
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
|
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
|
||||||
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
|
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
|
||||||
ConfigReply = ConfigRead + 6;
|
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7,
|
||||||
|
ConfigSync = ConfigRead + 8, ConfigSyncConflicts = ConfigRead + 9,
|
||||||
|
ConfigSyncResolve = ConfigRead + 10, ConfigEnd = ConfigRead + 11;
|
||||||
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
||||||
bool configurationPeer(HWND, const wchar_t *filename);
|
bool configurationPeer(HWND, const wchar_t *filename);
|
||||||
void launchConfiguration(App &, bool snippets = false);
|
void launchConfiguration(App &, bool snippets = false, bool logs = false);
|
||||||
LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
|
LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-1
@@ -148,6 +148,26 @@ std::wstring convertTo(const std::wstring &text, const std::vector<Layout> &layo
|
|||||||
}
|
}
|
||||||
return best < 0 ? text : convert(text, layouts[best], to);
|
return best < 0 ? text : convert(text, layouts[best], to);
|
||||||
}
|
}
|
||||||
|
int nextConversionLayout(const std::wstring &text, const std::vector<Layout> &layouts,
|
||||||
|
const std::vector<int> &cycle, int hint)
|
||||||
|
{
|
||||||
|
if (cycle.empty())
|
||||||
|
return -1;
|
||||||
|
int source = detect(text, layouts, hint);
|
||||||
|
if (source < 0)
|
||||||
|
source = hint;
|
||||||
|
auto it = std::find(cycle.begin(), cycle.end(), source);
|
||||||
|
size_t start = it == cycle.end() ? 0 : (size_t(it - cycle.begin()) + 1) % cycle.size();
|
||||||
|
// Compare every candidate with the original input, never an intermediate conversion.
|
||||||
|
// Bound the search so digits, whitespace and identical layouts cannot loop forever.
|
||||||
|
for (size_t offset = 0; offset < cycle.size(); ++offset)
|
||||||
|
{
|
||||||
|
int target = cycle[(start + offset) % cycle.size()];
|
||||||
|
if (target != source && convertTo(text, layouts, target, hint) != text)
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
return cycle[start];
|
||||||
|
}
|
||||||
std::wstring cycleCase(const std::wstring &text)
|
std::wstring cycleCase(const std::wstring &text)
|
||||||
{
|
{
|
||||||
if (text == upper(text))
|
if (text == upper(text))
|
||||||
@@ -261,7 +281,17 @@ Template parseTemplate(const std::wstring &text)
|
|||||||
size_t cursor = std::wstring::npos;
|
size_t cursor = std::wstring::npos;
|
||||||
for (size_t i = 0; i < text.size();)
|
for (size_t i = 0; i < text.size();)
|
||||||
{
|
{
|
||||||
if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0)
|
if (text.compare(i, 9, L"{{enter}}") == 0)
|
||||||
|
{
|
||||||
|
t.text += L"{enter}";
|
||||||
|
i += 9;
|
||||||
|
}
|
||||||
|
else if (text.compare(i, 7, L"{enter}") == 0)
|
||||||
|
{
|
||||||
|
++t.enters;
|
||||||
|
i += 7;
|
||||||
|
}
|
||||||
|
else if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0)
|
||||||
{
|
{
|
||||||
t.text += L"{cursor}";
|
t.text += L"{cursor}";
|
||||||
i += 10;
|
i += 10;
|
||||||
@@ -449,6 +479,14 @@ void Run::backspace(HWND w, ULONGLONG now)
|
|||||||
keys.pop_back();
|
keys.pop_back();
|
||||||
at = now;
|
at = now;
|
||||||
}
|
}
|
||||||
|
int Run::matchSuffix(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
||||||
|
{
|
||||||
|
if (text.empty() || text.size() >= keys.size() || overflow)
|
||||||
|
return -1;
|
||||||
|
Run tail = *this;
|
||||||
|
tail.keys = keys.substr(keys.size() - text.size());
|
||||||
|
return tail.match(text, layouts, physical);
|
||||||
|
}
|
||||||
int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
||||||
{
|
{
|
||||||
if (text.size() != keys.size() || overflow)
|
if (text.size() != keys.size() || overflow)
|
||||||
|
|||||||
+17
-1
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <map>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -23,16 +24,22 @@ std::wstring upper(std::wstring text);
|
|||||||
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
|
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
|
||||||
int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1);
|
int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1);
|
||||||
std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint);
|
std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint);
|
||||||
|
int nextConversionLayout(const std::wstring &, const std::vector<Layout> &,
|
||||||
|
const std::vector<int> &cycle, int hint);
|
||||||
std::wstring cycleCase(const std::wstring &);
|
std::wstring cycleCase(const std::wstring &);
|
||||||
std::wstring transliterate(const std::wstring &);
|
std::wstring transliterate(const std::wstring &);
|
||||||
struct Snippet
|
struct Snippet
|
||||||
{
|
{
|
||||||
std::wstring trigger, text;
|
std::wstring trigger, text;
|
||||||
|
std::wstring id;
|
||||||
};
|
};
|
||||||
|
std::wstring snippetId(const Snippet &);
|
||||||
|
std::wstring newSnippetId();
|
||||||
struct Template
|
struct Template
|
||||||
{
|
{
|
||||||
std::wstring text, suffix;
|
std::wstring text, suffix;
|
||||||
int cursors = 0;
|
int cursors = 0;
|
||||||
|
int enters = 0;
|
||||||
};
|
};
|
||||||
Template parseTemplate(const std::wstring &);
|
Template parseTemplate(const std::wstring &);
|
||||||
struct Hotkey
|
struct Hotkey
|
||||||
@@ -47,23 +54,31 @@ struct Settings
|
|||||||
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
||||||
bool elevatedAutostart = false;
|
bool elevatedAutostart = false;
|
||||||
bool autoCorrect = false;
|
bool autoCorrect = false;
|
||||||
|
bool autoReplace = false, autoTypos = false;
|
||||||
|
std::map<std::wstring, std::wstring> autoReplacements;
|
||||||
bool writeLog = true;
|
bool writeLog = true;
|
||||||
|
bool screenshotAutoOpenEditor = false;
|
||||||
|
bool useTesseract = false;
|
||||||
|
std::wstring tesseractPath;
|
||||||
|
std::wstring tesseractLanguages = L"ukr+eng+rus";
|
||||||
int autoMaxRank = 0;
|
int autoMaxRank = 0;
|
||||||
int autoMinLength = 3;
|
int autoMinLength = 3;
|
||||||
std::wstring autoPrefixChars = L"([{«“‘\"";
|
std::wstring autoPrefixChars = L"([{«“‘\"";
|
||||||
std::wstring autoSuffixChars = L")]}»”’\".,!?:;";
|
std::wstring autoSuffixChars = L")]}»”’\".,!?:;";
|
||||||
std::vector<std::wstring> autoLanguages{L"en", L"uk"};
|
std::vector<std::wstring> autoLanguages{L"en", L"uk"};
|
||||||
std::vector<std::wstring> autoNever;
|
std::vector<std::wstring> autoNever;
|
||||||
|
std::map<std::wstring, std::vector<std::wstring>> autoWords;
|
||||||
std::vector<std::wstring> autoExcluded{
|
std::vector<std::wstring> autoExcluded{
|
||||||
L"Code", L"rider64", L"devenv", L"WindowsTerminal", L"cmd", L"powershell", L"pwsh",
|
L"Code", L"rider64", L"devenv", L"WindowsTerminal", L"cmd", L"powershell", L"pwsh",
|
||||||
L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||||
int maxItems = 500, maxMiB = 200, maxImageMiB = 20;
|
int maxItems = 500, maxMiB = 200, maxImageMiB = 20;
|
||||||
std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C",
|
std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C",
|
||||||
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Ctrl+Shift+V",
|
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Win+V",
|
||||||
screenshot = L"Win+Shift+S";
|
screenshot = L"Win+Shift+S";
|
||||||
std::vector<std::wstring> cycle, direct, directIds;
|
std::vector<std::wstring> cycle, direct, directIds;
|
||||||
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||||
std::vector<Snippet> snippets;
|
std::vector<Snippet> snippets;
|
||||||
|
std::vector<std::wstring> snippetTombstones;
|
||||||
};
|
};
|
||||||
// Consume repeats and key-up for intercepted chords, even if settings change.
|
// Consume repeats and key-up for intercepted chords, even if settings change.
|
||||||
struct HotkeyHook
|
struct HotkeyHook
|
||||||
@@ -85,5 +100,6 @@ struct Run
|
|||||||
void record(wchar_t, HWND, HKL, ULONGLONG);
|
void record(wchar_t, HWND, HKL, ULONGLONG);
|
||||||
void backspace(HWND, ULONGLONG);
|
void backspace(HWND, ULONGLONG);
|
||||||
int match(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
|
int match(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
|
||||||
|
int matchSuffix(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
|
||||||
};
|
};
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "../vendor/json.hpp"
|
||||||
|
#include "platform.h"
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
namespace ks
|
||||||
|
{
|
||||||
|
using DiagnosticJson = nlohmann::json;
|
||||||
|
struct OperationLogRow
|
||||||
|
{
|
||||||
|
std::wstring time, process, command, source, target, outcome, details;
|
||||||
|
bool problem = true;
|
||||||
|
};
|
||||||
|
inline std::vector<OperationLogRow> parseOperationLog(const std::wstring &contents)
|
||||||
|
{
|
||||||
|
std::vector<OperationLogRow> rows;
|
||||||
|
std::wistringstream stream(contents);
|
||||||
|
std::wstring line;
|
||||||
|
OperationLogRow *legacy = nullptr;
|
||||||
|
while (std::getline(stream, line))
|
||||||
|
{
|
||||||
|
if (!line.empty() && line.back() == L'\r')
|
||||||
|
line.pop_back();
|
||||||
|
if (line.empty())
|
||||||
|
continue;
|
||||||
|
auto marker = line.find(L" Operation {");
|
||||||
|
if (marker != line.npos)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
auto j = DiagnosticJson::parse(utf8(line.substr(marker + 11)));
|
||||||
|
auto field = [&](const char *key) { return wide(j.value(key, std::string{})); };
|
||||||
|
OperationLogRow row{line.substr(0, marker), field("process"), field("command"),
|
||||||
|
field("source"), field("target"), field("outcome")};
|
||||||
|
row.problem = j.value("problem", true);
|
||||||
|
row.details =
|
||||||
|
L"Operation: " + field("attempt") + L"\r\nResult: " + row.outcome +
|
||||||
|
L"\r\nVerification: " + field("verification") + L"\r\nElapsed: " +
|
||||||
|
std::to_wstring(j.value("elapsedMs", 0ULL)) + L" ms\r\n\r\nOriginal:\r\n" +
|
||||||
|
field("original") + L"\r\n\r\nExpected selection:\r\n" + field("selectionExpected") +
|
||||||
|
L"\r\n\r\nExpected result / verification target:\r\n" + field("expected") +
|
||||||
|
L"\r\n\r\nRead back:\r\n" + field("actual") + L"\r\n\r\nForeground unchanged: " +
|
||||||
|
(j.value("foregroundSame", false) ? L"yes" : L"no") + L"\r\nInput context unchanged: " +
|
||||||
|
(j.value("inputSame", false) ? L"yes" : L"no") + L"\r\nText truncated: " +
|
||||||
|
(j.value("textTruncated", false) ? L"yes (4096 UTF-16 units per field)" : L"no") +
|
||||||
|
L"\r\n\r\nContext and stages:\r\n" + field("context");
|
||||||
|
for (size_t at = 0; (at = row.details.find(L'\n', at)) != row.details.npos; ++at)
|
||||||
|
if (at == 0 || row.details[at - 1] != L'\r')
|
||||||
|
row.details.insert(at++, 1, L'\r');
|
||||||
|
rows.push_back(std::move(row));
|
||||||
|
legacy = nullptr;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
} // Preserve malformed and old records for diagnosis.
|
||||||
|
}
|
||||||
|
if (line.front() != L' ' || !legacy)
|
||||||
|
{
|
||||||
|
rows.push_back({L"", L"", L"Legacy / event", L"", L"", L"Unstructured event", line});
|
||||||
|
legacy = &rows.back();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
legacy->details += L"\r\n" + line;
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
inline std::wstring conversionReadbackOutcome(const std::wstring &actual, const std::wstring &original,
|
||||||
|
const std::wstring &expected)
|
||||||
|
{
|
||||||
|
if (actual == expected)
|
||||||
|
return L"Paste verified";
|
||||||
|
if (actual == original)
|
||||||
|
return L"Original text remained";
|
||||||
|
return L"Readback differs";
|
||||||
|
}
|
||||||
|
} // namespace ks
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <map>
|
||||||
|
#include <set>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace ks {
|
||||||
|
// Keep the visible working set even in unusually tall windows. Never evict a
|
||||||
|
// visible preview to admit another visible preview: that creates a paint loop.
|
||||||
|
template<class T>
|
||||||
|
void trimHistoryPreviews(std::map<std::wstring,T> &cache,
|
||||||
|
const std::set<std::wstring> &visible, size_t limit=32)
|
||||||
|
{
|
||||||
|
for(auto it=cache.begin(); cache.size()>limit && it!=cache.end();)
|
||||||
|
if(!visible.contains(it->first)) it=cache.erase(it);
|
||||||
|
else ++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,12 +45,15 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
|||||||
cmd = 21;
|
cmd = 21;
|
||||||
else if (arg == L"--exit")
|
else if (arg == L"--exit")
|
||||||
cmd = 99;
|
cmd = 99;
|
||||||
|
else if (arg == L"--check-update")
|
||||||
|
cmd = 23;
|
||||||
LocalFree(argv);
|
LocalFree(argv);
|
||||||
if (existing)
|
if (existing)
|
||||||
{
|
{
|
||||||
HWND window = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
HWND window = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
||||||
if (window)
|
if (window)
|
||||||
{
|
{
|
||||||
|
if (cmd == 23) { DWORD pid{}; GetWindowThreadProcessId(window, &pid); AllowSetForegroundWindow(pid); }
|
||||||
COPYDATASTRUCT data{1, sizeof(cmd), &cmd};
|
COPYDATASTRUCT data{1, sizeof(cmd), &cmd};
|
||||||
DWORD_PTR result{};
|
DWORD_PTR result{};
|
||||||
SendMessageTimeoutW(window, WM_COPYDATA, 0, (LPARAM)&data, SMTO_ABORTIFHUNG, 1000, &result);
|
SendMessageTimeoutW(window, WM_COPYDATA, 0, (LPARAM)&data, SMTO_ABORTIFHUNG, 1000, &result);
|
||||||
|
|||||||
+152
@@ -186,6 +186,43 @@ Pixels decodeImage(const Clip &clip, UINT maxWidth, UINT maxHeight)
|
|||||||
check(converter->CopyPixels(nullptr, pixels.width * 4, (UINT)pixels.bgra.size(), pixels.bgra.data()));
|
check(converter->CopyPixels(nullptr, pixels.width * 4, (UINT)pixels.bgra.size(), pixels.bgra.data()));
|
||||||
return pixels;
|
return pixels;
|
||||||
}
|
}
|
||||||
|
Bytes encodePng(const Pixels &p)
|
||||||
|
{
|
||||||
|
if (!p.width || !p.height || uint64_t(p.width) * p.height * 4 != p.bgra.size() ||
|
||||||
|
p.bgra.size() > UINT_MAX || p.width > UINT_MAX / 4)
|
||||||
|
throw std::runtime_error("Invalid PNG image dimensions");
|
||||||
|
Com<IWICImagingFactory> factory;
|
||||||
|
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
|
||||||
|
IID_PPV_ARGS(factory.out())));
|
||||||
|
Com<IStream> stream;
|
||||||
|
check(CreateStreamOnHGlobal(nullptr, TRUE, stream.out()));
|
||||||
|
Com<IWICBitmapEncoder> encoder;
|
||||||
|
check(factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.out()));
|
||||||
|
check(encoder->Initialize(stream.p, WICBitmapEncoderNoCache));
|
||||||
|
Com<IWICBitmapFrameEncode> frame;
|
||||||
|
check(encoder->CreateNewFrame(frame.out(), nullptr));
|
||||||
|
check(frame->Initialize(nullptr));
|
||||||
|
check(frame->SetSize(p.width, p.height));
|
||||||
|
auto format = GUID_WICPixelFormat32bppBGRA;
|
||||||
|
check(frame->SetPixelFormat(&format));
|
||||||
|
if (format != GUID_WICPixelFormat32bppBGRA)
|
||||||
|
throw std::runtime_error("Unsupported PNG pixel format");
|
||||||
|
check(frame->WritePixels(p.height, p.width * 4, (UINT)p.bgra.size(),
|
||||||
|
const_cast<BYTE *>(p.bgra.data())));
|
||||||
|
check(frame->Commit());
|
||||||
|
check(encoder->Commit());
|
||||||
|
STATSTG stat{};
|
||||||
|
check(stream->Stat(&stat, STATFLAG_NONAME));
|
||||||
|
if (stat.cbSize.QuadPart > UINT_MAX)
|
||||||
|
throw std::runtime_error("PNG image is too large");
|
||||||
|
check(stream->Seek({}, STREAM_SEEK_SET, nullptr));
|
||||||
|
Bytes bytes((size_t)stat.cbSize.QuadPart);
|
||||||
|
ULONG read{};
|
||||||
|
check(stream->Read(bytes.data(), (ULONG)bytes.size(), &read));
|
||||||
|
if (read != bytes.size())
|
||||||
|
throw std::runtime_error("Cannot read encoded PNG image");
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
Format imageDib(const Pixels &p)
|
Format imageDib(const Pixels &p)
|
||||||
{
|
{
|
||||||
BITMAPINFOHEADER h{};
|
BITMAPINFOHEADER h{};
|
||||||
@@ -271,4 +308,119 @@ std::wstring recognizeText(const Clip &clip)
|
|||||||
WindowsDeleteString(text);
|
WindowsDeleteString(text);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
std::filesystem::path tesseractExecutable(const Settings &s)
|
||||||
|
{
|
||||||
|
if (!s.tesseractPath.empty())
|
||||||
|
{
|
||||||
|
std::filesystem::path path(s.tesseractPath);
|
||||||
|
if (!path.is_absolute()) throw std::runtime_error("Select an absolute path to tesseract.exe");
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
for (auto variable : {L"ProgramW6432", L"ProgramFiles", L"LOCALAPPDATA"})
|
||||||
|
{
|
||||||
|
wchar_t base[32768]{};
|
||||||
|
auto length = GetEnvironmentVariableW(variable, base, std::size(base));
|
||||||
|
if (!length || length >= std::size(base)) continue;
|
||||||
|
auto root = std::filesystem::path(base);
|
||||||
|
for (auto relative : {L"Tesseract-OCR/tesseract.exe", L"Programs/Tesseract-OCR/tesseract.exe"})
|
||||||
|
if (std::filesystem::is_regular_file(root / relative)) return root / relative;
|
||||||
|
}
|
||||||
|
throw std::runtime_error("Tesseract was not found. Install the optional 64-bit Tesseract 5 package or select tesseract.exe in OCR options.");
|
||||||
|
}
|
||||||
|
std::wstring ocrProfile(const Settings &s)
|
||||||
|
{
|
||||||
|
return s.useTesseract ? L"tesseract:" + lower(s.tesseractPath) + L":" + s.tesseractLanguages : L"windows";
|
||||||
|
}
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
struct TessLibrary
|
||||||
|
{
|
||||||
|
HMODULE module{};
|
||||||
|
explicit TessLibrary(const Settings &s)
|
||||||
|
{
|
||||||
|
auto exe = tesseractExecutable(s);
|
||||||
|
if (!std::filesystem::is_regular_file(exe)) throw std::runtime_error("Tesseract executable does not exist");
|
||||||
|
auto library = exe.parent_path() / L"libtesseract-5.dll";
|
||||||
|
module = LoadLibraryExW(library.c_str(), nullptr,
|
||||||
|
LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
|
||||||
|
if (!module) throw std::runtime_error("Cannot load Tesseract 5. Select a complete 64-bit installation with libtesseract-5.dll and its dependencies.");
|
||||||
|
}
|
||||||
|
~TessLibrary() { if (module) FreeLibrary(module); }
|
||||||
|
template<class T> T symbol(const char *name)
|
||||||
|
{
|
||||||
|
auto address = GetProcAddress(module, name);
|
||||||
|
if (!address) throw std::runtime_error(std::string("Unsupported Tesseract library: missing ") + name);
|
||||||
|
T result; static_assert(sizeof(result) == sizeof(address));
|
||||||
|
memcpy(&result, &address, sizeof(result));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
struct TessEngine
|
||||||
|
{
|
||||||
|
TessLibrary library;
|
||||||
|
void *handle{};
|
||||||
|
void (*destroy)(void *){};
|
||||||
|
explicit TessEngine(const Settings &s) : library(s)
|
||||||
|
{
|
||||||
|
auto folder = tesseractExecutable(s).parent_path() / L"tessdata";
|
||||||
|
size_t start = 0;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
auto end = s.tesseractLanguages.find(L'+', start);
|
||||||
|
auto language = s.tesseractLanguages.substr(start, end == std::wstring::npos ? end : end - start);
|
||||||
|
if (!std::filesystem::is_regular_file(folder / (language + L".traineddata")))
|
||||||
|
throw std::runtime_error("Tesseract language is not installed: " + utf8(language));
|
||||||
|
if (end == std::wstring::npos) break;
|
||||||
|
start = end + 1;
|
||||||
|
} while (true);
|
||||||
|
auto create = library.symbol<void *(*)()>("TessBaseAPICreate");
|
||||||
|
destroy = library.symbol<void (*)(void *)>("TessBaseAPIDelete");
|
||||||
|
auto init = library.symbol<int (*)(void *, const char *, const char *)>("TessBaseAPIInit3");
|
||||||
|
handle = create();
|
||||||
|
if (!handle) throw std::runtime_error("Cannot create Tesseract engine");
|
||||||
|
if (init(handle, utf8(folder.wstring()).c_str(), utf8(s.tesseractLanguages).c_str()) != 0)
|
||||||
|
{
|
||||||
|
destroy(handle); handle = nullptr;
|
||||||
|
throw std::runtime_error("Cannot initialize the selected Tesseract languages");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
~TessEngine() { if (handle) destroy(handle); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
void validateTesseract(const Settings &s)
|
||||||
|
{
|
||||||
|
if (s.useTesseract) { TessEngine engine(s); }
|
||||||
|
}
|
||||||
|
std::wstring recognizeText(const Clip &clip, const Settings &s)
|
||||||
|
{
|
||||||
|
if (!s.useTesseract) return recognizeText(clip);
|
||||||
|
auto apartmentResult = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||||
|
if (FAILED(apartmentResult) && apartmentResult != RPC_E_CHANGED_MODE) check(apartmentResult);
|
||||||
|
struct Apartment { bool owned; ~Apartment() { if (owned) CoUninitialize(); } } apartment{SUCCEEDED(apartmentResult)};
|
||||||
|
// Load the optional C API dynamically: Windows OCR users need no Tesseract DLLs.
|
||||||
|
TessEngine engine(s);
|
||||||
|
auto &lib = engine.library;
|
||||||
|
auto image = lib.symbol<void (*)(void *, const unsigned char *, int, int, int, int)>("TessBaseAPISetImage");
|
||||||
|
auto mode = lib.symbol<void (*)(void *, int)>("TessBaseAPISetPageSegMode");
|
||||||
|
auto recognize = lib.symbol<int (*)(void *, void *)>("TessBaseAPIRecognize");
|
||||||
|
auto getText = lib.symbol<char *(*)(void *)>("TessBaseAPIGetUTF8Text");
|
||||||
|
auto freeText = lib.symbol<void (*)(const char *)>("TessDeleteText");
|
||||||
|
auto createMonitor = lib.symbol<void *(*)()>("TessMonitorCreate");
|
||||||
|
auto freeMonitor = lib.symbol<void (*)(void *)>("TessMonitorDelete");
|
||||||
|
auto deadline = lib.symbol<void (*)(void *, int)>("TessMonitorSetDeadlineMSecs");
|
||||||
|
std::unique_ptr<void, void (*)(void *)> monitor(createMonitor(), freeMonitor);
|
||||||
|
if (!monitor) throw std::runtime_error("Cannot create Tesseract progress monitor");
|
||||||
|
auto pixels = decodeImage(clip);
|
||||||
|
Bytes gray(size_t(pixels.width) * pixels.height);
|
||||||
|
for (size_t i = 0; i < gray.size(); ++i)
|
||||||
|
gray[i] = BYTE((29 * pixels.bgra[4*i] + 150 * pixels.bgra[4*i+1] + 77 * pixels.bgra[4*i+2]) >> 8);
|
||||||
|
image(engine.handle, gray.data(), pixels.width, pixels.height, 1, pixels.width);
|
||||||
|
mode(engine.handle, 11); // Sparse text: screenshots often contain separate labels and paragraphs.
|
||||||
|
deadline(monitor.get(), 30000);
|
||||||
|
if (recognize(engine.handle, monitor.get()) != 0)
|
||||||
|
throw std::runtime_error("Tesseract recognition failed or exceeded 30 seconds");
|
||||||
|
std::unique_ptr<char, void (*)(const char *)> text(getText(engine.handle), freeText);
|
||||||
|
if (!text) throw std::runtime_error("Tesseract returned no result");
|
||||||
|
return wide(text.get());
|
||||||
|
}
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
|||||||
@@ -9,5 +9,10 @@ struct Pixels
|
|||||||
};
|
};
|
||||||
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
|
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
|
||||||
Format imageDib(const Pixels &);
|
Format imageDib(const Pixels &);
|
||||||
|
Bytes encodePng(const Pixels &);
|
||||||
std::wstring recognizeText(const Clip &);
|
std::wstring recognizeText(const Clip &);
|
||||||
|
std::wstring recognizeText(const Clip &, const Settings &);
|
||||||
|
std::filesystem::path tesseractExecutable(const Settings &);
|
||||||
|
void validateTesseract(const Settings &);
|
||||||
|
std::wstring ocrProfile(const Settings &);
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "platform.h"
|
||||||
|
#include <uiautomation.h>
|
||||||
|
|
||||||
|
namespace ks
|
||||||
|
{
|
||||||
|
// Read-only UI Automation: never select, move the caret, or touch the clipboard.
|
||||||
|
class PasteProbe
|
||||||
|
{
|
||||||
|
IUIAutomation2 *automation{};
|
||||||
|
IUIAutomationElement *element{};
|
||||||
|
IUIAutomationTextPattern *pattern{};
|
||||||
|
|
||||||
|
public:
|
||||||
|
std::wstring status = L"not-requested";
|
||||||
|
PasteProbe(bool enabled, const std::wstring &original)
|
||||||
|
{
|
||||||
|
if (!enabled)
|
||||||
|
return;
|
||||||
|
status = L"accessibility-unavailable";
|
||||||
|
if (FAILED(CoCreateInstance(CLSID_CUIAutomation8, nullptr, CLSCTX_INPROC_SERVER, IID_IUIAutomation2,
|
||||||
|
(void **)&automation)))
|
||||||
|
return;
|
||||||
|
automation->put_ConnectionTimeout(200);
|
||||||
|
automation->put_TransactionTimeout(200);
|
||||||
|
if (FAILED(automation->GetFocusedElement(&element)) || !element)
|
||||||
|
return;
|
||||||
|
BOOL password = TRUE;
|
||||||
|
if (FAILED(element->get_CurrentIsPassword(&password)) || password)
|
||||||
|
{
|
||||||
|
status = L"protected-or-inaccessible-field";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (FAILED(element->GetCurrentPatternAs(UIA_TextPatternId, IID_IUIAutomationTextPattern,
|
||||||
|
(void **)&pattern)) ||
|
||||||
|
!pattern)
|
||||||
|
return;
|
||||||
|
auto selected = read(1, false);
|
||||||
|
auto normalize = [](std::wstring value)
|
||||||
|
{
|
||||||
|
for (size_t at = 0; (at = value.find(L"\r\n", at)) != value.npos;)
|
||||||
|
value.erase(at, 1);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
if (!selected || normalize(*selected) != normalize(original))
|
||||||
|
{
|
||||||
|
if (selected)
|
||||||
|
status = L"initial-selection-mismatch";
|
||||||
|
if (pattern)
|
||||||
|
pattern->Release();
|
||||||
|
pattern = nullptr;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status = L"ready";
|
||||||
|
}
|
||||||
|
PasteProbe(const PasteProbe &) = delete;
|
||||||
|
~PasteProbe()
|
||||||
|
{
|
||||||
|
if (pattern)
|
||||||
|
pattern->Release();
|
||||||
|
if (element)
|
||||||
|
element->Release();
|
||||||
|
if (automation)
|
||||||
|
automation->Release();
|
||||||
|
}
|
||||||
|
std::optional<std::wstring> read(int characters, bool allowCaret = true)
|
||||||
|
{
|
||||||
|
if (!pattern)
|
||||||
|
return {};
|
||||||
|
if (characters <= 0 || characters > 4096)
|
||||||
|
{
|
||||||
|
status = L"readback-size-limit";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
IUIAutomationElement *focused{};
|
||||||
|
BOOL same = FALSE;
|
||||||
|
if (SUCCEEDED(automation->GetFocusedElement(&focused)) && focused)
|
||||||
|
{
|
||||||
|
automation->CompareElements(element, focused, &same);
|
||||||
|
}
|
||||||
|
if (!same)
|
||||||
|
{
|
||||||
|
if (focused)
|
||||||
|
focused->Release();
|
||||||
|
status = L"focused-control-changed";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
BOOL password = TRUE;
|
||||||
|
if (FAILED(focused->get_CurrentIsPassword(&password)) || password)
|
||||||
|
{
|
||||||
|
focused->Release();
|
||||||
|
status = L"protected-or-inaccessible-field";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
// Native Edit accessibility proxies can retain a text snapshot in the
|
||||||
|
// pattern object. Obtain a fresh pattern after paste, not the pre-paste one.
|
||||||
|
pattern->Release();
|
||||||
|
pattern = nullptr;
|
||||||
|
HRESULT refreshed =
|
||||||
|
focused->GetCurrentPatternAs(UIA_TextPatternId, IID_IUIAutomationTextPattern, (void **)&pattern);
|
||||||
|
focused->Release();
|
||||||
|
if (FAILED(refreshed) || !pattern)
|
||||||
|
{
|
||||||
|
status = L"accessibility-unavailable";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
IUIAutomationTextRangeArray *ranges{};
|
||||||
|
if (FAILED(pattern->GetSelection(&ranges)) || !ranges)
|
||||||
|
{
|
||||||
|
status = L"selection-unavailable";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
int count{};
|
||||||
|
ranges->get_Length(&count);
|
||||||
|
IUIAutomationTextRange *range{};
|
||||||
|
if (count == 1)
|
||||||
|
ranges->GetElement(0, &range);
|
||||||
|
ranges->Release();
|
||||||
|
if (!range)
|
||||||
|
{
|
||||||
|
status = L"selection-ambiguous";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
int difference{};
|
||||||
|
HRESULT hr = range->CompareEndpoints(TextPatternRangeEndpoint_Start, range,
|
||||||
|
TextPatternRangeEndpoint_End, &difference);
|
||||||
|
if (SUCCEEDED(hr) && difference == 0)
|
||||||
|
{
|
||||||
|
if (!allowCaret)
|
||||||
|
{
|
||||||
|
range->Release();
|
||||||
|
status = L"initial-selection-unavailable";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
int moved{};
|
||||||
|
hr = range->MoveEndpointByUnit(TextPatternRangeEndpoint_Start, TextUnit_Character, -characters,
|
||||||
|
&moved);
|
||||||
|
if (SUCCEEDED(hr) && moved != -characters)
|
||||||
|
hr = E_FAIL;
|
||||||
|
}
|
||||||
|
BSTR value{};
|
||||||
|
if (SUCCEEDED(hr))
|
||||||
|
hr = range->GetText(8193, &value);
|
||||||
|
range->Release();
|
||||||
|
if (FAILED(hr))
|
||||||
|
{
|
||||||
|
SysFreeString(value);
|
||||||
|
status = L"range-read-failed";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
std::wstring text(value ? value : L"", SysStringLen(value));
|
||||||
|
SysFreeString(value);
|
||||||
|
if (text.size() > 8192)
|
||||||
|
{
|
||||||
|
status = L"readback-size-limit";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
status = L"read-back-via-accessibility";
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} // namespace ks
|
||||||
+33
-6
@@ -1,6 +1,8 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
|
#include "../vendor/json.hpp"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
|
#include <iomanip>
|
||||||
#include <imm.h>
|
#include <imm.h>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
@@ -394,22 +396,27 @@ bool chord(WORD key, UINT mods)
|
|||||||
bool needsSelectionDelay(HWND target)
|
bool needsSelectionDelay(HWND target)
|
||||||
{
|
{
|
||||||
const auto name = lower(processName(target));
|
const auto name = lower(processName(target));
|
||||||
return name == L"ms-teams" || name == L"msedgewebview2";
|
return name == L"ms-teams" || name == L"msedgewebview2" ||
|
||||||
|
name == L"chrome" || name == L"msedge";
|
||||||
}
|
}
|
||||||
bool selectPrevious(size_t count, const std::function<bool()> &valid)
|
bool selectPrevious(size_t count, const std::function<bool()> &valid)
|
||||||
{
|
{
|
||||||
if (count > 100000)
|
if (count > 100000)
|
||||||
return false;
|
return false;
|
||||||
// Teams' WebView editor applies selection changes asynchronously. A burst of
|
// Chromium and Teams' WebView editors apply selection changes asynchronously. A burst of
|
||||||
// Shift+Left events can all use the same caret position and select only one
|
// Shift+Left events can all use the same caret position and select only one
|
||||||
// character. Give each change time to settle before sending the next one.
|
// character. Give each change time to settle before sending the next one.
|
||||||
const auto target = GetForegroundWindow();
|
const auto target = GetForegroundWindow();
|
||||||
|
diagnosticEvent(L"SelectPrevious requested=" + std::to_wstring(count) +
|
||||||
|
L" mode=" + (needsSelectionDelay(target) ? std::wstring(L"paced-25ms") : std::wstring(L"batch")));
|
||||||
if (needsSelectionDelay(target))
|
if (needsSelectionDelay(target))
|
||||||
{
|
{
|
||||||
for (size_t i = 0; i < count; ++i)
|
for (size_t i = 0; i < count; ++i)
|
||||||
{
|
{
|
||||||
if (!valid() || GetForegroundWindow() != target || !chord(VK_LEFT, MOD_SHIFT))
|
if (!valid() || GetForegroundWindow() != target || !chord(VK_LEFT, MOD_SHIFT))
|
||||||
return false;
|
return false;
|
||||||
|
if (i < 32 || i + 1 == count)
|
||||||
|
diagnosticEvent(L"SelectPrevious Shift+Left sent=" + std::to_wstring(i + 1));
|
||||||
Sleep(25);
|
Sleep(25);
|
||||||
}
|
}
|
||||||
return valid() && GetForegroundWindow() == target;
|
return valid() && GetForegroundWindow() == target;
|
||||||
@@ -431,7 +438,10 @@ bool selectPrevious(size_t count, const std::function<bool()> &valid)
|
|||||||
add(VK_LEFT, KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYUP);
|
add(VK_LEFT, KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYUP);
|
||||||
}
|
}
|
||||||
add(VK_SHIFT, KEYEVENTF_KEYUP);
|
add(VK_SHIFT, KEYEVENTF_KEYUP);
|
||||||
if (SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) != inputs.size())
|
auto sent = SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT));
|
||||||
|
diagnosticEvent(L"SelectPrevious batch offset=" + std::to_wstring(offset) + L" eventsRequested=" +
|
||||||
|
std::to_wstring(inputs.size()) + L" eventsSent=" + std::to_wstring(sent));
|
||||||
|
if (sent != inputs.size())
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return valid();
|
return valid();
|
||||||
@@ -571,7 +581,7 @@ DiagnosticBuffer diagnosticBuffer;
|
|||||||
}
|
}
|
||||||
std::wstring DiagnosticBuffer::record(const std::wstring &message, bool problem, ULONGLONG now)
|
std::wstring DiagnosticBuffer::record(const std::wstring &message, bool problem, ULONGLONG now)
|
||||||
{
|
{
|
||||||
while (!entries.empty() && (entries.size() >= 40 || now - entries.front().first > 30000))
|
while (!entries.empty() && (entries.size() >= 128 || now - entries.front().first > 30000))
|
||||||
entries.pop_front();
|
entries.pop_front();
|
||||||
entries.emplace_back(now, message);
|
entries.emplace_back(now, message);
|
||||||
if (!problem)
|
if (!problem)
|
||||||
@@ -624,6 +634,22 @@ void diagnosticEvent(const std::wstring &message, bool problem)
|
|||||||
if (!incident.empty())
|
if (!incident.empty())
|
||||||
log(incident);
|
log(incident);
|
||||||
}
|
}
|
||||||
|
void diagnosticOperation(const std::string &json)
|
||||||
|
{
|
||||||
|
std::lock_guard lock(logGate);
|
||||||
|
if (!loggingEnabled) return;
|
||||||
|
auto record = nlohmann::json::parse(json);
|
||||||
|
if (!record.value("problem", true))
|
||||||
|
{
|
||||||
|
// Keep successful operations only in the bounded, short-lived context.
|
||||||
|
// They are persisted with a subsequent failure, never as standalone rows.
|
||||||
|
diagnosticBuffer.record(L"Completed operation " + wide(record.dump(-1, ' ', true,
|
||||||
|
nlohmann::json::error_handler_t::replace)), false, GetTickCount64());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
record["context"] = utf8(diagnosticBuffer.record(L"Operation complete", true, GetTickCount64()));
|
||||||
|
log(L"Operation " + wide(record.dump(-1, ' ', true, nlohmann::json::error_handler_t::replace)));
|
||||||
|
}
|
||||||
void log(const std::wstring &message)
|
void log(const std::wstring &message)
|
||||||
{
|
{
|
||||||
std::lock_guard lock(logGate);
|
std::lock_guard lock(logGate);
|
||||||
@@ -645,8 +671,9 @@ void log(const std::wstring &message)
|
|||||||
std::ofstream f(path, std::ios::app);
|
std::ofstream f(path, std::ios::app);
|
||||||
SYSTEMTIME t;
|
SYSTEMTIME t;
|
||||||
GetLocalTime(&t);
|
GetLocalTime(&t);
|
||||||
f << t.wYear << '-' << t.wMonth << '-' << t.wDay << ' ' << t.wHour << ':' << t.wMinute << ':'
|
f << std::setfill('0') << std::setw(4) << t.wYear << '-' << std::setw(2) << t.wMonth << '-'
|
||||||
<< t.wSecond << ' ' << utf8(message) << '\n';
|
<< std::setw(2) << t.wDay << ' ' << std::setw(2) << t.wHour << ':' << std::setw(2) << t.wMinute << ':'
|
||||||
|
<< std::setw(2) << t.wSecond << ' ' << utf8(message) << '\n';
|
||||||
}
|
}
|
||||||
catch (...)
|
catch (...)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,16 @@
|
|||||||
|
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
|
inline std::wstring localBuildTitle(const std::wstring &title)
|
||||||
|
{
|
||||||
|
static const bool development=[] {
|
||||||
|
wchar_t path[32768]{};
|
||||||
|
if(!GetModuleFileNameW(nullptr,path,std::size(path))) return false;
|
||||||
|
std::error_code error;
|
||||||
|
return std::filesystem::exists(std::filesystem::path(path).parent_path()/L".local-dev",error);
|
||||||
|
}();
|
||||||
|
return development?title+L" [LOCAL DEV]":title;
|
||||||
|
}
|
||||||
using Bytes = std::vector<unsigned char>;
|
using Bytes = std::vector<unsigned char>;
|
||||||
struct Format
|
struct Format
|
||||||
{
|
{
|
||||||
@@ -66,6 +76,7 @@ class DiagnosticBuffer
|
|||||||
};
|
};
|
||||||
void setLoggingEnabled(bool enabled);
|
void setLoggingEnabled(bool enabled);
|
||||||
void diagnosticEvent(const std::wstring &message, bool problem = false);
|
void diagnosticEvent(const std::wstring &message, bool problem = false);
|
||||||
|
void diagnosticOperation(const std::string &json);
|
||||||
std::wstring readDiagnosticLog(bool previous);
|
std::wstring readDiagnosticLog(bool previous);
|
||||||
void clearDiagnosticLog(bool previous);
|
void clearDiagnosticLog(bool previous);
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
|||||||
+719
-96
File diff suppressed because it is too large
Load Diff
+22
-4
@@ -2,10 +2,25 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
|
// Only the on-demand screenshot process owns this state; no resident App dependency.
|
||||||
|
struct ScreenshotHost {
|
||||||
|
HINSTANCE instance{};
|
||||||
|
HWND window{};
|
||||||
|
unsigned windows{};
|
||||||
|
bool (*notify)(const Clip &){};
|
||||||
|
bool autoOpenEditor = false;
|
||||||
|
};
|
||||||
|
inline constexpr UINT ScreenshotRequest=WM_APP+71, ScreenshotIdle=WM_APP+72;
|
||||||
|
void showScreenshot(ScreenshotHost &);
|
||||||
|
void showScreenshotEditor(ScreenshotHost &,const Clip &);
|
||||||
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
|
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
|
||||||
enum class LineEnds { End, Start, Both, None };
|
enum class LineEnds { End, Start, Both, None };
|
||||||
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse };
|
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse, Triangle, Trapezoid };
|
||||||
struct ScreenshotOffset { double x, y; };
|
struct ScreenshotOffset { double x, y; };
|
||||||
|
struct ScreenshotLink {
|
||||||
|
unsigned long long target{};
|
||||||
|
double u{}, v{}; // Relative boundary position, or fractional position along a path.
|
||||||
|
};
|
||||||
struct ScreenshotMark {
|
struct ScreenshotMark {
|
||||||
POINT from, to; int number; int radius = 15; int thickness = 3;
|
POINT from, to; int number; int radius = 15; int thickness = 3;
|
||||||
int fromNumber = 0, toNumber = 0;
|
int fromNumber = 0, toNumber = 0;
|
||||||
@@ -22,8 +37,10 @@ struct ScreenshotMark {
|
|||||||
std::wstring label;
|
std::wstring label;
|
||||||
ScreenshotOffset labelOffset{0,-24}; // Along the line and perpendicular to it, in image pixels.
|
ScreenshotOffset labelOffset{0,-24}; // Along the line and perpendicular to it, in image pixels.
|
||||||
bool labelRotationManual=false;
|
bool labelRotationManual=false;
|
||||||
|
unsigned long long id{};
|
||||||
|
ScreenshotLink fromLink, toLink;
|
||||||
bool isText() const { return !text.empty(); }
|
bool isText() const { return !text.empty(); }
|
||||||
bool isHighlight() const { return !points.empty() && !freehand; }
|
bool isHighlight() const { return !points.empty() && !freehand && shape==ScreenshotShape::None; }
|
||||||
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
|
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
|
||||||
};
|
};
|
||||||
class ScreenshotHistory {
|
class ScreenshotHistory {
|
||||||
@@ -37,8 +54,8 @@ class ScreenshotHistory {
|
|||||||
};
|
};
|
||||||
void resolveScreenshotLinks(std::vector<ScreenshotMark> &);
|
void resolveScreenshotLinks(std::vector<ScreenshotMark> &);
|
||||||
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
|
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
|
||||||
const std::vector<ScreenshotMark> &, double tolerance);
|
std::vector<ScreenshotMark> &, double tolerance);
|
||||||
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label };
|
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label, Vertex0, Vertex1, Vertex2 };
|
||||||
POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16);
|
POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16);
|
||||||
ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height);
|
ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height);
|
||||||
int nextScreenshotNumber(const std::vector<ScreenshotMark> &);
|
int nextScreenshotNumber(const std::vector<ScreenshotMark> &);
|
||||||
@@ -48,6 +65,7 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &, MarkPart, POINT origin
|
|||||||
POINT constrainScreenshotShape(ScreenshotShape, POINT anchor, POINT position, int width, int height);
|
POINT constrainScreenshotShape(ScreenshotShape, POINT anchor, POINT position, int width, int height);
|
||||||
RECT screenshotTextBounds(const ScreenshotMark &);
|
RECT screenshotTextBounds(const ScreenshotMark &);
|
||||||
POINT screenshotTextRotationHandle(const ScreenshotMark &, double clearance=24);
|
POINT screenshotTextRotationHandle(const ScreenshotMark &, double clearance=24);
|
||||||
|
POINT screenshotShapeRotationHandle(const ScreenshotMark &, double clearance=32);
|
||||||
ScreenshotMark screenshotArrowLabel(const ScreenshotMark &);
|
ScreenshotMark screenshotArrowLabel(const ScreenshotMark &);
|
||||||
// Render at source resolution; never reads or writes the system clipboard.
|
// Render at source resolution; never reads or writes the system clipboard.
|
||||||
Clip screenshotClip(HDC source, int width, int height, const std::vector<ScreenshotMark> &marks);
|
Clip screenshotClip(HDC source, int width, int height, const std::vector<ScreenshotMark> &marks);
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
#include "screenshot_notifications.h"
|
||||||
|
#include <atomic>
|
||||||
|
#include <shlobj.h>
|
||||||
|
#include <propkey.h>
|
||||||
|
#include <propvarutil.h>
|
||||||
|
#include <wrl/client.h>
|
||||||
|
namespace ks {
|
||||||
|
namespace {
|
||||||
|
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
|
||||||
|
constexpr GUID ActivatorId{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}};
|
||||||
|
}
|
||||||
|
namespace {
|
||||||
|
using Microsoft::WRL::ComPtr;
|
||||||
|
void check(HRESULT result) {
|
||||||
|
if(FAILED(result)) throw std::runtime_error("Screenshot registration failed: " + std::to_string((unsigned long)result));
|
||||||
|
}
|
||||||
|
void registryString(const std::wstring &path,const wchar_t *name,const std::wstring &value)
|
||||||
|
{
|
||||||
|
HKEY key{};
|
||||||
|
if(RegCreateKeyExW(HKEY_CURRENT_USER,path.c_str(),0,nullptr,0,KEY_SET_VALUE,nullptr,&key,nullptr)!=ERROR_SUCCESS)
|
||||||
|
throw std::runtime_error("Cannot register screenshot notifications");
|
||||||
|
auto result=RegSetValueExW(key,name,0,REG_SZ,(const BYTE *)value.c_str(),DWORD((value.size()+1)*sizeof(wchar_t)));
|
||||||
|
RegCloseKey(key);
|
||||||
|
if(result!=ERROR_SUCCESS) throw std::runtime_error("Cannot register screenshot notification activation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void registerScreenshotNotifications()
|
||||||
|
{
|
||||||
|
auto exe=(std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe").wstring();
|
||||||
|
if(!std::filesystem::exists(exe)) throw std::runtime_error("Screenshot helper is missing");
|
||||||
|
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",nullptr,L"\""+exe+L"\" --toast-activated");
|
||||||
|
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",L"ServerExecutable",exe);
|
||||||
|
auto identity=std::wstring(L"Software\\Classes\\AppUserModelId\\")+AppId;
|
||||||
|
registryString(identity,L"DisplayName",L"KSwitcher Screenshot");
|
||||||
|
registryString(identity,L"IconUri",exe);
|
||||||
|
registryString(identity,L"CustomActivator",L"{e3f78047-f0ad-49b6-9074-411931706167}");
|
||||||
|
// A shortcut supplies desktop notification identity and its COM activation callback.
|
||||||
|
ComPtr<IShellLinkW> link; check(CoCreateInstance(CLSID_ShellLink,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&link)));
|
||||||
|
check(link->SetPath(exe.c_str())); check(link->SetIconLocation(exe.c_str(),0));
|
||||||
|
ComPtr<IPropertyStore> properties; check(link.As(&properties));
|
||||||
|
PROPVARIANT id{}; check(InitPropVariantFromString(AppId,&id));
|
||||||
|
auto result=properties->SetValue(PKEY_AppUserModel_ID,id); PropVariantClear(&id); check(result);
|
||||||
|
GUID activator=ActivatorId;
|
||||||
|
PROPVARIANT clsid{}; clsid.vt=VT_CLSID; clsid.puuid=&activator;
|
||||||
|
check(properties->SetValue(PKEY_AppUserModel_ToastActivatorCLSID,clsid));
|
||||||
|
check(properties->Commit());
|
||||||
|
PWSTR programs{}; check(SHGetKnownFolderPath(FOLDERID_Programs,0,nullptr,&programs));
|
||||||
|
auto shortcut=std::filesystem::path(programs)/L"KSwitcher Screenshot.lnk"; CoTaskMemFree(programs);
|
||||||
|
ComPtr<IPersistFile> file; check(link.As(&file)); check(file->Save(shortcut.c_str(),TRUE));
|
||||||
|
// Notify the shell that notification identity/activation registration changed.
|
||||||
|
SHChangeNotify(SHCNE_ASSOCCHANGED,SHCNF_IDLIST,nullptr,nullptr);
|
||||||
|
}
|
||||||
|
bool validScreenshotToken(const std::wstring &id)
|
||||||
|
{
|
||||||
|
if(id.size()!=36) return false;
|
||||||
|
for(size_t i=0;i<id.size();++i) {
|
||||||
|
if(i==8 || i==13 || i==18 || i==23) { if(id[i]!=L'-') return false; }
|
||||||
|
else if(!((id[i]>=L'0' && id[i]<=L'9') || (id[i]>=L'a' && id[i]<=L'f') || (id[i]>=L'A' && id[i]<=L'F'))) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Windows SDK INotificationActivationCallback ABI (notificationactivationcallback.h).
|
||||||
|
constexpr GUID CallbackId{0x53e31837,0x6600,0x4a81,{0x93,0x95,0x75,0xcf,0xfe,0x74,0x6f,0x94}};
|
||||||
|
struct NotificationInput { LPCWSTR key,value; };
|
||||||
|
struct NotificationCallback : IUnknown {
|
||||||
|
virtual HRESULT STDMETHODCALLTYPE Activate(LPCWSTR,LPCWSTR,const NotificationInput *,ULONG)=0;
|
||||||
|
};
|
||||||
|
class Activator final : public NotificationCallback {
|
||||||
|
std::atomic<ULONG> references{1};
|
||||||
|
DWORD thread=GetCurrentThreadId();
|
||||||
|
std::wstring target;
|
||||||
|
bool quit;
|
||||||
|
public:
|
||||||
|
Activator(std::wstring path,bool shouldQuit):target(std::move(path)),quit(shouldQuit) {}
|
||||||
|
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID id,void **out) override {
|
||||||
|
if(!out) return E_POINTER;
|
||||||
|
*out=nullptr;
|
||||||
|
if(id!=IID_IUnknown && id!=CallbackId) return E_NOINTERFACE;
|
||||||
|
*out=static_cast<NotificationCallback *>(this); AddRef(); return S_OK;
|
||||||
|
}
|
||||||
|
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
|
||||||
|
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
|
||||||
|
HRESULT STDMETHODCALLTYPE Activate(LPCWSTR app,LPCWSTR args,const NotificationInput *,ULONG) override {
|
||||||
|
if(!app || std::wstring(app)!=AppId || !args) return E_INVALIDARG;
|
||||||
|
const bool update=std::wstring(args)==L"check-update";
|
||||||
|
if(!update && !validScreenshotToken(args)) return E_INVALIDARG;
|
||||||
|
auto path=update ? (std::filesystem::path(target).parent_path()/L"KSwitcher.exe").wstring() : target;
|
||||||
|
auto command=update ? L"\""+path+L"\" --check-update" :
|
||||||
|
L"\""+path+L"\" --open \"kswitcher-screenshot:"+args+L"\"";
|
||||||
|
STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{};
|
||||||
|
auto ok=CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,nullptr,nullptr,&startup,&process);
|
||||||
|
auto result=ok?S_OK:HRESULT_FROM_WIN32(GetLastError());
|
||||||
|
if(ok) {
|
||||||
|
AllowSetForegroundWindow(process.dwProcessId); ResumeThread(process.hThread);
|
||||||
|
CloseHandle(process.hThread); CloseHandle(process.hProcess);
|
||||||
|
}
|
||||||
|
if(quit) PostThreadMessageW(thread,WM_QUIT,0,0);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
class ActivatorFactory final : public IClassFactory {
|
||||||
|
std::atomic<ULONG> references{1};
|
||||||
|
std::wstring target;
|
||||||
|
bool quit;
|
||||||
|
public:
|
||||||
|
ActivatorFactory(std::wstring path,bool shouldQuit):target(std::move(path)),quit(shouldQuit) {}
|
||||||
|
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID id,void **out) override {
|
||||||
|
if(!out) return E_POINTER;
|
||||||
|
*out=nullptr;
|
||||||
|
if(id!=IID_IUnknown && id!=IID_IClassFactory) return E_NOINTERFACE;
|
||||||
|
*out=static_cast<IClassFactory *>(this); AddRef(); return S_OK;
|
||||||
|
}
|
||||||
|
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
|
||||||
|
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
|
||||||
|
HRESULT STDMETHODCALLTYPE CreateInstance(IUnknown *outer,REFIID id,void **out) override {
|
||||||
|
if(outer) return CLASS_E_NOAGGREGATION;
|
||||||
|
auto object=new Activator(target,quit); auto result=object->QueryInterface(id,out); object->Release(); return result;
|
||||||
|
}
|
||||||
|
HRESULT STDMETHODCALLTYPE LockServer(BOOL) override { return S_OK; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
int runScreenshotToastActivator()
|
||||||
|
{
|
||||||
|
if(FAILED(CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED))) return 1;
|
||||||
|
MSG message{}; PeekMessageW(&message,nullptr,0,0,PM_NOREMOVE);
|
||||||
|
auto factory=new ActivatorFactory(executable(),true); DWORD registration{};
|
||||||
|
auto result=CoRegisterClassObject(ActivatorId,factory,CLSCTX_LOCAL_SERVER,REGCLS_MULTIPLEUSE,®istration);
|
||||||
|
factory->Release();
|
||||||
|
if(SUCCEEDED(result)) {
|
||||||
|
auto timer=SetTimer(nullptr,0,60000,[](HWND,UINT,UINT_PTR,DWORD){PostQuitMessage(0);});
|
||||||
|
while(GetMessageW(&message,nullptr,0,0)>0) { TranslateMessage(&message); DispatchMessageW(&message); }
|
||||||
|
if(timer) KillTimer(nullptr,timer);
|
||||||
|
CoRevokeClassObject(registration);
|
||||||
|
}
|
||||||
|
CoUninitialize(); return FAILED(result)?1:0;
|
||||||
|
}
|
||||||
|
DWORD startScreenshotActivationListener()
|
||||||
|
{
|
||||||
|
auto target=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
||||||
|
auto factory=new ActivatorFactory(target.wstring(),false);
|
||||||
|
DWORD registration{};
|
||||||
|
auto result=CoRegisterClassObject(ActivatorId,factory,CLSCTX_LOCAL_SERVER,REGCLS_MULTIPLEUSE,®istration);
|
||||||
|
factory->Release();
|
||||||
|
return SUCCEEDED(result)?registration:0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#include "app.h"
|
||||||
|
#include "screenshot_notifications.h"
|
||||||
|
|
||||||
|
namespace ks {
|
||||||
|
static void launch(App &app, const Clip *clip)
|
||||||
|
{
|
||||||
|
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
||||||
|
if (!std::filesystem::exists(path))
|
||||||
|
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
|
||||||
|
auto command=L"\""+path.wstring()+L"\" ";
|
||||||
|
if(clip) command+=L"--open kswitcher-screenshot:"+cacheScreenshotForEditor(*clip);
|
||||||
|
else command+=std::wstring(L"--auto-open-editor=")+(app.settings.screenshotAutoOpenEditor?L"1":L"0");
|
||||||
|
if(app.settings.writeLog) command+=L" --write-log";
|
||||||
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
|
PROCESS_INFORMATION process{};
|
||||||
|
// Grant foreground rights before the child's first selection window opens.
|
||||||
|
if (!CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,
|
||||||
|
nullptr,path.parent_path().c_str(),&startup,&process))
|
||||||
|
throw std::runtime_error(utf8(lastError(L"Cannot start screenshot editor")));
|
||||||
|
AllowSetForegroundWindow(process.dwProcessId);
|
||||||
|
ResumeThread(process.hThread);
|
||||||
|
CloseHandle(process.hThread);
|
||||||
|
CloseHandle(process.hProcess);
|
||||||
|
}
|
||||||
|
void launchScreenshot(App &app) { launch(app,nullptr); }
|
||||||
|
void launchScreenshotEditor(App &app,const Clip &clip) { launch(app,&clip); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
#include "screenshot.h"
|
||||||
|
#include "screenshot_notifications.h"
|
||||||
|
#include <commctrl.h>
|
||||||
|
#include <shellapi.h>
|
||||||
|
#include <roapi.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr wchar_t ControllerClass[]=L"KSwitcher.Screenshot.Controller";
|
||||||
|
LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM lp)
|
||||||
|
{
|
||||||
|
auto host=(ks::ScreenshotHost *)GetWindowLongPtrW(window,GWLP_USERDATA);
|
||||||
|
if(message==WM_NCCREATE) {
|
||||||
|
host=(ks::ScreenshotHost *)((CREATESTRUCTW *)lp)->lpCreateParams;
|
||||||
|
SetWindowLongPtrW(window,GWLP_USERDATA,(LONG_PTR)host);
|
||||||
|
}
|
||||||
|
if(host && message==ks::ScreenshotRequest) {
|
||||||
|
host->autoOpenEditor=(wp&1)!=0;
|
||||||
|
ks::setLoggingEnabled((wp&2)!=0);
|
||||||
|
try { ks::showScreenshot(*host); }
|
||||||
|
catch(const std::exception &e) {
|
||||||
|
MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screen capture",MB_OK|MB_ICONERROR);
|
||||||
|
PostMessageW(window,ks::ScreenshotIdle,0,0);
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
if(host && message==WM_COPYDATA) {
|
||||||
|
auto data=(COPYDATASTRUCT *)lp;
|
||||||
|
if(!data || data->dwData!=73 || data->cbData!=37*sizeof(wchar_t) || !data->lpData) return FALSE;
|
||||||
|
std::wstring token((const wchar_t *)data->lpData,36);
|
||||||
|
if(!ks::validScreenshotToken(token)) return FALSE;
|
||||||
|
try { ks::showScreenshotEditor(*host,ks::loadNotifiedScreenshot(token)); }
|
||||||
|
catch(const std::exception &e) {
|
||||||
|
MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screenshot",MB_OK|MB_ICONINFORMATION);
|
||||||
|
PostMessageW(window,ks::ScreenshotIdle,0,0);
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
if(host && message==ks::ScreenshotIdle && !host->windows) {
|
||||||
|
DestroyWindow(window);
|
||||||
|
PostQuitMessage(0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return DefWindowProcW(window,message,wp,lp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
|
||||||
|
{
|
||||||
|
std::wstring token;
|
||||||
|
bool autoOpenEditor=false, writeLog=false;
|
||||||
|
int argc{}; auto argv=CommandLineToArgvW(GetCommandLineW(),&argc);
|
||||||
|
if(argc>1 && std::wstring(argv[argc-1])==L"--write-log") {
|
||||||
|
writeLog=true; --argc;
|
||||||
|
ks::setLoggingEnabled(true);
|
||||||
|
}
|
||||||
|
if(argc>=2 && std::wstring(argv[1])==L"--toast-activated") {
|
||||||
|
LocalFree(argv); return ks::runScreenshotToastActivator();
|
||||||
|
}
|
||||||
|
if(argc==2 && std::wstring(argv[1])==L"--register-notifications") {
|
||||||
|
LocalFree(argv); CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||||
|
int result=0; try { ks::registerScreenshotNotifications(); } catch(...) { result=1; }
|
||||||
|
CoUninitialize(); return result;
|
||||||
|
}
|
||||||
|
if(argc==2 && (std::wstring(argv[1])==L"--auto-open-editor=0" ||
|
||||||
|
std::wstring(argv[1])==L"--auto-open-editor=1")) {
|
||||||
|
autoOpenEditor=std::wstring(argv[1])==L"--auto-open-editor=1";
|
||||||
|
} else if(argc>1) {
|
||||||
|
if(argc==3 && std::wstring(argv[1])==L"--open") {
|
||||||
|
std::wstring uri=argv[2]; const std::wstring prefix=L"kswitcher-screenshot:";
|
||||||
|
if(uri.starts_with(prefix)) token=uri.substr(prefix.size());
|
||||||
|
}
|
||||||
|
if(!ks::validScreenshotToken(token)) { LocalFree(argv); return 1; }
|
||||||
|
}
|
||||||
|
LocalFree(argv);
|
||||||
|
COPYDATASTRUCT open{73,DWORD((token.size()+1)*sizeof(wchar_t)),token.data()};
|
||||||
|
HANDLE mutex=CreateMutexW(nullptr,FALSE,L"Local\\KSwitcher.Screenshot.Singleton");
|
||||||
|
if(!mutex) return 1;
|
||||||
|
// Wait through startup/shutdown races; the controller accepts requests only while alive.
|
||||||
|
bool acquired=false;
|
||||||
|
for(int attempt=0;attempt<100;++attempt) {
|
||||||
|
auto wait=WaitForSingleObject(mutex,0);
|
||||||
|
if(wait==WAIT_OBJECT_0 || wait==WAIT_ABANDONED) { acquired=true; break; }
|
||||||
|
if(auto existing=FindWindowW(ControllerClass,nullptr)) {
|
||||||
|
DWORD pid{}; GetWindowThreadProcessId(existing,&pid);
|
||||||
|
AllowSetForegroundWindow(pid);
|
||||||
|
DWORD_PTR accepted{};
|
||||||
|
if(SendMessageTimeoutW(existing,token.empty()?ks::ScreenshotRequest:WM_COPYDATA,token.empty()?(autoOpenEditor?1:0)|(writeLog?2:0):0,
|
||||||
|
token.empty()?0:(LPARAM)&open,SMTO_ABORTIFHUNG,2000,&accepted) && accepted) {
|
||||||
|
CloseHandle(mutex); return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Sleep(50);
|
||||||
|
}
|
||||||
|
if(!acquired) { CloseHandle(mutex); return 1; }
|
||||||
|
RoInitialize(RO_INIT_SINGLETHREADED);
|
||||||
|
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_BAR_CLASSES};
|
||||||
|
InitCommonControlsEx(&controls);
|
||||||
|
ks::ScreenshotHost host{instance};
|
||||||
|
host.notify=ks::notifyScreenshot;
|
||||||
|
WNDCLASSW cls{}; cls.hInstance=instance; cls.lpfnWndProc=controller; cls.lpszClassName=ControllerClass;
|
||||||
|
RegisterClassW(&cls);
|
||||||
|
host.window=CreateWindowW(ControllerClass,L"KSwitcher Screenshot",0,0,0,0,0,nullptr,nullptr,instance,&host);
|
||||||
|
int result=1;
|
||||||
|
if(host.window) {
|
||||||
|
// Toast protocol activation may run unelevated while the editor was launched by an elevated resident.
|
||||||
|
// WM_COPYDATA accepts only a validated cache GUID, never a path or executable command.
|
||||||
|
ChangeWindowMessageFilterEx(host.window,WM_COPYDATA,MSGFLT_ALLOW,nullptr);
|
||||||
|
if(token.empty()) PostMessageW(host.window,ks::ScreenshotRequest,(autoOpenEditor?1:0)|(writeLog?2:0),0);
|
||||||
|
else SendMessageW(host.window,WM_COPYDATA,0,(LPARAM)&open);
|
||||||
|
MSG message{};
|
||||||
|
while((result=GetMessageW(&message,nullptr,0,0))>0) {
|
||||||
|
TranslateMessage(&message); DispatchMessageW(&message);
|
||||||
|
}
|
||||||
|
result=result<0?1:0;
|
||||||
|
}
|
||||||
|
RoUninitialize();
|
||||||
|
ReleaseMutex(mutex); CloseHandle(mutex);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
#include "screenshot_notifications.h"
|
||||||
|
#include "ocr.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <aclapi.h>
|
||||||
|
#include <sddl.h>
|
||||||
|
#include <shlobj.h>
|
||||||
|
#include <propkey.h>
|
||||||
|
#include <propvarutil.h>
|
||||||
|
#include <roapi.h>
|
||||||
|
#include <winstring.h>
|
||||||
|
#include <wincodec.h>
|
||||||
|
#ifdef __MINGW32__
|
||||||
|
// This SDK aliases BYTE and boolean, producing duplicate template specializations.
|
||||||
|
// Notifications use the boolean specialization; IReference<BYTE> is unused here.
|
||||||
|
#define ____FIReference_1_BYTE_INTERFACE_DEFINED__
|
||||||
|
#endif
|
||||||
|
#include <windows.ui.notifications.h>
|
||||||
|
#include <windows.data.xml.dom.h>
|
||||||
|
#include <wrl/client.h>
|
||||||
|
|
||||||
|
namespace ks {
|
||||||
|
namespace {
|
||||||
|
using Microsoft::WRL::ComPtr;
|
||||||
|
namespace Notifications=ABI::Windows::UI::Notifications;
|
||||||
|
namespace Xml=ABI::Windows::Data::Xml::Dom;
|
||||||
|
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
|
||||||
|
std::string notificationError;
|
||||||
|
void check(HRESULT result) { if(FAILED(result)) { char message[100]; snprintf(message,sizeof(message),"Windows screenshot notification failed (0x%08lx)",(unsigned long)result); throw std::runtime_error(message); } }
|
||||||
|
struct String {
|
||||||
|
HSTRING value{};
|
||||||
|
explicit String(const std::wstring &s) { check(WindowsCreateString(s.c_str(),(UINT32)s.size(),&value)); }
|
||||||
|
~String() { WindowsDeleteString(value); }
|
||||||
|
};
|
||||||
|
std::filesystem::path cacheDirectory()
|
||||||
|
{
|
||||||
|
PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_LocalAppData,0,nullptr,&folder));
|
||||||
|
auto path=std::filesystem::path(folder)/L"KSwitcher"/L"ScreenshotNotifications";
|
||||||
|
CoTaskMemFree(folder); std::filesystem::create_directories(path); return path;
|
||||||
|
}
|
||||||
|
std::filesystem::path previewDirectory()
|
||||||
|
{
|
||||||
|
HANDLE token{};
|
||||||
|
if(!OpenProcessToken(GetCurrentProcess(),TOKEN_QUERY,&token)) check(HRESULT_FROM_WIN32(GetLastError()));
|
||||||
|
DWORD size{}; GetTokenInformation(token,TokenUser,nullptr,0,&size);
|
||||||
|
std::vector<BYTE> data(size);
|
||||||
|
BOOL read=GetTokenInformation(token,TokenUser,data.data(),size,&size);
|
||||||
|
DWORD error=read?ERROR_SUCCESS:GetLastError(); CloseHandle(token); check(HRESULT_FROM_WIN32(error));
|
||||||
|
auto user=reinterpret_cast<TOKEN_USER *>(data.data())->User.Sid;
|
||||||
|
LPWSTR sidText{}; if(!ConvertSidToStringSidW(user,&sidText)) check(HRESULT_FROM_WIN32(GetLastError()));
|
||||||
|
std::wstring sid(sidText); LocalFree(sidText);
|
||||||
|
PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_ProgramData,0,nullptr,&folder));
|
||||||
|
auto path=std::filesystem::path(folder)/(L"KSwitcher.ScreenshotPreviews-"+sid); CoTaskMemFree(folder);
|
||||||
|
// The notification broker cannot load previews from some profile directories.
|
||||||
|
// Keep this cache outside the profile, private to the current user and SYSTEM.
|
||||||
|
auto sddl=L"O:"+sid+L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;"+sid+L")";
|
||||||
|
PSECURITY_DESCRIPTOR security{};
|
||||||
|
if(!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(),SDDL_REVISION_1,&security,nullptr))
|
||||||
|
check(HRESULT_FROM_WIN32(GetLastError()));
|
||||||
|
SECURITY_ATTRIBUTES attributes{sizeof(attributes),security,FALSE};
|
||||||
|
BOOL created=CreateDirectoryW(path.c_str(),&attributes);
|
||||||
|
error=created?ERROR_SUCCESS:GetLastError();
|
||||||
|
LocalFree(security);
|
||||||
|
if(error!=ERROR_ALREADY_EXISTS) check(HRESULT_FROM_WIN32(error));
|
||||||
|
// Do not use a pre-created directory or junction controlled by another user.
|
||||||
|
DWORD flags=GetFileAttributesW(path.c_str());
|
||||||
|
if(flags==INVALID_FILE_ATTRIBUTES || !(flags&FILE_ATTRIBUTE_DIRECTORY) || (flags&FILE_ATTRIBUTE_REPARSE_POINT))
|
||||||
|
throw std::runtime_error("Invalid screenshot preview directory");
|
||||||
|
PSID owner{}; PSECURITY_DESCRIPTOR existing{};
|
||||||
|
error=GetNamedSecurityInfoW(const_cast<wchar_t *>(path.c_str()),SE_FILE_OBJECT,OWNER_SECURITY_INFORMATION,
|
||||||
|
&owner,nullptr,nullptr,nullptr,&existing);
|
||||||
|
check(HRESULT_FROM_WIN32(error));
|
||||||
|
bool owned=EqualSid(owner,user); LocalFree(existing);
|
||||||
|
if(!owned) throw std::runtime_error("Screenshot preview directory belongs to another user");
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
void cleanCache(const std::filesystem::path &path)
|
||||||
|
{
|
||||||
|
std::vector<std::filesystem::directory_entry> files;
|
||||||
|
for(const auto &file:std::filesystem::directory_iterator(path))
|
||||||
|
if(file.is_regular_file() && validScreenshotToken(file.path().stem().wstring()) &&
|
||||||
|
(file.path().extension()==L".capture" || file.path().extension()==L".png")) files.push_back(file);
|
||||||
|
std::sort(files.begin(),files.end(),[](const auto &a,const auto &b){return a.last_write_time()>b.last_write_time();});
|
||||||
|
uintmax_t size=0;
|
||||||
|
for(size_t i=0;i<files.size();++i) {
|
||||||
|
size+=files[i].file_size();
|
||||||
|
if(i>=40 || size>256*1024*1024 || std::filesystem::file_time_type::clock::now()-files[i].last_write_time()>std::chrono::hours(24)) {
|
||||||
|
std::error_code ignored; std::filesystem::remove(files[i].path(),ignored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void savePreview(const Clip &clip,const std::filesystem::path &path)
|
||||||
|
{
|
||||||
|
auto pixels=decodeImage(clip,360,220);
|
||||||
|
for(size_t i=3;i<pixels.bgra.size();i+=4) pixels.bgra[i]=255;
|
||||||
|
ComPtr<IWICImagingFactory> factory; check(CoCreateInstance(CLSID_WICImagingFactory,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&factory)));
|
||||||
|
ComPtr<IWICStream> stream; check(factory->CreateStream(&stream)); check(stream->InitializeFromFilename(path.c_str(),GENERIC_WRITE));
|
||||||
|
ComPtr<IWICBitmapEncoder> encoder; check(factory->CreateEncoder(GUID_ContainerFormatPng,nullptr,&encoder));
|
||||||
|
check(encoder->Initialize(stream.Get(),WICBitmapEncoderNoCache));
|
||||||
|
ComPtr<IWICBitmapFrameEncode> frame; check(encoder->CreateNewFrame(&frame,nullptr)); check(frame->Initialize(nullptr));
|
||||||
|
check(frame->SetSize(pixels.width,pixels.height));
|
||||||
|
auto format=GUID_WICPixelFormat32bppBGRA; check(frame->SetPixelFormat(&format));
|
||||||
|
if(format!=GUID_WICPixelFormat32bppBGRA) throw std::runtime_error("Unsupported preview pixel format");
|
||||||
|
check(frame->WritePixels(pixels.height,pixels.width*4,(UINT)pixels.bgra.size(),pixels.bgra.data()));
|
||||||
|
check(frame->Commit()); check(encoder->Commit());
|
||||||
|
}
|
||||||
|
std::wstring escapeXml(const std::wstring &s)
|
||||||
|
{
|
||||||
|
std::wstring result;
|
||||||
|
for(auto ch:s) { if(ch==L'&') result+=L"&"; else if(ch==L'\"') result+=L"""; else if(ch==L'<') result+=L"<"; else result+=ch; }
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::wstring cacheScreenshotForEditor(const Clip &clip)
|
||||||
|
{
|
||||||
|
// Normalize PNG/DIBV5 and other supported clipboard images at full resolution.
|
||||||
|
auto pixels=decodeImage(clip);
|
||||||
|
if(pixels.bgra.empty() || pixels.bgra.size()>128*1024*1024-sizeof(BITMAPINFOHEADER))
|
||||||
|
throw std::runtime_error("Image is too large for the screenshot editor");
|
||||||
|
auto dib=imageDib(pixels);
|
||||||
|
auto directory=cacheDirectory(); cleanCache(directory);
|
||||||
|
GUID guid{}; check(CoCreateGuid(&guid)); wchar_t value[40]{}; StringFromGUID2(guid,value,40);
|
||||||
|
std::wstring id(value+1,36);
|
||||||
|
atomicWrite(directory/(id+L".capture"),protect(dib.data));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
Clip loadNotifiedScreenshot(const std::wstring &id)
|
||||||
|
{
|
||||||
|
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
|
||||||
|
auto directory=cacheDirectory(); cleanCache(directory);
|
||||||
|
auto path=directory/(id+L".capture");
|
||||||
|
if(!std::filesystem::exists(path)) throw std::runtime_error("This screenshot notification has expired. Take a new screenshot.");
|
||||||
|
return Clip{{{CF_DIB,L"",unprotect(readFile(path,160*1024*1024))}}};
|
||||||
|
}
|
||||||
|
bool notifyScreenshot(const Clip &clip)
|
||||||
|
{
|
||||||
|
notificationError.clear();
|
||||||
|
const char *stage="registration";
|
||||||
|
try {
|
||||||
|
registerScreenshotNotifications();
|
||||||
|
check(SetCurrentProcessExplicitAppUserModelID(AppId));
|
||||||
|
stage="notification manager";
|
||||||
|
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
|
||||||
|
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
|
||||||
|
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
|
||||||
|
ComPtr<Notifications::IToastNotifier> notifier; String appId(AppId);
|
||||||
|
stage="notifier";
|
||||||
|
check(manager->CreateToastNotifierWithId(appId.value,¬ifier));
|
||||||
|
stage="settings";
|
||||||
|
Notifications::NotificationSetting setting{};
|
||||||
|
auto settingResult=notifier->get_Setting(&setting);
|
||||||
|
// A first-time sender has no settings record until its first notification.
|
||||||
|
if(settingResult!=HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) check(settingResult);
|
||||||
|
if(setting!=Notifications::NotificationSetting_Enabled) throw std::runtime_error("Screenshot notifications are disabled in Windows settings");
|
||||||
|
stage="cache";
|
||||||
|
auto directory=cacheDirectory(); cleanCache(directory);
|
||||||
|
GUID guid{}; check(CoCreateGuid(&guid)); wchar_t value[40]{}; StringFromGUID2(guid,value,40);
|
||||||
|
std::wstring id(value+1,36);
|
||||||
|
auto dib=std::find_if(clip.formats.begin(),clip.formats.end(),[](const auto &format){return format.id==CF_DIB;});
|
||||||
|
if(dib==clip.formats.end() || dib->data.size()>128*1024*1024) return false;
|
||||||
|
atomicWrite(directory/(id+L".capture"),protect(dib->data));
|
||||||
|
stage="preview";
|
||||||
|
auto previews=previewDirectory(); cleanCache(previews);
|
||||||
|
auto preview=previews/(id+L".png"); savePreview(clip,preview);
|
||||||
|
cleanCache(previews);
|
||||||
|
cleanCache(directory);
|
||||||
|
stage="XML";
|
||||||
|
String documentClass(L"Windows.Data.Xml.Dom.XmlDocument"); ComPtr<IInspectable> documentInstance;
|
||||||
|
check(RoActivateInstance(documentClass.value,&documentInstance));
|
||||||
|
ComPtr<Xml::IXmlDocument> document; check(documentInstance.As(&document));
|
||||||
|
ComPtr<Xml::IXmlDocumentIO> documentIO; check(document.As(&documentIO));
|
||||||
|
auto uri=L"file:///"+preview.generic_wstring();
|
||||||
|
String content(L"<toast launch=\""+id+L"\"><visual><binding template=\"ToastGeneric\">"
|
||||||
|
L"<text>Screenshot copied</text><text>Click to edit and annotate</text><image id=\"1\" placement=\"appLogoOverride\" alt=\"Screenshot preview\" src=\""+escapeXml(uri)+L"\"/></binding></visual><audio silent=\"true\"/></toast>");
|
||||||
|
check(documentIO->LoadXml(content.value));
|
||||||
|
stage="toast factory";
|
||||||
|
ComPtr<Notifications::IToastNotificationFactory> toastFactory; String toastClass(L"Windows.UI.Notifications.ToastNotification");
|
||||||
|
check(RoGetActivationFactory(toastClass.value,IID_PPV_ARGS(&toastFactory)));
|
||||||
|
ComPtr<Notifications::IToastNotification> toast; check(toastFactory->CreateToastNotification(document.Get(),&toast));
|
||||||
|
ComPtr<ABI::Windows::Foundation::IPropertyValueStatics> values;
|
||||||
|
String propertyClass(L"Windows.Foundation.PropertyValue");
|
||||||
|
check(RoGetActivationFactory(propertyClass.value,IID_PPV_ARGS(&values)));
|
||||||
|
FILETIME now{}; GetSystemTimeAsFileTime(&now);
|
||||||
|
ULARGE_INTEGER timestamp{}; timestamp.LowPart=now.dwLowDateTime; timestamp.HighPart=now.dwHighDateTime;
|
||||||
|
ABI::Windows::Foundation::DateTime expires{(INT64)(timestamp.QuadPart+24ull*60*60*10000000)};
|
||||||
|
ComPtr<IInspectable> boxed; check(values->CreateDateTime(expires,&boxed));
|
||||||
|
ComPtr<ABI::Windows::Foundation::IReference<ABI::Windows::Foundation::DateTime>> expiration;
|
||||||
|
check(boxed.As(&expiration)); check(toast->put_ExpirationTime(expiration.Get()));
|
||||||
|
stage="show";
|
||||||
|
check(notifier->Show(toast.Get()));
|
||||||
|
return true;
|
||||||
|
} catch(const std::exception &e) { notificationError=std::string(stage)+": "+e.what(); log(wide(notificationError)); return false; } // Clipboard is already populated; fall back to the editor.
|
||||||
|
}
|
||||||
|
std::string screenshotNotificationError() { return notificationError; }
|
||||||
|
|
||||||
|
std::wstring updateToastXml(const std::wstring &version)
|
||||||
|
{
|
||||||
|
return L"<toast launch=\"check-update\"><visual><binding template=\"ToastGeneric\">"
|
||||||
|
L"<text>KSwitcher update available</text><text>Version " + escapeXml(version) +
|
||||||
|
L" is available. Click to review and install.</text></binding></visual>"
|
||||||
|
L"<actions><action content=\"Review update\" arguments=\"check-update\" activationType=\"foreground\"/>"
|
||||||
|
L"<action content=\"Later\" arguments=\"dismiss\" activationType=\"system\"/></actions></toast>";
|
||||||
|
}
|
||||||
|
bool notifyUpdateAvailable(const std::wstring &version)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Reuse the screenshot notification identity, Windows settings and COM activator.
|
||||||
|
registerScreenshotNotifications();
|
||||||
|
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
|
||||||
|
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
|
||||||
|
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
|
||||||
|
ComPtr<Notifications::IToastNotifier> notifier; String appId(AppId);
|
||||||
|
check(manager->CreateToastNotifierWithId(appId.value,¬ifier));
|
||||||
|
Notifications::NotificationSetting setting{};
|
||||||
|
auto settingsResult=notifier->get_Setting(&setting);
|
||||||
|
if(settingsResult!=HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) check(settingsResult);
|
||||||
|
if(setting!=Notifications::NotificationSetting_Enabled)
|
||||||
|
throw std::runtime_error("Windows notifications for KSwitcher Screenshot are disabled");
|
||||||
|
String documentClass(L"Windows.Data.Xml.Dom.XmlDocument"); ComPtr<IInspectable> instance;
|
||||||
|
check(RoActivateInstance(documentClass.value,&instance));
|
||||||
|
ComPtr<Xml::IXmlDocument> document; check(instance.As(&document));
|
||||||
|
ComPtr<Xml::IXmlDocumentIO> io; check(document.As(&io));
|
||||||
|
String content(updateToastXml(version)); check(io->LoadXml(content.value));
|
||||||
|
ComPtr<Notifications::IToastNotificationFactory> factory; String toastClass(L"Windows.UI.Notifications.ToastNotification");
|
||||||
|
check(RoGetActivationFactory(toastClass.value,IID_PPV_ARGS(&factory)));
|
||||||
|
ComPtr<Notifications::IToastNotification> toast; check(factory->CreateToastNotification(document.Get(),&toast));
|
||||||
|
// Repeated submissions replace the existing update card in Notification Center.
|
||||||
|
ComPtr<Notifications::IToastNotification2> tagged; check(toast.As(&tagged));
|
||||||
|
String tag(L"update"),group(L"KSwitcher"); check(tagged->put_Tag(tag.value)); check(tagged->put_Group(group.value));
|
||||||
|
check(notifier->Show(toast.Get()));
|
||||||
|
return true;
|
||||||
|
} catch(const std::exception &e) { log(L"Update toast: "+wide(e.what())); return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "platform.h"
|
||||||
|
namespace ks {
|
||||||
|
bool notifyScreenshot(const Clip &);
|
||||||
|
std::string screenshotNotificationError();
|
||||||
|
void registerScreenshotNotifications();
|
||||||
|
int runScreenshotToastActivator();
|
||||||
|
DWORD startScreenshotActivationListener();
|
||||||
|
bool validScreenshotToken(const std::wstring &);
|
||||||
|
Clip loadNotifiedScreenshot(const std::wstring &);
|
||||||
|
std::wstring cacheScreenshotForEditor(const Clip &);
|
||||||
|
}
|
||||||
+160
-6
@@ -8,6 +8,22 @@
|
|||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
using Json = nlohmann::json;
|
using Json = nlohmann::json;
|
||||||
|
std::wstring snippetId(const Snippet &snippet)
|
||||||
|
{
|
||||||
|
if (!snippet.id.empty()) return snippet.id;
|
||||||
|
// Deterministic migration of pre-ID profiles on every existing device.
|
||||||
|
std::wstring id = L"legacy-";
|
||||||
|
const wchar_t *hex = L"0123456789abcdef";
|
||||||
|
for (unsigned char c : utf8(lower(snippet.trigger))) { id += hex[c >> 4]; id += hex[c & 15]; }
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
std::wstring newSnippetId()
|
||||||
|
{
|
||||||
|
GUID guid{}; wchar_t text[40]{};
|
||||||
|
if (FAILED(CoCreateGuid(&guid))) throw std::runtime_error("Cannot create snippet ID.");
|
||||||
|
StringFromGUID2(guid, text, 40);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
Bytes jsonBytes(const Json &j)
|
Bytes jsonBytes(const Json &j)
|
||||||
@@ -44,6 +60,13 @@ bool validId(const std::wstring &id)
|
|||||||
} // namespace
|
} // namespace
|
||||||
void validateSettings(const Settings &s)
|
void validateSettings(const Settings &s)
|
||||||
{
|
{
|
||||||
|
if (s.tesseractPath.size() > 32700 || s.tesseractPath.find_first_of(L"\r\n\"") != s.tesseractPath.npos)
|
||||||
|
throw std::runtime_error("Invalid Tesseract executable path");
|
||||||
|
if (s.tesseractLanguages.empty() || s.tesseractLanguages.size() > 128 ||
|
||||||
|
s.tesseractLanguages.front() == L'+' || s.tesseractLanguages.back() == L'+' ||
|
||||||
|
s.tesseractLanguages.find(L"++") != s.tesseractLanguages.npos ||
|
||||||
|
s.tesseractLanguages.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+") != s.tesseractLanguages.npos)
|
||||||
|
throw std::runtime_error("Use Tesseract language codes separated by +, for example ukr+eng+rus");
|
||||||
std::vector<Hotkey> used;
|
std::vector<Hotkey> used;
|
||||||
std::vector<std::wstring> keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard, s.screenshot};
|
std::vector<std::wstring> keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard, s.screenshot};
|
||||||
keys.insert(keys.end(), s.direct.begin(), s.direct.end());
|
keys.insert(keys.end(), s.direct.begin(), s.direct.end());
|
||||||
@@ -85,12 +108,42 @@ void validateSettings(const Settings &s)
|
|||||||
throw std::runtime_error("Unsupported auto-correction language");
|
throw std::runtime_error("Unsupported auto-correction language");
|
||||||
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
|
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
|
||||||
throw std::runtime_error("Too many auto-correction exceptions");
|
throw std::runtime_error("Too many auto-correction exceptions");
|
||||||
|
if (s.autoReplacements.size() > 10000)
|
||||||
|
throw std::runtime_error("Too many automatic replacements");
|
||||||
|
for (const auto &[from, to] : s.autoReplacements)
|
||||||
|
{
|
||||||
|
bool valid = false;
|
||||||
|
for (auto alphabet : {L"abcdefghijklmnopqrstuvwxyz", L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя",
|
||||||
|
L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя"})
|
||||||
|
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
|
||||||
|
from == lower(from) && to == lower(to) && from != to &&
|
||||||
|
from.find_first_not_of(alphabet) == from.npos && to.find_first_not_of(alphabet) == to.npos;
|
||||||
|
if (!valid)
|
||||||
|
throw std::runtime_error("Replacement pairs must be different lowercase words of 1-32 letters in the same language");
|
||||||
|
}
|
||||||
|
for (const auto &[language, words] : s.autoWords)
|
||||||
|
{
|
||||||
|
std::wstring alphabet = language == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
|
||||||
|
language == L"uk" ? L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя" :
|
||||||
|
language == L"ru" ? L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя" : L"";
|
||||||
|
if (alphabet.empty() || words.size() > 10000)
|
||||||
|
throw std::runtime_error("Invalid personal dictionary language or too many words");
|
||||||
|
for (const auto &word : words)
|
||||||
|
if (word.empty() || word.size() > 32 || lower(word).find_first_not_of(alphabet) != word.npos)
|
||||||
|
throw std::runtime_error("Personal words must contain 1-32 letters of their selected language");
|
||||||
|
}
|
||||||
if (s.maxItems < 10 || s.maxItems > 10000 || s.maxMiB < 1 || s.maxMiB > 2048 || s.maxImageMiB < 1 ||
|
if (s.maxItems < 10 || s.maxItems > 10000 || s.maxMiB < 1 || s.maxMiB > 2048 || s.maxImageMiB < 1 ||
|
||||||
s.maxImageMiB > 64)
|
s.maxImageMiB > 64)
|
||||||
throw std::runtime_error("History limits: 10–10000 items, 1–2048 MiB total, 1–64 MiB per image");
|
throw std::runtime_error("History limits: 10–10000 items, 1–2048 MiB total, 1–64 MiB per image");
|
||||||
std::set<std::wstring> seen;
|
std::set<std::wstring> seen;
|
||||||
|
std::set<std::wstring> ids;
|
||||||
|
for (const auto &id : s.snippetTombstones)
|
||||||
|
if (id.empty() || id.size() > 600 || !ids.insert(id).second)
|
||||||
|
throw std::runtime_error("Invalid snippet deletion record.");
|
||||||
for (const auto &snippet : s.snippets)
|
for (const auto &snippet : s.snippets)
|
||||||
{
|
{
|
||||||
|
auto id = snippetId(snippet);
|
||||||
|
if (id.size() > 600 || !ids.insert(id).second) throw std::runtime_error("Duplicate snippet ID or deleted snippet.");
|
||||||
if (snippet.trigger.size() < 2 || snippet.trigger.size() > 64 ||
|
if (snippet.trigger.size() < 2 || snippet.trigger.size() > 64 ||
|
||||||
snippet.trigger.find_first_of(L"\r\n\t ") != snippet.trigger.npos)
|
snippet.trigger.find_first_of(L"\r\n\t ") != snippet.trigger.npos)
|
||||||
throw std::runtime_error("Snippet triggers must contain 2–64 characters without whitespace");
|
throw std::runtime_error("Snippet triggers must contain 2–64 characters without whitespace");
|
||||||
@@ -125,14 +178,30 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
|||||||
s.switchLayout = j.value("switchSystemLayout", true);
|
s.switchLayout = j.value("switchSystemLayout", true);
|
||||||
s.writeLog = j.value("writeLog", true);
|
s.writeLog = j.value("writeLog", true);
|
||||||
s.history = j.value("historyEnabled", true);
|
s.history = j.value("historyEnabled", true);
|
||||||
|
s.useTesseract = j.value("useTesseract", false);
|
||||||
|
s.tesseractPath = str(j, "tesseractPath");
|
||||||
|
s.tesseractLanguages = str(j, "tesseractLanguages", s.tesseractLanguages);
|
||||||
s.autostart = j.value("startWithSystem", false);
|
s.autostart = j.value("startWithSystem", false);
|
||||||
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
|
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
|
||||||
s.awake = j.value("preventScreenLock", false);
|
s.awake = j.value("preventScreenLock", false);
|
||||||
s.autoCorrect = j.value("autoCorrectLayout", false);
|
s.autoCorrect = j.value("autoCorrectLayout", false);
|
||||||
|
s.autoReplace = j.value("autoCorrectReplace", false);
|
||||||
|
s.autoTypos = j.value("autoCorrectTypos", false);
|
||||||
|
if (j.contains("autoCorrectReplacements"))
|
||||||
|
for (auto &[from, to] : j.at("autoCorrectReplacements").items())
|
||||||
|
{
|
||||||
|
auto key = lower(wide(from));
|
||||||
|
if (!s.autoReplacements.emplace(key, lower(wide(to.get<std::string>()))).second)
|
||||||
|
throw std::runtime_error("Duplicate automatic replacement");
|
||||||
|
}
|
||||||
s.autoMaxRank = j.value("autoCorrectMaxRank", 0);
|
s.autoMaxRank = j.value("autoCorrectMaxRank", 0);
|
||||||
s.autoMinLength = j.value("autoCorrectMinLength", 3);
|
s.autoMinLength = j.value("autoCorrectMinLength", 3);
|
||||||
s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
|
s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
|
||||||
s.autoSuffixChars = str(j, "autoCorrectSuffixChars", s.autoSuffixChars);
|
s.autoSuffixChars = str(j, "autoCorrectSuffixChars", s.autoSuffixChars);
|
||||||
|
if (j.contains("autoCorrectWords"))
|
||||||
|
for (auto &[language, words] : j.at("autoCorrectWords").items())
|
||||||
|
for (auto &word : words)
|
||||||
|
s.autoWords[wide(language)].push_back(lower(wide(word.get<std::string>())));
|
||||||
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
|
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
|
||||||
{"autoCorrectNever", &s.autoNever},
|
{"autoCorrectNever", &s.autoNever},
|
||||||
{"autoCorrectExcludedApps", &s.autoExcluded}})
|
{"autoCorrectExcludedApps", &s.autoExcluded}})
|
||||||
@@ -151,6 +220,7 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
|||||||
s.undo = str(j, "undoHotkey", s.undo);
|
s.undo = str(j, "undoHotkey", s.undo);
|
||||||
s.clipboard = str(j, "clipboardHotkey", s.clipboard);
|
s.clipboard = str(j, "clipboardHotkey", s.clipboard);
|
||||||
s.screenshot = str(j, "screenshotHotkey", s.screenshot);
|
s.screenshot = str(j, "screenshotHotkey", s.screenshot);
|
||||||
|
s.screenshotAutoOpenEditor = j.value("screenshotAutoOpenEditor", false);
|
||||||
if (j.contains("layouts"))
|
if (j.contains("layouts"))
|
||||||
{
|
{
|
||||||
s.cycle.clear();
|
s.cycle.clear();
|
||||||
@@ -177,7 +247,13 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
|||||||
}
|
}
|
||||||
if (j.contains("snippets"))
|
if (j.contains("snippets"))
|
||||||
for (auto &sn : j.at("snippets"))
|
for (auto &sn : j.at("snippets"))
|
||||||
s.snippets.push_back({str(sn, "trigger"), str(sn, "text")});
|
{
|
||||||
|
Snippet item{str(sn, "trigger"), str(sn, "text"), str(sn, "id")};
|
||||||
|
item.id = snippetId(item);
|
||||||
|
s.snippets.push_back(std::move(item));
|
||||||
|
}
|
||||||
|
if (j.contains("snippetTombstones"))
|
||||||
|
for (const auto &id : j.at("snippetTombstones")) s.snippetTombstones.push_back(wide(id.get<std::string>()));
|
||||||
validateSettings(s);
|
validateSettings(s);
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
@@ -195,6 +271,9 @@ Bytes encodeSettings(const Settings &s)
|
|||||||
{"writeLog", s.writeLog},
|
{"writeLog", s.writeLog},
|
||||||
{"autostartAsAdministrator", s.elevatedAutostart},
|
{"autostartAsAdministrator", s.elevatedAutostart},
|
||||||
{"historyEnabled", s.history},
|
{"historyEnabled", s.history},
|
||||||
|
{"useTesseract", s.useTesseract},
|
||||||
|
{"tesseractPath", utf8(s.tesseractPath)},
|
||||||
|
{"tesseractLanguages", utf8(s.tesseractLanguages)},
|
||||||
{"startWithSystem", s.autostart},
|
{"startWithSystem", s.autostart},
|
||||||
{"preventScreenLock", s.awake},
|
{"preventScreenLock", s.awake},
|
||||||
{"maxItems", s.maxItems},
|
{"maxItems", s.maxItems},
|
||||||
@@ -205,15 +284,28 @@ Bytes encodeSettings(const Settings &s)
|
|||||||
{"translitHotkey", utf8(s.translit)},
|
{"translitHotkey", utf8(s.translit)},
|
||||||
{"undoHotkey", utf8(s.undo)},
|
{"undoHotkey", utf8(s.undo)},
|
||||||
{"clipboardHotkey", utf8(s.clipboard)},
|
{"clipboardHotkey", utf8(s.clipboard)},
|
||||||
{"screenshotHotkey", utf8(s.screenshot)}};
|
{"screenshotHotkey", utf8(s.screenshot)},
|
||||||
|
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
|
||||||
j["layouts"] = Json::array();
|
j["layouts"] = Json::array();
|
||||||
for (auto &id : s.cycle)
|
for (auto &id : s.cycle)
|
||||||
j["layouts"].push_back(utf8(id));
|
j["layouts"].push_back(utf8(id));
|
||||||
j["autoCorrectLayout"] = s.autoCorrect;
|
j["autoCorrectLayout"] = s.autoCorrect;
|
||||||
|
j["autoCorrectReplace"] = s.autoReplace;
|
||||||
|
j["autoCorrectTypos"] = s.autoTypos;
|
||||||
|
j["autoCorrectReplacements"] = Json::object();
|
||||||
|
for (const auto &[from, to] : s.autoReplacements)
|
||||||
|
j["autoCorrectReplacements"][utf8(from)] = utf8(to);
|
||||||
j["autoCorrectMaxRank"] = s.autoMaxRank;
|
j["autoCorrectMaxRank"] = s.autoMaxRank;
|
||||||
j["autoCorrectMinLength"] = s.autoMinLength;
|
j["autoCorrectMinLength"] = s.autoMinLength;
|
||||||
j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars);
|
j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars);
|
||||||
j["autoCorrectSuffixChars"] = utf8(s.autoSuffixChars);
|
j["autoCorrectSuffixChars"] = utf8(s.autoSuffixChars);
|
||||||
|
j["autoCorrectWords"] = Json::object();
|
||||||
|
for (const auto &[language, words] : s.autoWords)
|
||||||
|
{
|
||||||
|
auto &items = j["autoCorrectWords"][utf8(language)] = Json::array();
|
||||||
|
for (const auto &word : words)
|
||||||
|
items.push_back(utf8(lower(word)));
|
||||||
|
}
|
||||||
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
|
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
|
||||||
{"autoCorrectNever", &s.autoNever},
|
{"autoCorrectNever", &s.autoNever},
|
||||||
{"autoCorrectExcludedApps", &s.autoExcluded}})
|
{"autoCorrectExcludedApps", &s.autoExcluded}})
|
||||||
@@ -227,7 +319,9 @@ Bytes encodeSettings(const Settings &s)
|
|||||||
j["layoutHotkeys"].push_back({{"layoutId", utf8(s.directIds[i])}, {"hotkey", utf8(s.direct[i])}});
|
j["layoutHotkeys"].push_back({{"layoutId", utf8(s.directIds[i])}, {"hotkey", utf8(s.direct[i])}});
|
||||||
j["snippets"] = Json::array();
|
j["snippets"] = Json::array();
|
||||||
for (auto &sn : s.snippets)
|
for (auto &sn : s.snippets)
|
||||||
j["snippets"].push_back({{"trigger", utf8(sn.trigger)}, {"text", utf8(sn.text)}});
|
j["snippets"].push_back({{"trigger", utf8(sn.trigger)}, {"text", utf8(sn.text)}, {"id", utf8(snippetId(sn))}});
|
||||||
|
j["snippetTombstones"] = Json::array();
|
||||||
|
for (const auto &id : s.snippetTombstones) j["snippetTombstones"].push_back(utf8(id));
|
||||||
j["excludedApps"] = Json::array();
|
j["excludedApps"] = Json::array();
|
||||||
for (auto &name : s.excluded)
|
for (auto &name : s.excluded)
|
||||||
j["excludedApps"].push_back(utf8(name));
|
j["excludedApps"].push_back(utf8(name));
|
||||||
@@ -295,7 +389,8 @@ History::History(const std::filesystem::path &folder) : folder_(folder)
|
|||||||
e.value("at", uint64_t{}),
|
e.value("at", uint64_t{}),
|
||||||
e.value("pinned", false),
|
e.value("pinned", false),
|
||||||
e.value("image", false),
|
e.value("image", false),
|
||||||
e.value("files", false)};
|
e.value("files", false), e.value("ocrState", e.value("image", false) ? 1 : 0),
|
||||||
|
str(e, "ocrProfile", L"windows")};
|
||||||
if (validId(item.id) && std::filesystem::exists(folder_ / (item.id + L".dat")))
|
if (validId(item.id) && std::filesystem::exists(folder_ / (item.id + L".dat")))
|
||||||
entries_.push_back(std::move(item));
|
entries_.push_back(std::move(item));
|
||||||
}
|
}
|
||||||
@@ -311,7 +406,7 @@ void History::saveIndex()
|
|||||||
{"at", e.at},
|
{"at", e.at},
|
||||||
{"pinned", e.pinned},
|
{"pinned", e.pinned},
|
||||||
{"image", e.image},
|
{"image", e.image},
|
||||||
{"files", e.files}});
|
{"files", e.files}, {"ocrState", e.ocrState}, {"ocrProfile", utf8(e.ocrProfile)}});
|
||||||
atomicWrite(folder_ / L"index.dat", protect(jsonBytes(j)));
|
atomicWrite(folder_ / L"index.dat", protect(jsonBytes(j)));
|
||||||
}
|
}
|
||||||
std::vector<Entry> History::entries() const
|
std::vector<Entry> History::entries() const
|
||||||
@@ -340,6 +435,7 @@ void History::prune(const Settings &s)
|
|||||||
total -= entries_[i].bytes;
|
total -= entries_[i].bytes;
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
std::filesystem::remove(folder_ / (entries_[i].id + L".dat"), ec);
|
std::filesystem::remove(folder_ / (entries_[i].id + L".dat"), ec);
|
||||||
|
std::filesystem::remove(folder_ / (entries_[i].id + L".ocr"), ec);
|
||||||
entries_.erase(entries_.begin() + i);
|
entries_.erase(entries_.begin() + i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,7 +523,8 @@ void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
|
|||||||
GetSystemTimeAsFileTime(&now);
|
GetSystemTimeAsFileTime(&now);
|
||||||
entries_.insert(entries_.begin(),
|
entries_.insert(entries_.begin(),
|
||||||
Entry{id, text, app, encrypted.size(),
|
Entry{id, text, app, encrypted.size(),
|
||||||
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files});
|
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files,
|
||||||
|
image ? 1 : 0, ocrProfile(s)});
|
||||||
prune(s);
|
prune(s);
|
||||||
saveIndex();
|
saveIndex();
|
||||||
}
|
}
|
||||||
@@ -443,6 +540,7 @@ void History::remove(const std::wstring &id)
|
|||||||
saveIndex();
|
saveIndex();
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
std::filesystem::remove(folder_ / (id + L".dat"), ec);
|
std::filesystem::remove(folder_ / (id + L".dat"), ec);
|
||||||
|
std::filesystem::remove(folder_ / (id + L".ocr"), ec);
|
||||||
}
|
}
|
||||||
void History::pin(const std::wstring &id)
|
void History::pin(const std::wstring &id)
|
||||||
{
|
{
|
||||||
@@ -462,8 +560,49 @@ void History::clear()
|
|||||||
{
|
{
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
std::filesystem::remove(folder_ / (e.id + L".dat"), ec);
|
std::filesystem::remove(folder_ / (e.id + L".dat"), ec);
|
||||||
|
std::filesystem::remove(folder_ / (e.id + L".ocr"), ec);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
bool History::saveOcr(const std::wstring &id, const std::optional<std::wstring> &text, const Settings &settings)
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; });
|
||||||
|
// A late OCR completion must never recreate a deleted/pruned image.
|
||||||
|
auto profile = ocrProfile(settings);
|
||||||
|
if (it == entries_.end() || !it->image || (it->ocrState == 2 && it->ocrProfile == profile)) return false;
|
||||||
|
if (text)
|
||||||
|
{
|
||||||
|
auto utf = utf8(*text);
|
||||||
|
if (utf.size() > 1024 * 1024) throw std::runtime_error("OCR text exceeds storage limit");
|
||||||
|
// A version byte also makes empty recognition results encryptable by DPAPI.
|
||||||
|
Bytes payload{1};
|
||||||
|
payload.insert(payload.end(), utf.begin(), utf.end());
|
||||||
|
auto encrypted = protect(payload);
|
||||||
|
auto path = folder_ / (id + L".ocr");
|
||||||
|
std::error_code ec;
|
||||||
|
auto previousBytes = std::filesystem::file_size(path, ec);
|
||||||
|
if (ec) previousBytes = 0;
|
||||||
|
atomicWrite(path, encrypted);
|
||||||
|
it->bytes = it->bytes - std::min(it->bytes, uint64_t(previousBytes)) + encrypted.size();
|
||||||
|
it->ocrState = 2;
|
||||||
|
}
|
||||||
|
else it->ocrState = 3;
|
||||||
|
it->ocrProfile = profile;
|
||||||
|
prune(settings);
|
||||||
|
saveIndex();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::optional<std::wstring> History::ocrText(const std::wstring &id, const std::wstring &profile) const
|
||||||
|
{
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; });
|
||||||
|
if (it == entries_.end() || it->ocrState != 2 || (!profile.empty() && it->ocrProfile != profile)) return std::nullopt;
|
||||||
|
}
|
||||||
|
auto bytes = unprotect(readFile(folder_ / (id + L".ocr"), 2 * 1024 * 1024));
|
||||||
|
if (bytes.empty() || bytes.front() != 1) throw std::runtime_error("Invalid OCR text format");
|
||||||
|
return wide(std::string(bytes.begin() + 1, bytes.end()));
|
||||||
|
}
|
||||||
std::vector<Entry> History::search(const std::wstring &query) const
|
std::vector<Entry> History::search(const std::wstring &query) const
|
||||||
{
|
{
|
||||||
auto items = entries();
|
auto items = entries();
|
||||||
@@ -475,6 +614,21 @@ std::vector<Entry> History::search(const std::wstring &query) const
|
|||||||
{
|
{
|
||||||
if (lower(e.preview + L" " + e.app).find(q) != q.npos)
|
if (lower(e.preview + L" " + e.app).find(q) != q.npos)
|
||||||
return false;
|
return false;
|
||||||
|
if (e.ocrState == 2)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
auto text = ocrText(e.id);
|
||||||
|
if (text && lower(*text).find(q) != q.npos) return false;
|
||||||
|
}
|
||||||
|
catch (...) {} // Missing/corrupt OCR must not hide the original clip.
|
||||||
|
}
|
||||||
|
// Short previews contain the complete text (or a placeholder for
|
||||||
|
// textless clips). Do not decrypt image payloads just to rule them out.
|
||||||
|
// Newlines/tabs are flattened in previews, so those queries still
|
||||||
|
// need the original text. Exactly 240 characters may be truncated.
|
||||||
|
if (e.preview.size() < 240 && q.find_first_of(L"\r\n\t") == q.npos)
|
||||||
|
return true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return lower(load(e.id).text()).find(q) == q.npos;
|
return lower(load(e.id).text()).find(q) == q.npos;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ struct Entry
|
|||||||
std::wstring id, preview, app;
|
std::wstring id, preview, app;
|
||||||
uint64_t bytes{}, at{};
|
uint64_t bytes{}, at{};
|
||||||
bool pinned{}, image{}, files{};
|
bool pinned{}, image{}, files{};
|
||||||
|
int ocrState{}; // 0: legacy/not applicable, 1: pending, 2: saved, 3: failed
|
||||||
|
std::wstring ocrProfile = L"windows";
|
||||||
};
|
};
|
||||||
class History
|
class History
|
||||||
{
|
{
|
||||||
@@ -27,6 +29,8 @@ class History
|
|||||||
std::vector<Entry> entries() const;
|
std::vector<Entry> entries() const;
|
||||||
Clip load(const std::wstring &id) const;
|
Clip load(const std::wstring &id) const;
|
||||||
void add(const Clip &, const std::wstring &app, const Settings &);
|
void add(const Clip &, const std::wstring &app, const Settings &);
|
||||||
|
bool saveOcr(const std::wstring &id, const std::optional<std::wstring> &, const Settings &);
|
||||||
|
std::optional<std::wstring> ocrText(const std::wstring &id, const std::wstring &profile = L"") const;
|
||||||
void remove(const std::wstring &);
|
void remove(const std::wstring &);
|
||||||
void pin(const std::wstring &);
|
void pin(const std::wstring &);
|
||||||
void clear();
|
void clear();
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "screenshot.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace ks::stroke_detail {
|
||||||
|
struct Point { double x, y; };
|
||||||
|
inline double distance(Point a, Point b) { return std::hypot(a.x-b.x,a.y-b.y); }
|
||||||
|
inline double length(const std::vector<Point> &p) {
|
||||||
|
double total=0;
|
||||||
|
for(size_t i=1;i<p.size();++i) total+=distance(p[i-1],p[i]);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
// Arc-length sampling makes fitting independent of mouse speed and bounds its cost.
|
||||||
|
inline std::vector<Point> sample(const std::vector<Point> &p, size_t count) {
|
||||||
|
std::vector<Point> out{p.front()};
|
||||||
|
double step=length(p)/(count-1), traversed=0;
|
||||||
|
size_t segment=1;
|
||||||
|
for(size_t i=1;i+1<count;++i) {
|
||||||
|
double target=i*step;
|
||||||
|
while(segment+1<p.size() && traversed+distance(p[segment-1],p[segment])<target) {
|
||||||
|
traversed+=distance(p[segment-1],p[segment]); ++segment;
|
||||||
|
}
|
||||||
|
double span=distance(p[segment-1],p[segment]);
|
||||||
|
double t=span>0?(target-traversed)/span:0;
|
||||||
|
out.push_back({p[segment-1].x+t*(p[segment].x-p[segment-1].x),
|
||||||
|
p[segment-1].y+t*(p[segment].y-p[segment-1].y)});
|
||||||
|
}
|
||||||
|
out.push_back(p.back()); return out;
|
||||||
|
}
|
||||||
|
inline double segmentDistance(Point p, Point a, Point b) {
|
||||||
|
double dx=b.x-a.x,dy=b.y-a.y,den=dx*dx+dy*dy;
|
||||||
|
double t=den?std::clamp(((p.x-a.x)*dx+(p.y-a.y)*dy)/den,0.0,1.0):0;
|
||||||
|
return distance(p,{a.x+t*dx,a.y+t*dy});
|
||||||
|
}
|
||||||
|
inline double cross(Point a, Point b, Point c) { return (b.x-a.x)*(c.y-a.y)-(b.y-a.y)*(c.x-a.x); }
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace ks {
|
||||||
|
// Returns a replacement path; an empty result means no visible improvement.
|
||||||
|
// Report recognized geometry separately from a smoothed freehand path.
|
||||||
|
inline std::vector<POINT> improveScreenshotStroke(const std::vector<POINT> &input, ScreenshotShape *shape=nullptr,
|
||||||
|
bool *straightLine=nullptr) {
|
||||||
|
if(shape) *shape=ScreenshotShape::None;
|
||||||
|
if(straightLine) *straightLine=false;
|
||||||
|
using namespace stroke_detail;
|
||||||
|
if(input.size()<3) return {};
|
||||||
|
std::vector<Point> raw;
|
||||||
|
for(auto p:input) if(raw.empty() || distance(raw.back(),{double(p.x),double(p.y)})>0)
|
||||||
|
raw.push_back({double(p.x),double(p.y)});
|
||||||
|
double total=length(raw);
|
||||||
|
if(raw.size()>=3) {
|
||||||
|
double span=distance(raw.front(),raw.back());
|
||||||
|
if(span>=24 && total<=span*1.35) {
|
||||||
|
double tolerance=std::clamp(span*.03,2.0,6.0),maximum=0,backtracking=0,previous=0;
|
||||||
|
Point a=raw.front(),b=raw.back();
|
||||||
|
// Check every original point so a short excursion cannot disappear in resampling.
|
||||||
|
for(auto p:raw) {
|
||||||
|
maximum=std::max(maximum,segmentDistance(p,a,b));
|
||||||
|
double along=((p.x-a.x)*(b.x-a.x)+(p.y-a.y)*(b.y-a.y))/span;
|
||||||
|
backtracking+=std::max(0.0,previous-along); previous=along;
|
||||||
|
}
|
||||||
|
double squared=0;
|
||||||
|
for(auto p:sample(raw,64)) { double d=segmentDistance(p,a,b); squared+=d*d; }
|
||||||
|
if(maximum<=tolerance && std::sqrt(squared/64)<=tolerance*.7 &&
|
||||||
|
backtracking<=std::max(2.0,span*.02)) {
|
||||||
|
if(straightLine) *straightLine=true;
|
||||||
|
return {input.front(),input.back()};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(raw.size()<5 || total<16) return {};
|
||||||
|
auto points=sample(raw,128);
|
||||||
|
double left=points[0].x,right=left,top=points[0].y,bottom=top;
|
||||||
|
for(auto p:points) { left=std::min(left,p.x); right=std::max(right,p.x); top=std::min(top,p.y); bottom=std::max(bottom,p.y); }
|
||||||
|
double scale=std::min(right-left,bottom-top);
|
||||||
|
std::vector<Point> best;
|
||||||
|
double bestScore=1e30;
|
||||||
|
double gap=distance(raw.front(),raw.back());
|
||||||
|
if(scale>=20 && gap<=scale*.5) {
|
||||||
|
auto consider=[&](std::vector<Point> candidate, ScreenshotShape kind=ScreenshotShape::Rectangle) {
|
||||||
|
bool oval=kind==ScreenshotShape::Circle || kind==ScreenshotShape::Ellipse;
|
||||||
|
if(!oval && gap>scale*.22) return;
|
||||||
|
double perimeter=length(candidate);
|
||||||
|
if(total<perimeter*.82 || total>perimeter*1.25) return;
|
||||||
|
auto probes=sample(candidate,128);
|
||||||
|
double sum=0,maximum=0;
|
||||||
|
auto measure=[&](const std::vector<Point> &a,const std::vector<Point> &b) {
|
||||||
|
for(auto p:a) {
|
||||||
|
double d=1e30;
|
||||||
|
for(size_t i=1;i<b.size();++i) d=std::min(d,segmentDistance(p,b[i-1],b[i]));
|
||||||
|
sum+=d*d; maximum=std::max(maximum,d);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
measure(points,candidate); measure(probes,points);
|
||||||
|
double score=std::sqrt(sum/256);
|
||||||
|
if(maximum<=std::max(3.0,scale*(oval?.22:.12)) && score<=std::max(1.5,scale*(oval?.085:.055)) && score<bestScore) {
|
||||||
|
bestScore=score; best=std::move(candidate);
|
||||||
|
if(shape) *shape=kind;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Fit oriented bounding boxes; testing angles also handles tilted ovals.
|
||||||
|
for(int degrees=0;degrees<180;degrees+=5) {
|
||||||
|
double angle=degrees*3.141592653589793/180,cs=std::cos(angle),sn=std::sin(angle);
|
||||||
|
double x0=1e30,y0=x0,x1=-x0,y1=-x0;
|
||||||
|
for(auto p:points) {
|
||||||
|
double x=p.x*cs+p.y*sn,y=-p.x*sn+p.y*cs;
|
||||||
|
x0=std::min(x0,x); x1=std::max(x1,x); y0=std::min(y0,y); y1=std::max(y1,y);
|
||||||
|
}
|
||||||
|
auto world=[&](double x,double y)->Point { return {x*cs-y*sn,x*sn+y*cs}; };
|
||||||
|
consider({world(x0,y0),world(x1,y0),world(x1,y1),world(x0,y1),world(x0,y0)});
|
||||||
|
double rx=(x1-x0)/2,ry=(y1-y0)/2;
|
||||||
|
double cx=(x0+x1)/2,cy=(y0+y1)/2;
|
||||||
|
std::vector<Point> local;
|
||||||
|
for(auto p:points) local.push_back({p.x*cs+p.y*sn,-p.x*sn+p.y*cs});
|
||||||
|
// Fit four sides to their supporting samples. Extremal bounds exaggerate
|
||||||
|
// crooked corners and can unfairly make an oval score better than a box.
|
||||||
|
double sides[]{x0,x1,y0,y1};
|
||||||
|
bool supported=true;
|
||||||
|
for(int iteration=0;iteration<6;++iteration) {
|
||||||
|
double sums[4]{}; int counts[4]{};
|
||||||
|
for(auto p:local) {
|
||||||
|
double distances[]{std::abs(p.x-sides[0]),std::abs(p.x-sides[1]),
|
||||||
|
std::abs(p.y-sides[2]),std::abs(p.y-sides[3])};
|
||||||
|
auto side=std::min_element(distances,distances+4)-distances;
|
||||||
|
sums[side]+=side<2?p.x:p.y; ++counts[side];
|
||||||
|
}
|
||||||
|
for(int side=0;side<4;++side) {
|
||||||
|
if(counts[side]<10) { supported=false; break; }
|
||||||
|
sides[side]=sums[side]/counts[side];
|
||||||
|
}
|
||||||
|
if(!supported) break;
|
||||||
|
}
|
||||||
|
if(supported && sides[1]-sides[0]>=16 && sides[3]-sides[2]>=16)
|
||||||
|
consider({world(sides[0],sides[2]),world(sides[1],sides[2]),
|
||||||
|
world(sides[1],sides[3]),world(sides[0],sides[3]),world(sides[0],sides[2])});
|
||||||
|
// Fit the whole gesture instead of letting a single mouse wobble set the bounds.
|
||||||
|
auto error=[&](double x,double y,double a,double b) {
|
||||||
|
if(a<8 || b<8) return 1e30;
|
||||||
|
double sum=0;
|
||||||
|
for(auto p:local) {
|
||||||
|
double u=(p.x-x)/a,v=(p.y-y)/b,r=std::hypot(u,v);
|
||||||
|
double normal=std::hypot(u/a,v/b);
|
||||||
|
double d=normal>1e-9?(r-1)*r/normal:std::min(a,b);
|
||||||
|
sum+=d*d;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
};
|
||||||
|
double fit=error(cx,cy,rx,ry),step=std::max(rx,ry)*.08;
|
||||||
|
for(int iteration=0;iteration<10;++iteration) {
|
||||||
|
for(int parameter=0;parameter<4;++parameter) for(int sign:{-1,1}) {
|
||||||
|
double values[]{cx,cy,rx,ry}; values[parameter]+=sign*step;
|
||||||
|
double next=error(values[0],values[1],values[2],values[3]);
|
||||||
|
if(next<fit) { fit=next; cx=values[0]; cy=values[1]; rx=values[2]; ry=values[3]; }
|
||||||
|
}
|
||||||
|
step*=.65;
|
||||||
|
}
|
||||||
|
// Require almost one complete turn, with little retracing. This admits a small
|
||||||
|
// closing gap or overshoot but rejects C-shaped arcs, spirals and scribbles.
|
||||||
|
double turn=0,travel=0;
|
||||||
|
for(size_t i=1;i<local.size();++i) {
|
||||||
|
double a=std::atan2((local[i-1].y-cy)/ry,(local[i-1].x-cx)/rx);
|
||||||
|
double b=std::atan2((local[i].y-cy)/ry,(local[i].x-cx)/rx);
|
||||||
|
double delta=std::remainder(b-a,2*3.141592653589793);
|
||||||
|
turn+=delta; travel+=std::abs(delta);
|
||||||
|
}
|
||||||
|
if(std::abs(turn)<5.4 || std::abs(turn)>7.1 || travel-std::abs(turn)>.65) continue;
|
||||||
|
// Near-circular gestures become true circles.
|
||||||
|
bool circle=std::abs(rx-ry)<=std::max(rx,ry)*.12;
|
||||||
|
if(circle) rx=ry=(rx+ry)/2;
|
||||||
|
std::vector<Point> oval;
|
||||||
|
for(int i=0;i<96;++i) {
|
||||||
|
double t=i*2*3.141592653589793/96;
|
||||||
|
oval.push_back(world(cx+rx*std::cos(t),cy+ry*std::sin(t)));
|
||||||
|
}
|
||||||
|
oval.push_back(oval.front()); consider(std::move(oval),circle?ScreenshotShape::Circle:ScreenshotShape::Ellipse);
|
||||||
|
}
|
||||||
|
auto sorted=points;
|
||||||
|
std::sort(sorted.begin(),sorted.end(),[](Point a,Point b){return a.x<b.x || (a.x==b.x && a.y<b.y);});
|
||||||
|
std::vector<Point> hull;
|
||||||
|
for(auto p:sorted) {
|
||||||
|
while(hull.size()>1 && cross(hull[hull.size()-2],hull.back(),p)<=0) hull.pop_back();
|
||||||
|
hull.push_back(p);
|
||||||
|
}
|
||||||
|
size_t lower=hull.size();
|
||||||
|
for(size_t i=sorted.size()-1;i-->0;) {
|
||||||
|
auto p=sorted[i];
|
||||||
|
while(hull.size()>lower && cross(hull[hull.size()-2],hull.back(),p)<=0) hull.pop_back();
|
||||||
|
hull.push_back(p);
|
||||||
|
}
|
||||||
|
hull.pop_back();
|
||||||
|
while(hull.size()>3) {
|
||||||
|
if(hull.size()==4 && bestScore>scale*.035) {
|
||||||
|
// Opposite edges must already be nearly parallel; retain their unequal
|
||||||
|
// lengths and fit a shared direction instead of accepting any quadrilateral.
|
||||||
|
for(size_t i=0;i<2;++i) {
|
||||||
|
Point a=hull[i],b=hull[i+1],c=hull[(i+2)%4],d=hull[(i+3)%4];
|
||||||
|
double ab=distance(a,b),cd=distance(c,d);
|
||||||
|
if(std::min(ab,cd)<16 || std::min(ab,cd)/std::max(ab,cd)>.8) continue;
|
||||||
|
Point u{(b.x-a.x)/ab,(b.y-a.y)/ab},v{(c.x-d.x)/cd,(c.y-d.y)/cd};
|
||||||
|
if(u.x*v.x+u.y*v.y<.96) continue;
|
||||||
|
double norm=std::hypot(u.x+v.x,u.y+v.y);
|
||||||
|
Point axis{(u.x+v.x)/norm,(u.y+v.y)/norm};
|
||||||
|
auto along=[&](Point p){return p.x*axis.x+p.y*axis.y;};
|
||||||
|
auto across=[&](Point p){return -p.x*axis.y+p.y*axis.x;};
|
||||||
|
double y0=(across(a)+across(b))/2,y1=(across(c)+across(d))/2;
|
||||||
|
if(std::abs(y1-y0)<20) continue;
|
||||||
|
auto fit=[&](Point p,double y)->Point {double x=along(p);return {x*axis.x-y*axis.y,x*axis.y+y*axis.x};};
|
||||||
|
std::vector<Point> candidate{fit(a,y0),fit(b,y0),fit(c,y1),fit(d,y1),fit(a,y0)};
|
||||||
|
consider(std::move(candidate),ScreenshotShape::Trapezoid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
size_t remove=0; double area=1e30;
|
||||||
|
for(size_t i=0;i<hull.size();++i) {
|
||||||
|
double a=std::abs(cross(hull[(i+hull.size()-1)%hull.size()],hull[i],hull[(i+1)%hull.size()]));
|
||||||
|
if(a<area) { area=a; remove=i; }
|
||||||
|
}
|
||||||
|
hull.erase(hull.begin()+remove);
|
||||||
|
}
|
||||||
|
if(hull.size()==3) { hull.push_back(hull.front()); consider(hull,ScreenshotShape::Triangle); }
|
||||||
|
}
|
||||||
|
if(best.empty()) {
|
||||||
|
// A small spatial filter suppresses jitter without moving endpoints or flattening broad curves.
|
||||||
|
best=sample(raw,size_t(std::clamp(std::ceil(total/2)+1,5.0,2048.0)));
|
||||||
|
auto original=best;
|
||||||
|
for(size_t i=1;i+1<best.size();++i)
|
||||||
|
best[i]={(original[i-1].x+2*original[i].x+original[i+1].x)/4,
|
||||||
|
(original[i-1].y+2*original[i].y+original[i+1].y)/4};
|
||||||
|
}
|
||||||
|
std::vector<POINT> result;
|
||||||
|
for(auto p:best) {
|
||||||
|
POINT q{LONG(std::lround(p.x)),LONG(std::lround(p.y))};
|
||||||
|
if(result.empty() || result.back().x!=q.x || result.back().y!=q.y) result.push_back(q);
|
||||||
|
}
|
||||||
|
if(result.size()==input.size() && std::equal(result.begin(),result.end(),input.begin(),
|
||||||
|
[](POINT a,POINT b){return a.x==b.x && a.y==b.y;})) return {};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
+354
@@ -0,0 +1,354 @@
|
|||||||
|
#include "sync.h"
|
||||||
|
#include "json.hpp"
|
||||||
|
#include <winhttp.h>
|
||||||
|
#include <wincred.h>
|
||||||
|
#include <set>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace ks
|
||||||
|
{
|
||||||
|
using Json = nlohmann::json;
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr size_t Limit = 4 * 1024 * 1024;
|
||||||
|
Bytes bytes(const Json &j) { auto s = j.dump(); return {s.begin(), s.end()}; }
|
||||||
|
Json parse(const Bytes &b) { return Json::parse(b.begin(), b.end()); }
|
||||||
|
Json configJson(const SyncConfig &c)
|
||||||
|
{
|
||||||
|
return {{"enabled", c.enabled}, {"provider", utf8(c.provider)}, {"server", utf8(c.server)},
|
||||||
|
{"mount", utf8(c.mount)}, {"path", utf8(c.path)}, {"authMount", utf8(c.authMount)},
|
||||||
|
{"username", utf8(c.username)}, {"keepLocalLayouts", c.keepLocalLayouts}};
|
||||||
|
}
|
||||||
|
std::wstring credentialName(const SyncConfig &c)
|
||||||
|
{
|
||||||
|
return L"KSwitcher.Sync/" + c.server + L"/" + c.authMount + L"/" + c.username;
|
||||||
|
}
|
||||||
|
bool safePath(const std::wstring &s)
|
||||||
|
{
|
||||||
|
if (s.empty() || s.size() > 512 || s.front() == '/' || s.back() == '/' ||
|
||||||
|
s.find(L"//") != s.npos) return false;
|
||||||
|
return s.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-/") == s.npos;
|
||||||
|
}
|
||||||
|
struct HttpHandle
|
||||||
|
{
|
||||||
|
HINTERNET h{};
|
||||||
|
~HttpHandle() { if (h) WinHttpCloseHandle(h); }
|
||||||
|
};
|
||||||
|
class OpenBao final : public SyncProvider
|
||||||
|
{
|
||||||
|
SyncConfig config;
|
||||||
|
SyncTransport transport;
|
||||||
|
std::wstring token;
|
||||||
|
Json response(unsigned status, const Bytes &result, bool missing)
|
||||||
|
{
|
||||||
|
if (result.size() > Limit) throw std::runtime_error("OpenBao response is too large.");
|
||||||
|
if (status == 404 && missing)
|
||||||
|
{
|
||||||
|
auto j = result.empty() ? Json::object() : parse(result);
|
||||||
|
if (j.contains("data") && !j["data"].is_null())
|
||||||
|
throw std::runtime_error("Remote profile is deleted. Restore it in OpenBao first.");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
if (status == 401 || status == 403) throw std::runtime_error("OpenBao denied access. Check login and profile policy.");
|
||||||
|
if (status == 400) throw std::runtime_error("OpenBao rejected the write or its version changed. Sync again; check KV v2 configuration.");
|
||||||
|
if (status < 200 || status >= 300) throw std::runtime_error("OpenBao HTTP error " + std::to_string(status) + ".");
|
||||||
|
return result.empty() ? Json::object() : parse(result);
|
||||||
|
}
|
||||||
|
Json request(const wchar_t *method, const std::wstring &path, const Json *body = nullptr, bool missing = false)
|
||||||
|
{
|
||||||
|
if (transport)
|
||||||
|
{
|
||||||
|
auto reply = transport(method, path, token, body ? bytes(*body) : Bytes{});
|
||||||
|
return response(reply.status, reply.body, missing);
|
||||||
|
}
|
||||||
|
URL_COMPONENTS url{sizeof(url)};
|
||||||
|
url.dwHostNameLength = url.dwUrlPathLength = url.dwExtraInfoLength = (DWORD)-1;
|
||||||
|
if (!WinHttpCrackUrl(config.server.c_str(), 0, 0, &url)) throw std::runtime_error("Invalid server URL.");
|
||||||
|
std::wstring host(url.lpszHostName, url.dwHostNameLength);
|
||||||
|
HttpHandle session{WinHttpOpen(L"KSwitcher Sync/1", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
|
||||||
|
WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0)};
|
||||||
|
if (!session.h || !WinHttpSetTimeouts(session.h, 5000, 5000, 5000, 5000))
|
||||||
|
throw std::runtime_error("Cannot initialize sync connection.");
|
||||||
|
HttpHandle connection{WinHttpConnect(session.h, host.c_str(), url.nPort, 0)};
|
||||||
|
HttpHandle req{connection.h ? WinHttpOpenRequest(connection.h, method, path.c_str(), nullptr,
|
||||||
|
WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE) : nullptr};
|
||||||
|
DWORD redirects = WINHTTP_OPTION_REDIRECT_POLICY_NEVER;
|
||||||
|
if (!req.h || !WinHttpSetOption(req.h, WINHTTP_OPTION_REDIRECT_POLICY, &redirects, sizeof(redirects)))
|
||||||
|
throw std::runtime_error("Cannot initialize sync request.");
|
||||||
|
std::wstring headers = L"Content-Type: application/json\r\n";
|
||||||
|
if (!token.empty()) headers += L"X-Vault-Token: " + token + L"\r\n";
|
||||||
|
std::string payload = body ? body->dump() : "";
|
||||||
|
bool sent = WinHttpSendRequest(req.h, headers.c_str(), (DWORD)-1,
|
||||||
|
payload.empty() ? WINHTTP_NO_REQUEST_DATA : payload.data(), (DWORD)payload.size(), (DWORD)payload.size(), 0);
|
||||||
|
if (!payload.empty()) SecureZeroMemory(payload.data(), payload.size());
|
||||||
|
if (!sent || !WinHttpReceiveResponse(req.h, nullptr))
|
||||||
|
throw std::runtime_error("OpenBao connection failed. Check network, HTTPS certificate and server address.");
|
||||||
|
DWORD status{}, length = sizeof(status);
|
||||||
|
if (!WinHttpQueryHeaders(req.h, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
|
||||||
|
WINHTTP_HEADER_NAME_BY_INDEX, &status, &length, WINHTTP_NO_HEADER_INDEX))
|
||||||
|
throw std::runtime_error("Cannot read OpenBao response.");
|
||||||
|
Bytes result;
|
||||||
|
ULONGLONG deadline = GetTickCount64() + 15000;
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
unsigned char chunk[8192]; DWORD received{};
|
||||||
|
if (GetTickCount64() > deadline || !WinHttpReadData(req.h, chunk, sizeof(chunk), &received))
|
||||||
|
throw std::runtime_error("OpenBao response timed out.");
|
||||||
|
if (!received) break;
|
||||||
|
if (result.size() + received > Limit) throw std::runtime_error("OpenBao response is too large.");
|
||||||
|
result.insert(result.end(), chunk, chunk + received);
|
||||||
|
}
|
||||||
|
return response(status, result, missing);
|
||||||
|
}
|
||||||
|
public:
|
||||||
|
explicit OpenBao(const SyncConfig &c, SyncTransport t) : config(c), transport(std::move(t))
|
||||||
|
{
|
||||||
|
validateSyncConfig(c);
|
||||||
|
PCREDENTIALW credential{};
|
||||||
|
if (!CredReadW(credentialName(c).c_str(), CRED_TYPE_GENERIC, 0, &credential))
|
||||||
|
throw std::runtime_error("Sign in: no saved password for this server and user.");
|
||||||
|
std::string password(reinterpret_cast<char *>(credential->CredentialBlob), credential->CredentialBlobSize);
|
||||||
|
SecureZeroMemory(credential->CredentialBlob, credential->CredentialBlobSize);
|
||||||
|
CredFree(credential);
|
||||||
|
Json login{{"password", password}};
|
||||||
|
SecureZeroMemory(password.data(), password.size());
|
||||||
|
Json response;
|
||||||
|
try { response = request(L"POST", L"/v1/auth/" + c.authMount + L"/login/" + c.username, &login); }
|
||||||
|
catch (...) { auto &p = login["password"].get_ref<std::string &>(); SecureZeroMemory(p.data(), p.size()); throw; }
|
||||||
|
auto &p = login["password"].get_ref<std::string &>(); SecureZeroMemory(p.data(), p.size());
|
||||||
|
token = wide(response.at("auth").at("client_token").get<std::string>());
|
||||||
|
if (token.empty() || token.find_first_of(L"\r\n") != token.npos)
|
||||||
|
throw std::runtime_error("Invalid OpenBao authentication response.");
|
||||||
|
}
|
||||||
|
~OpenBao() override
|
||||||
|
{
|
||||||
|
if (token.empty()) return;
|
||||||
|
// Sessions are scoped to a sync attempt; do not accumulate login tokens.
|
||||||
|
try { request(L"POST", L"/v1/auth/token/revoke-self"); } catch (...) {}
|
||||||
|
SecureZeroMemory(token.data(), token.size() * sizeof(wchar_t));
|
||||||
|
}
|
||||||
|
SyncDocument read() override
|
||||||
|
{
|
||||||
|
auto j = request(L"GET", L"/v1/" + config.mount + L"/data/" + config.path, nullptr, true);
|
||||||
|
if (j.is_null()) return {};
|
||||||
|
const auto &data = j.at("data");
|
||||||
|
if (!data.at("metadata").value("deletion_time", std::string()).empty() ||
|
||||||
|
data.at("metadata").value("destroyed", false))
|
||||||
|
throw std::runtime_error("Remote profile is deleted or destroyed.");
|
||||||
|
auto payload = data.at("data");
|
||||||
|
if (payload.value("syncSchema", 0) != 1 && payload.value("syncSchema", 0) != 2) throw std::runtime_error("Unsupported remote sync format.");
|
||||||
|
return {data.at("metadata").at("version").get<uint64_t>(), bytes(payload.at("settings"))};
|
||||||
|
}
|
||||||
|
uint64_t write(const Bytes &settings, uint64_t revision) override
|
||||||
|
{
|
||||||
|
Json body{{"options", {{"cas", revision}}}, {"data", {{"syncSchema", 2}, {"settings", parse(settings)}}}};
|
||||||
|
auto response = request(L"POST", L"/v1/" + config.mount + L"/data/" + config.path, &body);
|
||||||
|
return response.at("data").at("version").get<uint64_t>();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
bool syncVisible(const std::filesystem::path &root) { return std::filesystem::exists(root / L"sync.json"); }
|
||||||
|
SyncConfig loadSyncConfig(const std::filesystem::path &root)
|
||||||
|
{
|
||||||
|
SyncConfig c;
|
||||||
|
if (!syncVisible(root)) return c;
|
||||||
|
auto j = parse(readFile(root / L"sync.json", 16384));
|
||||||
|
c.enabled = j.value("enabled", false);
|
||||||
|
c.keepLocalLayouts = j.value("keepLocalLayouts", false);
|
||||||
|
c.provider = wide(j.value("provider", "openbao"));
|
||||||
|
c.server = wide(j.value("server", ""));
|
||||||
|
c.mount = wide(j.value("mount", "kswitcher"));
|
||||||
|
c.path = wide(j.value("path", "personal/settings"));
|
||||||
|
c.authMount = wide(j.value("authMount", "userpass"));
|
||||||
|
c.username = wide(j.value("username", ""));
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
void validateSyncConfig(const SyncConfig &c)
|
||||||
|
{
|
||||||
|
if (c.provider != L"openbao") throw std::runtime_error("Unsupported sync provider.");
|
||||||
|
URL_COMPONENTS url{sizeof(url)};
|
||||||
|
url.dwHostNameLength = url.dwUrlPathLength = url.dwExtraInfoLength = url.dwUserNameLength = url.dwPasswordLength = (DWORD)-1;
|
||||||
|
if (c.server.size() > 1024 || !WinHttpCrackUrl(c.server.c_str(), 0, 0, &url) ||
|
||||||
|
url.nScheme != INTERNET_SCHEME_HTTPS || !url.dwHostNameLength || url.dwUserNameLength ||
|
||||||
|
url.dwPasswordLength || url.dwExtraInfoLength ||
|
||||||
|
(url.dwUrlPathLength && std::wstring(url.lpszUrlPath, url.dwUrlPathLength) != L"/"))
|
||||||
|
throw std::runtime_error("Server must be an HTTPS origin, for example https://bao.example.com (no path or credentials).");
|
||||||
|
bool userValid = !c.username.empty() && c.username.size() <= 256 && c.username != L"." && c.username != L".." &&
|
||||||
|
c.username.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.@") == c.username.npos;
|
||||||
|
if (!safePath(c.mount) || !safePath(c.path) || !safePath(c.authMount) || !userValid)
|
||||||
|
throw std::runtime_error("Use letters, numbers, underscores and hyphens in paths and login; / is allowed only in paths.");
|
||||||
|
if (credentialName(c).size() > CRED_MAX_GENERIC_TARGET_NAME_LENGTH)
|
||||||
|
throw std::runtime_error("Sync connection name is too long.");
|
||||||
|
}
|
||||||
|
void saveSyncConfig(const std::filesystem::path &root, const SyncConfig &c)
|
||||||
|
{
|
||||||
|
if (c.enabled) validateSyncConfig(c);
|
||||||
|
atomicWrite(root / L"sync.json", bytes(configJson(c)));
|
||||||
|
}
|
||||||
|
void saveSyncPassword(const SyncConfig &c, const std::wstring &password)
|
||||||
|
{
|
||||||
|
validateSyncConfig(c);
|
||||||
|
auto secret = utf8(password);
|
||||||
|
if (secret.empty() || secret.size() > CRED_MAX_CREDENTIAL_BLOB_SIZE) throw std::runtime_error("Password is empty or too long.");
|
||||||
|
auto target = credentialName(c);
|
||||||
|
CREDENTIALW credential{}; credential.Type = CRED_TYPE_GENERIC; credential.TargetName = target.data();
|
||||||
|
credential.UserName = const_cast<wchar_t *>(c.username.c_str());
|
||||||
|
credential.CredentialBlob = reinterpret_cast<BYTE *>(secret.data());
|
||||||
|
credential.CredentialBlobSize = (DWORD)secret.size(); credential.Persist = CRED_PERSIST_LOCAL_MACHINE;
|
||||||
|
bool ok = CredWriteW(&credential, 0) != 0;
|
||||||
|
SecureZeroMemory(secret.data(), secret.size());
|
||||||
|
if (!ok) throw std::runtime_error("Cannot save password in Windows Credential Manager.");
|
||||||
|
}
|
||||||
|
void forgetSyncPassword(const SyncConfig &c)
|
||||||
|
{
|
||||||
|
if (!CredDeleteW(credentialName(c).c_str(), CRED_TYPE_GENERIC, 0) && GetLastError() != ERROR_NOT_FOUND)
|
||||||
|
throw std::runtime_error("Cannot remove saved sync password.");
|
||||||
|
}
|
||||||
|
std::unique_ptr<SyncProvider> makeSyncProvider(const SyncConfig &c, SyncTransport transport)
|
||||||
|
{
|
||||||
|
return std::make_unique<OpenBao>(c, std::move(transport));
|
||||||
|
}
|
||||||
|
namespace { Json canonicalProfile(Json); }
|
||||||
|
Bytes sharedSyncSettings(const Settings &s)
|
||||||
|
{
|
||||||
|
auto j = parse(encodeSettings(s));
|
||||||
|
// Windows may enumerate the same installed layouts in a different order on
|
||||||
|
// another PC. Binding order is not user intent (unlike the layout cycle).
|
||||||
|
auto &bindings = j["layoutHotkeys"];
|
||||||
|
std::sort(bindings.begin(), bindings.end(), [](const Json &a, const Json &b) {
|
||||||
|
return a.at("layoutId").get<std::string>() < b.at("layoutId").get<std::string>();
|
||||||
|
});
|
||||||
|
for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
|
||||||
|
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock"}) j.erase(key);
|
||||||
|
return bytes(canonicalProfile(j));
|
||||||
|
}
|
||||||
|
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
|
||||||
|
{
|
||||||
|
auto j = parse(shared), original = parse(encodeSettings(local));
|
||||||
|
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
|
||||||
|
if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false;
|
||||||
|
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
|
||||||
|
if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object();
|
||||||
|
auto allowed = parse(sharedSyncSettings(local));
|
||||||
|
if (!j.is_object() || j.value("format", "") != "kswitcher-settings" || j.value("schemaVersion", 0) != 1)
|
||||||
|
throw std::runtime_error("Unsupported synchronized settings format.");
|
||||||
|
for (auto it = j.begin(); it != j.end(); ++it)
|
||||||
|
if (!allowed.contains(it.key())) throw std::runtime_error("Remote profile contains unsupported or local-only fields.");
|
||||||
|
if (j.size() != allowed.size()) throw std::runtime_error("Remote profile is incomplete.");
|
||||||
|
std::set<std::string> installed;
|
||||||
|
for (const auto &layout : layouts) installed.insert(utf8(layout.id));
|
||||||
|
std::set<std::string> missing;
|
||||||
|
for (const auto &id : j.at("layouts"))
|
||||||
|
if (!installed.contains(id.get<std::string>())) missing.insert(id.get<std::string>());
|
||||||
|
for (const auto &key : j.at("layoutHotkeys"))
|
||||||
|
if (!installed.contains(key.at("layoutId").get<std::string>())) missing.insert(key.at("layoutId").get<std::string>());
|
||||||
|
if (!keepLocalLayouts && !missing.empty())
|
||||||
|
{
|
||||||
|
std::wstring message = L"Missing keyboard layouts on this PC: ";
|
||||||
|
for (const auto &id : missing)
|
||||||
|
{
|
||||||
|
auto code = id.starts_with("win:") ? id.substr(4) : id;
|
||||||
|
wchar_t language[128]{};
|
||||||
|
if (code.size() == 8 && code.find_first_not_of("0123456789abcdefABCDEF") == code.npos)
|
||||||
|
GetLocaleInfoW(MAKELCID(LOWORD(std::stoul(code, nullptr, 16)), SORT_DEFAULT), LOCALE_SENGLISHLANGUAGENAME,
|
||||||
|
language, 128);
|
||||||
|
if (message.back() != L' ') message += L"; ";
|
||||||
|
message += (language[0] ? std::wstring(language) : L"Unknown layout") + L" [" + wide(id) + L"]";
|
||||||
|
}
|
||||||
|
message += L". Install these exact layouts, or enable 'Keep this PC's layouts' below and sync other settings.";
|
||||||
|
throw std::runtime_error(utf8(message));
|
||||||
|
}
|
||||||
|
if (keepLocalLayouts)
|
||||||
|
for (auto key : {"layouts", "layoutHotkeys"}) j[key] = original.at(key);
|
||||||
|
original.update(j);
|
||||||
|
return decodeSettings(bytes(original), layouts);
|
||||||
|
}
|
||||||
|
#include "sync_merge.inl"
|
||||||
|
SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &local, SyncAction action,
|
||||||
|
const std::function<void(const Bytes &)> &validate, const std::vector<SyncChoice> &choices, bool keepLocalLayouts)
|
||||||
|
{
|
||||||
|
if (local.size() > Limit) throw std::runtime_error("Settings exceed the sync size limit.");
|
||||||
|
if (validate) validate(local);
|
||||||
|
auto remote = provider.read();
|
||||||
|
if (!remote.revision)
|
||||||
|
{
|
||||||
|
if (action != SyncAction::Upload) throw std::runtime_error("No remote profile. Choose Upload local to create it.");
|
||||||
|
return {true, local, provider.write(local, 0), L"Uploaded new profile."};
|
||||||
|
}
|
||||||
|
if (validate && action != SyncAction::Upload) validate(remote.settings);
|
||||||
|
auto mergeLocal = local, mergeBase = base;
|
||||||
|
if (keepLocalLayouts)
|
||||||
|
{
|
||||||
|
// Never upload a locally filtered projection over another PC's layout setup.
|
||||||
|
const auto remoteJson = parse(remote.settings);
|
||||||
|
auto preserve = [&](const Bytes &profile)
|
||||||
|
{
|
||||||
|
auto j = parse(profile);
|
||||||
|
for (auto key : {"layouts", "layoutHotkeys"}) j[key] = remoteJson.at(key);
|
||||||
|
return bytes(j);
|
||||||
|
};
|
||||||
|
mergeLocal = preserve(local);
|
||||||
|
if (!base.empty()) mergeBase = preserve(base);
|
||||||
|
}
|
||||||
|
SyncMerge merged;
|
||||||
|
if (action == SyncAction::Automatic) merged = mergeSyncProfile(mergeBase, mergeLocal, remote.settings, choices);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
auto selected = bytes(normalizeProfile(parse(action == SyncAction::Download ? remote.settings : mergeLocal)));
|
||||||
|
merged = {selected, selected, selected, {}};
|
||||||
|
}
|
||||||
|
if (validate) { validate(merged.local); validate(merged.remote); }
|
||||||
|
auto revision = remote.revision;
|
||||||
|
if (parse(merged.remote) != parse(remote.settings) && action != SyncAction::Download) revision = provider.write(merged.remote, revision);
|
||||||
|
return {true, merged.local, revision,
|
||||||
|
merged.conflicts.empty() ? L"Synchronized. Server version " + std::to_wstring(revision) + L"." :
|
||||||
|
L"Other changes synchronized. Resolve " + std::to_wstring(merged.conflicts.size()) + L" conflicting setting(s).",
|
||||||
|
merged.base, merged.conflicts};
|
||||||
|
}
|
||||||
|
Bytes encodeSyncConflicts(const std::vector<SyncConflict> &conflicts)
|
||||||
|
{
|
||||||
|
Json j = Json::array();
|
||||||
|
for (const auto &c : conflicts) j.push_back({{"key", c.key}, {"label", utf8(c.label)}, {"local", parse(c.local)}, {"remote", parse(c.remote)}});
|
||||||
|
return bytes(j);
|
||||||
|
}
|
||||||
|
std::vector<SyncConflict> decodeSyncConflicts(const Bytes &data)
|
||||||
|
{
|
||||||
|
std::vector<SyncConflict> result;
|
||||||
|
for (const auto &c : parse(data)) result.push_back({c.at("key"), wide(c.at("label").get<std::string>()), bytes(c.at("local")), bytes(c.at("remote"))});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Bytes encodeSyncChoices(const std::vector<SyncChoice> &choices)
|
||||||
|
{
|
||||||
|
Json j = Json::array();
|
||||||
|
for (const auto &c : choices) j.push_back({{"conflict", parse(encodeSyncConflicts({c.conflict})).at(0)}, {"useLocal", c.useLocal}});
|
||||||
|
return bytes(j);
|
||||||
|
}
|
||||||
|
std::vector<SyncChoice> decodeSyncChoices(const Bytes &data)
|
||||||
|
{
|
||||||
|
auto j = parse(data);
|
||||||
|
if (!j.is_array() || j.size() > 10000) throw std::runtime_error("Invalid conflict choices.");
|
||||||
|
std::vector<SyncChoice> result;
|
||||||
|
for (const auto &c : j) result.push_back({decodeSyncConflicts(bytes(Json::array({c.at("conflict")}))).at(0), c.at("useLocal").get<bool>()});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
std::wstring syncValueText(const Bytes &data)
|
||||||
|
{
|
||||||
|
auto j = parse(data);
|
||||||
|
if (j.is_null()) return L"(not present)";
|
||||||
|
if (tombstone(j)) return L"(deleted)";
|
||||||
|
return wide(j.is_string() ? j.get<std::string>() : j.dump(2));
|
||||||
|
}
|
||||||
|
Bytes loadSyncBase(const std::filesystem::path &root, const SyncConfig &c)
|
||||||
|
{
|
||||||
|
auto path = root / L"sync-base.bin";
|
||||||
|
if (!std::filesystem::exists(path)) return {};
|
||||||
|
auto j = parse(unprotect(readFile(path, Limit + 65536)));
|
||||||
|
auto connection = j.at("connection");
|
||||||
|
if (!connection.contains("keepLocalLayouts")) connection["keepLocalLayouts"] = false;
|
||||||
|
return connection == configJson(c) ? bytes(j.at("settings")) : Bytes{};
|
||||||
|
}
|
||||||
|
void saveSyncBase(const std::filesystem::path &root, const SyncConfig &c, const Bytes &base)
|
||||||
|
{
|
||||||
|
atomicWrite(root / L"sync-base.bin", protect(bytes({{"connection", configJson(c)}, {"settings", parse(base)}})));
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "storage.h"
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
namespace ks
|
||||||
|
{
|
||||||
|
struct SyncConfig
|
||||||
|
{
|
||||||
|
bool enabled{};
|
||||||
|
bool keepLocalLayouts{};
|
||||||
|
std::wstring provider = L"openbao", server, mount = L"kswitcher",
|
||||||
|
path = L"personal/settings", authMount = L"userpass", username;
|
||||||
|
bool operator==(const SyncConfig &) const = default;
|
||||||
|
};
|
||||||
|
bool syncVisible(const std::filesystem::path &);
|
||||||
|
SyncConfig loadSyncConfig(const std::filesystem::path &);
|
||||||
|
void saveSyncConfig(const std::filesystem::path &, const SyncConfig &);
|
||||||
|
void validateSyncConfig(const SyncConfig &);
|
||||||
|
void saveSyncPassword(const SyncConfig &, const std::wstring &);
|
||||||
|
void forgetSyncPassword(const SyncConfig &);
|
||||||
|
struct SyncDocument { uint64_t revision{}; Bytes settings; };
|
||||||
|
class SyncProvider
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
virtual ~SyncProvider() = default;
|
||||||
|
virtual SyncDocument read() = 0;
|
||||||
|
virtual uint64_t write(const Bytes &, uint64_t expectedRevision) = 0;
|
||||||
|
};
|
||||||
|
struct SyncHttpResponse { unsigned status{}; Bytes body; };
|
||||||
|
using SyncTransport = std::function<SyncHttpResponse(const std::wstring &method, const std::wstring &path,
|
||||||
|
const std::wstring &token, const Bytes &body)>;
|
||||||
|
// An injectable transport permits protocol regression tests without a live vault.
|
||||||
|
std::unique_ptr<SyncProvider> makeSyncProvider(const SyncConfig &, SyncTransport transport = {});
|
||||||
|
inline constexpr ULONGLONG SyncIntervalMs = 10 * 60 * 1000;
|
||||||
|
struct SyncConflict
|
||||||
|
{
|
||||||
|
std::string key;
|
||||||
|
std::wstring label;
|
||||||
|
Bytes local, remote;
|
||||||
|
};
|
||||||
|
struct SyncChoice { SyncConflict conflict; bool useLocal{}; };
|
||||||
|
Bytes encodeSyncConflicts(const std::vector<SyncConflict> &);
|
||||||
|
std::vector<SyncConflict> decodeSyncConflicts(const Bytes &);
|
||||||
|
Bytes encodeSyncChoices(const std::vector<SyncChoice> &);
|
||||||
|
std::vector<SyncChoice> decodeSyncChoices(const Bytes &);
|
||||||
|
std::wstring syncValueText(const Bytes &);
|
||||||
|
struct SyncMerge
|
||||||
|
{
|
||||||
|
Bytes local, remote, base;
|
||||||
|
std::vector<SyncConflict> conflicts;
|
||||||
|
};
|
||||||
|
SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &remote,
|
||||||
|
const std::vector<SyncChoice> &choices = {});
|
||||||
|
Bytes mergeSyncSettings(const Bytes &base, const Bytes &local, const Bytes &remote);
|
||||||
|
Bytes sharedSyncSettings(const Settings &);
|
||||||
|
Settings restoreSyncSettings(const Bytes &, const Settings &, const std::vector<Layout> &, bool keepLocalLayouts = false);
|
||||||
|
enum class SyncAction { Automatic, Download, Upload };
|
||||||
|
struct SyncResult
|
||||||
|
{
|
||||||
|
bool success{};
|
||||||
|
Bytes settings;
|
||||||
|
uint64_t revision{};
|
||||||
|
std::wstring status;
|
||||||
|
Bytes base;
|
||||||
|
std::vector<SyncConflict> conflicts;
|
||||||
|
};
|
||||||
|
SyncResult synchronize(SyncProvider &, const Bytes &base, const Bytes &local, SyncAction,
|
||||||
|
const std::function<void(const Bytes &)> &validate = {}, const std::vector<SyncChoice> &choices = {},
|
||||||
|
bool keepLocalLayouts = false);
|
||||||
|
Bytes loadSyncBase(const std::filesystem::path &, const SyncConfig &);
|
||||||
|
void saveSyncBase(const std::filesystem::path &, const SyncConfig &, const Bytes &);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
// Three-way merge operates on individual values; unresolved values retain their
|
||||||
|
// respective local/remote versions and their old common ancestor.
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
Json normalizeProfile(Json j)
|
||||||
|
{
|
||||||
|
if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false;
|
||||||
|
if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false;
|
||||||
|
if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object();
|
||||||
|
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
|
||||||
|
for (auto &sn : j.at("snippets"))
|
||||||
|
if (!sn.contains("id")) sn["id"] = utf8(snippetId({wide(sn.at("trigger").get<std::string>()), L""}));
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
Json treeProfile(const Json &source)
|
||||||
|
{
|
||||||
|
auto j = normalizeProfile(source);
|
||||||
|
Json items = Json::object();
|
||||||
|
for (const auto &sn : j.at("snippets"))
|
||||||
|
{
|
||||||
|
auto id = sn.at("id").get<std::string>();
|
||||||
|
if (items.contains(id)) throw std::runtime_error("Duplicate snippet ID.");
|
||||||
|
items[id] = sn;
|
||||||
|
}
|
||||||
|
for (const auto &id : j.at("snippetTombstones"))
|
||||||
|
{
|
||||||
|
auto key = id.get<std::string>();
|
||||||
|
if (items.contains(key)) throw std::runtime_error("Duplicate or deleted snippet ID.");
|
||||||
|
items[key] = {{"$deleted", true}};
|
||||||
|
}
|
||||||
|
j["snippets"] = items;
|
||||||
|
j.erase("snippetTombstones");
|
||||||
|
items = Json::object();
|
||||||
|
for (const auto &binding : j.at("layoutHotkeys")) items[binding.at("layoutId").get<std::string>()] = binding.at("hotkey");
|
||||||
|
j["layoutHotkeys"] = items;
|
||||||
|
for (auto key : {"excludedApps", "autoCorrectNever", "autoCorrectExcludedApps"})
|
||||||
|
{
|
||||||
|
items = Json::object();
|
||||||
|
for (const auto &item : j.at(key)) items[item.get<std::string>()] = true;
|
||||||
|
j[key] = items;
|
||||||
|
}
|
||||||
|
for (auto &language : j.at("autoCorrectWords").items())
|
||||||
|
{
|
||||||
|
items = Json::object();
|
||||||
|
for (const auto &word : language.value()) items[word.get<std::string>()] = true;
|
||||||
|
language.value() = items;
|
||||||
|
}
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
Json flatProfile(Json j)
|
||||||
|
{
|
||||||
|
auto &replacements = j["autoCorrectReplacements"];
|
||||||
|
for (auto it = replacements.begin(); it != replacements.end();)
|
||||||
|
if (it.value().is_null()) it = replacements.erase(it);
|
||||||
|
else ++it;
|
||||||
|
Json snippets = Json::array(), deleted = Json::array();
|
||||||
|
for (const auto &[id, item] : j.at("snippets").items())
|
||||||
|
{
|
||||||
|
if (item.is_null()) continue;
|
||||||
|
if (item.value("$deleted", false)) deleted.push_back(id);
|
||||||
|
else snippets.push_back(item);
|
||||||
|
}
|
||||||
|
j["snippets"] = snippets; j["snippetTombstones"] = deleted;
|
||||||
|
Json bindings = Json::array();
|
||||||
|
for (const auto &[id, hotkey] : j.at("layoutHotkeys").items())
|
||||||
|
if (!hotkey.is_null()) bindings.push_back({{"layoutId", id}, {"hotkey", hotkey}});
|
||||||
|
j["layoutHotkeys"] = bindings;
|
||||||
|
for (auto key : {"excludedApps", "autoCorrectNever", "autoCorrectExcludedApps"})
|
||||||
|
{
|
||||||
|
Json list = Json::array();
|
||||||
|
for (const auto &[item, present] : j.at(key).items()) if (present == true) list.push_back(item);
|
||||||
|
j[key] = list;
|
||||||
|
}
|
||||||
|
for (auto &language : j.at("autoCorrectWords").items())
|
||||||
|
{
|
||||||
|
Json list = Json::array();
|
||||||
|
if (language.value().is_object())
|
||||||
|
for (const auto &[word, present] : language.value().items()) if (present == true) list.push_back(word);
|
||||||
|
language.value() = list;
|
||||||
|
}
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
std::string pointerKey(const std::string &s)
|
||||||
|
{
|
||||||
|
std::string out;
|
||||||
|
for (char c : s) out += c == '~' ? "~0" : c == '/' ? "~1" : std::string(1, c);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
Json canonicalProfile(Json j) { return flatProfile(treeProfile(j)); }
|
||||||
|
std::wstring fieldLabel(const std::string &key)
|
||||||
|
{
|
||||||
|
static const std::map<std::string, std::wstring> labels{
|
||||||
|
{"toggleHotkey", L"Next layout shortcut"}, {"caseHotkey", L"Change case shortcut"},
|
||||||
|
{"translitHotkey", L"Transliteration shortcut"}, {"undoHotkey", L"Undo shortcut"},
|
||||||
|
{"clipboardHotkey", L"Clipboard shortcut"}, {"screenshotHotkey", L"Screenshot shortcut"},
|
||||||
|
{"layouts", L"Layout cycle order"}, {"layoutHotkeys", L"Layout shortcuts"},
|
||||||
|
{"convertLastWord", L"Convert recent word"}, {"switchSystemLayout", L"Switch system layout"},
|
||||||
|
{"autoCorrectMinLength", L"Minimum word length"}, {"autoCorrectMaxRank", L"Maximum word rank"},
|
||||||
|
{"autoCorrectLayout", L"Automatic layout correction"}, {"autoCorrectLanguages", L"Dictionary languages"},
|
||||||
|
{"autoCorrectReplace", L"Custom automatic replacements"}, {"autoCorrectTypos", L"Dictionary typo correction"},
|
||||||
|
{"autoCorrectReplacements", L"Replacement pairs"},
|
||||||
|
{"autoCorrectPrefixChars", L"Punctuation before words"}, {"autoCorrectSuffixChars", L"Punctuation after words"},
|
||||||
|
{"screenshotAutoOpenEditor", L"Open screenshot editor"}, {"tesseractLanguages", L"OCR languages"},
|
||||||
|
{"trigger", L"Trigger"}, {"text", L"Text"}};
|
||||||
|
auto it = labels.find(key); return it == labels.end() ? wide(key) : it->second;
|
||||||
|
}
|
||||||
|
struct MergeNode { Json local, remote, base; };
|
||||||
|
bool tombstone(const Json &j) { return j.is_object() && j.value("$deleted", false); }
|
||||||
|
MergeNode mergeNode(const Json &b, const Json &l, const Json &r, const std::string &path,
|
||||||
|
const std::wstring &label, const std::vector<SyncChoice> &choices, std::vector<SyncConflict> &conflicts,
|
||||||
|
const std::set<std::string> &forced)
|
||||||
|
{
|
||||||
|
if (l == r) return {l, l, l};
|
||||||
|
for (const auto &choice : choices)
|
||||||
|
if (choice.conflict.key == path && parse(choice.conflict.local) == l && parse(choice.conflict.remote) == r)
|
||||||
|
{
|
||||||
|
auto selected = choice.useLocal ? l : r;
|
||||||
|
if (selected.is_null() && path.starts_with("/snippets/") && path.find('/', 10) == std::string::npos)
|
||||||
|
selected = {{"$deleted", true}};
|
||||||
|
return {selected, selected, selected};
|
||||||
|
}
|
||||||
|
if (forced.contains(path))
|
||||||
|
{
|
||||||
|
conflicts.push_back({path, label + L" (duplicate trigger or shortcut)", bytes(l), bytes(r)});
|
||||||
|
return {l, r, b};
|
||||||
|
}
|
||||||
|
bool forcedChild = std::any_of(forced.begin(), forced.end(), [&](const std::string &key) { return key.starts_with(path + "/"); });
|
||||||
|
if (!forcedChild && l == b) return {r, r, r};
|
||||||
|
if (!forcedChild && r == b) return {l, l, l};
|
||||||
|
if (l.is_object() && r.is_object() && (b.is_object() || b.is_null()) && !tombstone(l) && !tombstone(r) && !tombstone(b))
|
||||||
|
{
|
||||||
|
MergeNode out{Json::object(), Json::object(), Json::object()};
|
||||||
|
std::set<std::string> keys;
|
||||||
|
for (const auto *j : {&b, &l, &r}) if (j->is_object()) for (auto it = j->begin(); it != j->end(); ++it) keys.insert(it.key());
|
||||||
|
for (const auto &key : keys)
|
||||||
|
{
|
||||||
|
auto get = [&](const Json &j) { return j.is_object() && j.contains(key) ? j.at(key) : Json(); };
|
||||||
|
auto childLabel = label.empty() ? fieldLabel(key) : label + L" / " + fieldLabel(key);
|
||||||
|
if (path == "/snippets")
|
||||||
|
{
|
||||||
|
auto item = get(l); if (item.is_null() || tombstone(item)) item = get(r);
|
||||||
|
childLabel = L"Snippet: " + wide(item.is_object() ? item.value("trigger", key) : key);
|
||||||
|
}
|
||||||
|
auto child = mergeNode(get(b), get(l), get(r), path + "/" + pointerKey(key), childLabel, choices, conflicts, forced);
|
||||||
|
// Keep nulls in the ancestor so deletion conflicts survive restarts.
|
||||||
|
out.local[key] = child.local; out.remote[key] = child.remote; out.base[key] = child.base;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
conflicts.push_back({path, label, bytes(l), bytes(r)});
|
||||||
|
return {l, r, b};
|
||||||
|
}
|
||||||
|
void collisionPaths(const Json &j, std::set<std::string> &paths)
|
||||||
|
{
|
||||||
|
std::map<std::wstring, std::string> triggers;
|
||||||
|
for (const auto &[id, sn] : j.at("snippets").items())
|
||||||
|
{
|
||||||
|
if (sn.is_null() || tombstone(sn)) continue;
|
||||||
|
auto path = "/snippets/" + pointerKey(id);
|
||||||
|
auto key = lower(wide(sn.at("trigger").get<std::string>()));
|
||||||
|
auto [it, inserted] = triggers.emplace(key, path);
|
||||||
|
if (!inserted) { paths.insert(path); paths.insert(it->second); }
|
||||||
|
}
|
||||||
|
std::map<std::pair<UINT, UINT>, std::string> shortcuts;
|
||||||
|
auto add = [&](const Json &text, const std::string &path) {
|
||||||
|
if (!text.is_string()) return;
|
||||||
|
auto hotkey = Hotkey::parse(wide(text.get<std::string>()));
|
||||||
|
if (!hotkey || !hotkey->key) return;
|
||||||
|
auto [it, inserted] = shortcuts.emplace(std::make_pair(hotkey->modifiers, hotkey->key), path);
|
||||||
|
if (!inserted) { paths.insert(path); paths.insert(it->second); }
|
||||||
|
};
|
||||||
|
for (const auto *key : {"toggleHotkey", "caseHotkey", "translitHotkey", "undoHotkey", "clipboardHotkey", "screenshotHotkey"}) add(j.at(key), std::string("/") + key);
|
||||||
|
for (const auto &[id, key] : j.at("layoutHotkeys").items()) add(key, "/layoutHotkeys/" + pointerKey(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &remote, const std::vector<SyncChoice> &choices)
|
||||||
|
{
|
||||||
|
auto l = treeProfile(parse(local)), r = treeProfile(parse(remote));
|
||||||
|
// With no common history, union independent entries; differing scalar values
|
||||||
|
// remain on their respective devices until the user resolves the conflict.
|
||||||
|
auto b = base.empty() ? Json::object() : treeProfile(parse(base));
|
||||||
|
SyncMerge result;
|
||||||
|
auto merged = mergeNode(b, l, r, "", L"", choices, result.conflicts, {});
|
||||||
|
std::set<std::string> collisions;
|
||||||
|
collisionPaths(merged.local, collisions); collisionPaths(merged.remote, collisions);
|
||||||
|
if (!collisions.empty())
|
||||||
|
{
|
||||||
|
result.conflicts.clear();
|
||||||
|
merged = mergeNode(b, l, r, "", L"", choices, result.conflicts, collisions);
|
||||||
|
}
|
||||||
|
result.local = bytes(flatProfile(merged.local));
|
||||||
|
result.remote = bytes(flatProfile(merged.remote));
|
||||||
|
result.base = bytes(flatProfile(merged.base));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Bytes mergeSyncSettings(const Bytes &base, const Bytes &local, const Bytes &remote)
|
||||||
|
{
|
||||||
|
auto result = mergeSyncProfile(base, local, remote);
|
||||||
|
if (!result.conflicts.empty()) throw std::runtime_error("Conflicting setting: " + result.conflicts.front().key);
|
||||||
|
return result.local;
|
||||||
|
}
|
||||||
+416
-346
File diff suppressed because it is too large
Load Diff
+357
@@ -0,0 +1,357 @@
|
|||||||
|
// Included inside namespace ks by ui.cpp; shares the native control/layout helpers.
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
|
||||||
|
LogClear = 606, LogProgram = 607, LogCommand = 608, LogResult = 609, LogProblems = 610,
|
||||||
|
LogDetails = 611, LogReset = 612;
|
||||||
|
struct LogViewState
|
||||||
|
{
|
||||||
|
std::vector<OperationLogRow> rows;
|
||||||
|
std::vector<size_t> visible;
|
||||||
|
int sortColumn = 0;
|
||||||
|
bool descending = true, rendering = false;
|
||||||
|
};
|
||||||
|
LogViewState *logState(HWND w)
|
||||||
|
{
|
||||||
|
return reinterpret_cast<LogViewState *>(GetPropW(w, L"LogViewState"));
|
||||||
|
}
|
||||||
|
const std::wstring &logCell(const OperationLogRow &row, int column)
|
||||||
|
{
|
||||||
|
switch (column)
|
||||||
|
{
|
||||||
|
case 0:
|
||||||
|
return row.time;
|
||||||
|
case 1:
|
||||||
|
return row.process;
|
||||||
|
case 2:
|
||||||
|
return row.command;
|
||||||
|
case 3:
|
||||||
|
return row.source;
|
||||||
|
case 4:
|
||||||
|
return row.target;
|
||||||
|
default:
|
||||||
|
return row.outcome;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void showLogDetails(HWND w)
|
||||||
|
{
|
||||||
|
auto *state = logState(w);
|
||||||
|
if (!state || state->rendering)
|
||||||
|
return;
|
||||||
|
int selected = ListView_GetNextItem(GetDlgItem(w, LogRows), -1, LVNI_SELECTED);
|
||||||
|
value(w, LogDetails,
|
||||||
|
selected >= 0 && (size_t)selected < state->visible.size()
|
||||||
|
? state->rows[state->visible[selected]].details
|
||||||
|
: L"Select an operation to inspect its text and stages.");
|
||||||
|
}
|
||||||
|
void renderLogRows(HWND w, App &app)
|
||||||
|
{
|
||||||
|
auto *state = logState(w);
|
||||||
|
if (!state)
|
||||||
|
return;
|
||||||
|
state->rendering = true;
|
||||||
|
state->visible.clear();
|
||||||
|
auto matches = [&](int id, const std::wstring &field)
|
||||||
|
{ return SendDlgItemMessageW(w, id, CB_GETCURSEL, 0, 0) <= 0 || value(w, id) == field; };
|
||||||
|
for (size_t i = 0; i < state->rows.size(); ++i)
|
||||||
|
{
|
||||||
|
const auto &row = state->rows[i];
|
||||||
|
if (matches(LogProgram, row.process) && matches(LogCommand, row.command) &&
|
||||||
|
matches(LogResult, row.outcome) && (!checked(w, LogProblems) || row.problem))
|
||||||
|
state->visible.push_back(i);
|
||||||
|
}
|
||||||
|
std::stable_sort(state->visible.begin(), state->visible.end(),
|
||||||
|
[&](size_t a, size_t b)
|
||||||
|
{
|
||||||
|
const auto &left = logCell(state->rows[a], state->sortColumn);
|
||||||
|
const auto &right = logCell(state->rows[b], state->sortColumn);
|
||||||
|
if (left == right)
|
||||||
|
return state->descending ? a > b : a < b;
|
||||||
|
int comparison = CompareStringOrdinal(left.c_str(), -1, right.c_str(), -1, TRUE);
|
||||||
|
return state->descending ? comparison == CSTR_GREATER_THAN
|
||||||
|
: comparison == CSTR_LESS_THAN;
|
||||||
|
});
|
||||||
|
HWND list = GetDlgItem(w, LogRows);
|
||||||
|
SendMessageW(list, WM_SETREDRAW, FALSE, 0);
|
||||||
|
ListView_DeleteAllItems(list);
|
||||||
|
for (size_t i = 0; i < state->visible.size(); ++i)
|
||||||
|
{
|
||||||
|
const auto &row = state->rows[state->visible[i]];
|
||||||
|
LVITEMW item{};
|
||||||
|
item.mask = LVIF_TEXT;
|
||||||
|
item.iItem = (int)i;
|
||||||
|
item.pszText = const_cast<wchar_t *>(row.time.c_str());
|
||||||
|
ListView_InsertItem(list, &item);
|
||||||
|
for (int column = 1; column < 6; ++column)
|
||||||
|
ListView_SetItemText(list, (int)i, column, const_cast<wchar_t *>(logCell(row, column).c_str()));
|
||||||
|
}
|
||||||
|
HWND header = ListView_GetHeader(list);
|
||||||
|
for (int i = 0; i < 6; ++i)
|
||||||
|
{
|
||||||
|
HDITEMW item{};
|
||||||
|
item.mask = HDI_FORMAT;
|
||||||
|
Header_GetItem(header, i, &item);
|
||||||
|
item.fmt &= ~(HDF_SORTUP | HDF_SORTDOWN);
|
||||||
|
if (i == state->sortColumn)
|
||||||
|
item.fmt |= state->descending ? HDF_SORTDOWN : HDF_SORTUP;
|
||||||
|
Header_SetItem(header, i, &item);
|
||||||
|
}
|
||||||
|
SendMessageW(list, WM_SETREDRAW, TRUE, 0);
|
||||||
|
InvalidateRect(list, nullptr, TRUE);
|
||||||
|
state->rendering = false;
|
||||||
|
value(w, LogStatus,
|
||||||
|
std::to_wstring(state->visible.size()) + L" of " + std::to_wstring(state->rows.size()) +
|
||||||
|
L" operations / events. " +
|
||||||
|
(app.settings.writeLog ? L"Diagnostic logging enabled."
|
||||||
|
: L"Logging disabled; saved entries only."));
|
||||||
|
if (!state->visible.empty())
|
||||||
|
ListView_SetItemState(list, 0, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED);
|
||||||
|
showLogDetails(w);
|
||||||
|
if (state->visible.empty())
|
||||||
|
value(w, LogDetails,
|
||||||
|
state->rows.empty()
|
||||||
|
? L"No recorded operations. Use a conversion shortcut with logging enabled, then refresh."
|
||||||
|
: L"No operations match these filters. Reset filters to show all entries.");
|
||||||
|
}
|
||||||
|
void fillLogFilter(HWND w, int id, const std::set<std::wstring> &choices)
|
||||||
|
{
|
||||||
|
auto previous = value(w, id);
|
||||||
|
HWND combo = GetDlgItem(w, id);
|
||||||
|
SendMessageW(combo, CB_RESETCONTENT, 0, 0);
|
||||||
|
SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)L"All");
|
||||||
|
int selected = 0, index = 1;
|
||||||
|
for (const auto &choice : choices)
|
||||||
|
{
|
||||||
|
if (choice.empty())
|
||||||
|
continue;
|
||||||
|
SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)choice.c_str());
|
||||||
|
if (choice == previous)
|
||||||
|
selected = index;
|
||||||
|
++index;
|
||||||
|
}
|
||||||
|
SendMessageW(combo, CB_SETCURSEL, selected, 0);
|
||||||
|
}
|
||||||
|
void refreshLogView(HWND w, App &app)
|
||||||
|
{
|
||||||
|
auto *state = logState(w);
|
||||||
|
if (!state)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
auto contents = readDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
||||||
|
state->rows = parseOperationLog(contents);
|
||||||
|
if (contents.starts_with(L"No log yet.") || contents.starts_with(L"No previous log") ||
|
||||||
|
contents == L"The log is empty.")
|
||||||
|
state->rows.clear();
|
||||||
|
std::set<std::wstring> programs, commands, results;
|
||||||
|
for (const auto &row : state->rows)
|
||||||
|
{
|
||||||
|
programs.insert(row.process);
|
||||||
|
commands.insert(row.command);
|
||||||
|
results.insert(row.outcome);
|
||||||
|
}
|
||||||
|
fillLogFilter(w, LogProgram, programs);
|
||||||
|
fillLogFilter(w, LogCommand, commands);
|
||||||
|
fillLogFilter(w, LogResult, results);
|
||||||
|
renderLogRows(w, app);
|
||||||
|
}
|
||||||
|
catch (const std::exception &e)
|
||||||
|
{
|
||||||
|
value(w, LogStatus, L"Could not read the log: " + wide(e.what()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void copyLogRows(HWND w, App &app)
|
||||||
|
{
|
||||||
|
auto *state = logState(w);
|
||||||
|
if (!state)
|
||||||
|
return;
|
||||||
|
std::wstring copied;
|
||||||
|
HWND list = GetDlgItem(w, LogRows);
|
||||||
|
for (int row = ListView_GetNextItem(list, -1, LVNI_SELECTED); row >= 0;
|
||||||
|
row = ListView_GetNextItem(list, row, LVNI_SELECTED))
|
||||||
|
{
|
||||||
|
if ((size_t)row >= state->visible.size())
|
||||||
|
continue;
|
||||||
|
const auto &entry = state->rows[state->visible[row]];
|
||||||
|
copied += entry.time + L"\t" + entry.process + L"\t" + entry.command + L"\t" + entry.source +
|
||||||
|
L" -> " + entry.target + L"\t" + entry.outcome + L"\r\n" + entry.details + L"\r\n\r\n";
|
||||||
|
}
|
||||||
|
if (!copied.empty())
|
||||||
|
{
|
||||||
|
auto clip = textClip(copied);
|
||||||
|
clip.formats.push_back({0, L"ExcludeClipboardContentFromMonitorProcessing", {1, 0, 0, 0}});
|
||||||
|
app.ownSequence = writeClipboard(app.window, clip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
|
{
|
||||||
|
auto *app = reinterpret_cast<App *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||||
|
if (msg == WM_NCCREATE)
|
||||||
|
{
|
||||||
|
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)((CREATESTRUCTW *)lp)->lpCreateParams);
|
||||||
|
SetPropW(w, L"LogViewState", new LogViewState);
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
if (!app)
|
||||||
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
switch (msg)
|
||||||
|
{
|
||||||
|
case WM_COMMAND:
|
||||||
|
if ((LOWORD(wp) == LogRefresh && HIWORD(wp) == BN_CLICKED) ||
|
||||||
|
(LOWORD(wp) == LogChoice && HIWORD(wp) == CBN_SELCHANGE))
|
||||||
|
{
|
||||||
|
refreshLogView(w, *app);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if ((LOWORD(wp) >= LogProgram && LOWORD(wp) <= LogResult && HIWORD(wp) == CBN_SELCHANGE) ||
|
||||||
|
(LOWORD(wp) == LogProblems && HIWORD(wp) == BN_CLICKED))
|
||||||
|
{
|
||||||
|
renderLogRows(w, *app);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (LOWORD(wp) == LogReset && HIWORD(wp) == BN_CLICKED)
|
||||||
|
{
|
||||||
|
for (int id : {LogProgram, LogCommand, LogResult})
|
||||||
|
SendDlgItemMessageW(w, id, CB_SETCURSEL, 0, 0);
|
||||||
|
CheckDlgButton(w, LogProblems, BST_UNCHECKED);
|
||||||
|
renderLogRows(w, *app);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (LOWORD(wp) == LogCopy && HIWORD(wp) == BN_CLICKED)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
copyLogRows(w, *app);
|
||||||
|
}
|
||||||
|
catch (const std::exception &e)
|
||||||
|
{
|
||||||
|
error(w, e);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (LOWORD(wp) == LogClear && HIWORD(wp) == BN_CLICKED)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
app->clearLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
|
||||||
|
refreshLogView(w, *app);
|
||||||
|
}
|
||||||
|
catch (const std::exception &e)
|
||||||
|
{
|
||||||
|
error(w, e);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (LOWORD(wp) != IDCANCEL || HIWORD(wp) != BN_CLICKED)
|
||||||
|
break;
|
||||||
|
[[fallthrough]];
|
||||||
|
case WM_CLOSE:
|
||||||
|
DestroyWindow(w);
|
||||||
|
return 0;
|
||||||
|
case WM_NOTIFY:
|
||||||
|
if (((NMHDR *)lp)->idFrom != LogRows)
|
||||||
|
break;
|
||||||
|
if (((NMHDR *)lp)->code == LVN_ITEMCHANGED)
|
||||||
|
showLogDetails(w);
|
||||||
|
if (((NMHDR *)lp)->code == LVN_COLUMNCLICK)
|
||||||
|
{
|
||||||
|
auto *state = logState(w);
|
||||||
|
int column = ((NMLISTVIEW *)lp)->iSubItem;
|
||||||
|
state->descending = state->sortColumn == column ? !state->descending : false;
|
||||||
|
state->sortColumn = column;
|
||||||
|
renderLogRows(w, *app);
|
||||||
|
}
|
||||||
|
if (((NMHDR *)lp)->code == LVN_KEYDOWN)
|
||||||
|
{
|
||||||
|
auto key = ((NMLVKEYDOWN *)lp)->wVKey;
|
||||||
|
if (GetKeyState(VK_CONTROL) & 0x8000)
|
||||||
|
{
|
||||||
|
if (key == 'A')
|
||||||
|
ListView_SetItemState(GetDlgItem(w, LogRows), -1, LVIS_SELECTED, LVIS_SELECTED);
|
||||||
|
if (key == 'C')
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
copyLogRows(w, *app);
|
||||||
|
}
|
||||||
|
catch (const std::exception &e)
|
||||||
|
{
|
||||||
|
error(w, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
case WM_DPICHANGED:
|
||||||
|
resizeForDpi(w, wp, lp);
|
||||||
|
return 0;
|
||||||
|
case WM_NCDESTROY:
|
||||||
|
delete logState(w);
|
||||||
|
RemovePropW(w, L"LogViewState");
|
||||||
|
app->logWindow = nullptr;
|
||||||
|
disposeFont(w);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
void showLogs(App &app)
|
||||||
|
{
|
||||||
|
if (app.logWindow)
|
||||||
|
{
|
||||||
|
showRequestedWindow(app.logWindow);
|
||||||
|
activateWindow(app.logWindow);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto w = makeWindow(app, L"KSwitcher.Logs", L"KSwitcher diagnostic operations", logProc, 1100, 760);
|
||||||
|
app.logWindow = w;
|
||||||
|
auto choice = control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST, LogChoice, 16, 16, 280, 140);
|
||||||
|
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Current log — kswitcher.log");
|
||||||
|
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Previous log — kswitcher.log.1");
|
||||||
|
SendMessageW(choice, CB_SETCURSEL, 0, 0);
|
||||||
|
button(w, L"&Refresh", LogRefresh, 310, 14, 100);
|
||||||
|
button(w, L"&Copy selected", LogCopy, 424, 14, 140);
|
||||||
|
button(w, L"Clear &log", LogClear, 578, 14, 100);
|
||||||
|
control(w, L"STATIC", L"", 0, LogStatus, 16, 49, 1068, 22);
|
||||||
|
label(w, L"&Program", 16, 78, 170);
|
||||||
|
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogProgram, 16, 101, 174, 300);
|
||||||
|
label(w, L"Co&mmand", 206, 78, 200);
|
||||||
|
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogCommand, 206, 101, 204, 300);
|
||||||
|
label(w, L"Res&ult", 426, 78, 300);
|
||||||
|
control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST | WS_VSCROLL, LogResult, 426, 101, 310, 300);
|
||||||
|
checkbox(w, L"&Only problems", LogProblems, 752, 102, 166);
|
||||||
|
button(w, L"Reset &filters", LogReset, 938, 98, 146);
|
||||||
|
INITCOMMONCONTROLSEX common{sizeof(common), ICC_LISTVIEW_CLASSES};
|
||||||
|
InitCommonControlsEx(&common);
|
||||||
|
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
|
||||||
|
LogRows, 16, 142, 1068, 260);
|
||||||
|
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER |
|
||||||
|
LVS_EX_INFOTIP);
|
||||||
|
const wchar_t *titles[] = {L"Time", L"Program", L"Command",
|
||||||
|
L"Source layout", L"Target layout", L"Result"};
|
||||||
|
const int widths[] = {165, 145, 150, 130, 130, 320};
|
||||||
|
for (int i = 0; i < 6; ++i)
|
||||||
|
{
|
||||||
|
LVCOLUMNW column{};
|
||||||
|
column.mask = LVCF_TEXT | LVCF_WIDTH;
|
||||||
|
column.pszText = const_cast<wchar_t *>(titles[i]);
|
||||||
|
column.cx = scale(w, widths[i]);
|
||||||
|
ListView_InsertColumn(list, i, &column);
|
||||||
|
}
|
||||||
|
auto details = edit(w, LogDetails, 16, 416, 1068, 276,
|
||||||
|
ES_MULTILINE | ES_AUTOVSCROLL | ES_READONLY | WS_VSCROLL | WS_BORDER);
|
||||||
|
auto hint = control(
|
||||||
|
w, L"STATIC",
|
||||||
|
L"Click a column to sort. Select an operation for details. Ctrl+C copies selected operations.", 0, 0,
|
||||||
|
16, 712, 860, 22);
|
||||||
|
button(w, L"Clos&e", IDCANCEL, 974, 708, 110);
|
||||||
|
auto *layout = windowLayoutState(w);
|
||||||
|
layout->stretch = list;
|
||||||
|
layout->details = details;
|
||||||
|
layout->bottomLeft = hint;
|
||||||
|
layout->bottomRight = GetDlgItem(w, IDCANCEL);
|
||||||
|
arrangeWindow(w);
|
||||||
|
refreshLogView(w, app);
|
||||||
|
showRequestedWindow(w);
|
||||||
|
activateWindow(w);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "platform.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <commctrl.h>
|
||||||
|
#include <windowsx.h>
|
||||||
|
|
||||||
|
namespace ks {
|
||||||
|
inline constexpr UINT ScrollActivity=WM_APP+42;
|
||||||
|
inline constexpr UINT_PTR ScrollFrameTimer=0x4b5301, ScrollIdleTimer=0x4b5302;
|
||||||
|
struct WheelBatch {
|
||||||
|
long long vertical{}, horizontal{};
|
||||||
|
WORD keys{};
|
||||||
|
LPARAM point{};
|
||||||
|
bool frame{}, active{}, listBox{};
|
||||||
|
int remainder{};
|
||||||
|
};
|
||||||
|
inline LRESULT CALLBACK wheelBatchProc(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR id,DWORD_PTR data)
|
||||||
|
{
|
||||||
|
auto *batch=reinterpret_cast<WheelBatch *>(data);
|
||||||
|
if(msg==WM_MOUSEWHEEL || msg==WM_MOUSEHWHEEL) {
|
||||||
|
(msg==WM_MOUSEWHEEL?batch->vertical:batch->horizontal)+=GET_WHEEL_DELTA_WPARAM(wp);
|
||||||
|
batch->keys=GET_KEYSTATE_WPARAM(wp); batch->point=lp;
|
||||||
|
if(!batch->active) {
|
||||||
|
batch->active=true;
|
||||||
|
SendMessageW(GetParent(w),ScrollActivity,(WPARAM)w,TRUE);
|
||||||
|
}
|
||||||
|
// Do not restart the frame timer: an uninterrupted wheel stream must
|
||||||
|
// keep moving, rather than wait for a debounce interval to expire.
|
||||||
|
if(!batch->frame) batch->frame=SetTimer(w,ScrollFrameTimer,16,nullptr)!=0;
|
||||||
|
SetTimer(w,ScrollIdleTimer,100,nullptr);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if(msg==WM_TIMER && wp==ScrollFrameTimer) {
|
||||||
|
KillTimer(w,ScrollFrameTimer); batch->frame=false;
|
||||||
|
if(batch->listBox && batch->vertical) {
|
||||||
|
UINT lines=3; SystemParametersInfoW(SPI_GETWHEELSCROLLLINES,0,&lines,0);
|
||||||
|
if(lines==WHEEL_PAGESCROLL) {
|
||||||
|
RECT client{}; GetClientRect(w,&client);
|
||||||
|
auto height=SendMessageW(w,LB_GETITEMHEIGHT,0,0);
|
||||||
|
lines=std::max<LRESULT>(1,client.bottom/std::max<LRESULT>(1,height));
|
||||||
|
}
|
||||||
|
auto delta=batch->vertical+batch->remainder;
|
||||||
|
batch->remainder=(int)(delta%WHEEL_DELTA);
|
||||||
|
auto top=SendMessageW(w,LB_GETTOPINDEX,0,0);
|
||||||
|
auto count=SendMessageW(w,LB_GETCOUNT,0,0);
|
||||||
|
auto next=std::clamp<long long>(top-(delta/WHEEL_DELTA)*lines,0,std::max<LRESULT>(0,count-1));
|
||||||
|
if(next!=top) SendMessageW(w,LB_SETTOPINDEX,(WPARAM)next,0);
|
||||||
|
batch->vertical=0;
|
||||||
|
} else if(batch->vertical) {
|
||||||
|
// Native edit/list-view/window handlers retain their own scrolling
|
||||||
|
// semantics, including system settings and partial wheel deltas.
|
||||||
|
auto delta=(short)std::clamp<long long>(batch->vertical,-32760,32760);
|
||||||
|
batch->vertical-=delta;
|
||||||
|
DefSubclassProc(w,WM_MOUSEWHEEL,MAKEWPARAM(batch->keys,(WORD)delta),batch->point);
|
||||||
|
}
|
||||||
|
if(batch->horizontal) {
|
||||||
|
auto delta=(short)std::clamp<long long>(batch->horizontal,-32760,32760);
|
||||||
|
batch->horizontal-=delta;
|
||||||
|
DefSubclassProc(w,WM_MOUSEHWHEEL,MAKEWPARAM(batch->keys,(WORD)delta),batch->point);
|
||||||
|
}
|
||||||
|
if(batch->vertical || batch->horizontal)
|
||||||
|
batch->frame=SetTimer(w,ScrollFrameTimer,16,nullptr)!=0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if(msg==WM_TIMER && wp==ScrollIdleTimer) {
|
||||||
|
if(batch->frame) return 0;
|
||||||
|
KillTimer(w,ScrollIdleTimer); batch->active=false;
|
||||||
|
SendMessageW(GetParent(w),ScrollActivity,(WPARAM)w,FALSE);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if(msg==WM_NCDESTROY) {
|
||||||
|
KillTimer(w,ScrollFrameTimer); KillTimer(w,ScrollIdleTimer);
|
||||||
|
RemoveWindowSubclass(w,wheelBatchProc,id); delete batch;
|
||||||
|
}
|
||||||
|
return DefSubclassProc(w,msg,wp,lp);
|
||||||
|
}
|
||||||
|
inline void enableWheelBatching(HWND window,bool listBox=false)
|
||||||
|
{
|
||||||
|
if(!window) return;
|
||||||
|
auto *batch=new WheelBatch; batch->listBox=listBox;
|
||||||
|
if(!SetWindowSubclass(window,wheelBatchProc,ScrollFrameTimer,(DWORD_PTR)batch)) delete batch;
|
||||||
|
}
|
||||||
|
}
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
#include "ui_sync_conflicts.inl"
|
||||||
|
struct SyncView { HWND parent; App *app; };
|
||||||
|
void updateSyncControls(HWND w, App &app)
|
||||||
|
{
|
||||||
|
bool enabled = checked(w, 601);
|
||||||
|
bool savedEnabled = loadSyncConfig(app.data).enabled;
|
||||||
|
for (int id : {602, 603, 604, 605, 606, 607, 610, 611, 613})
|
||||||
|
EnableWindow(GetDlgItem(w, id), enabled);
|
||||||
|
EnableWindow(GetDlgItem(w, 612), enabled && savedEnabled);
|
||||||
|
EnableWindow(GetDlgItem(w, IDOK), enabled || savedEnabled);
|
||||||
|
value(w, IDOK, enabled ? L"&Save / Sign in / Sync" : L"&Save");
|
||||||
|
std::wstring status;
|
||||||
|
if (!enabled)
|
||||||
|
status = savedEnabled ? L"Click Save to disable synchronization." :
|
||||||
|
L"Synchronization is disabled. Enable it above to configure and sync.";
|
||||||
|
else if (!savedEnabled)
|
||||||
|
status = L"Configure the connection, then click Save / Sign in / Sync to enable synchronization.";
|
||||||
|
else
|
||||||
|
status = app.syncCommand();
|
||||||
|
if (value(w, 608) != status) value(w, 608, status);
|
||||||
|
}
|
||||||
|
SyncConfig syncFields(HWND w)
|
||||||
|
{
|
||||||
|
SyncConfig c;
|
||||||
|
c.enabled = checked(w, 601);
|
||||||
|
c.keepLocalLayouts = checked(w, 613);
|
||||||
|
c.server = value(w, 602);
|
||||||
|
while (!c.server.empty() && c.server.back() == L'/') c.server.pop_back();
|
||||||
|
c.username = lower(value(w, 603)); c.mount = value(w, 605);
|
||||||
|
c.path = value(w, 606); c.authMount = value(w, 607);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
LRESULT CALLBACK syncProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
|
{
|
||||||
|
if (msg == WM_DPICHANGED) { resizeForDpi(w, wp, lp); return 0; }
|
||||||
|
auto *v = reinterpret_cast<SyncView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||||
|
if (!v) return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (msg == WM_TIMER)
|
||||||
|
{
|
||||||
|
updateSyncControls(w, *v->app);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (msg == WM_COMMAND)
|
||||||
|
{
|
||||||
|
int id = LOWORD(wp);
|
||||||
|
if (id == 601 && HIWORD(wp) == BN_CLICKED)
|
||||||
|
{
|
||||||
|
updateSyncControls(w, *v->app);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if ((id == IDOK || id == 610 || id == 611 || id == 612) &&
|
||||||
|
!IsWindowEnabled(GetDlgItem(w, id))) return 0;
|
||||||
|
if (id == 612) { showSyncConflicts(w, *v->app); return 0; }
|
||||||
|
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
|
||||||
|
if (id == 609)
|
||||||
|
{
|
||||||
|
auto c = loadSyncConfig(v->app->data);
|
||||||
|
c.enabled = false;
|
||||||
|
saveSyncConfig(v->app->data, c);
|
||||||
|
forgetSyncPassword(c);
|
||||||
|
check(w, 601, false);
|
||||||
|
value(w, 604, L"");
|
||||||
|
updateSyncControls(w, *v->app);
|
||||||
|
value(w, 608, L"Disabled. Saved password removed. An in-flight server request may still finish.");
|
||||||
|
KillTimer(w, 1);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (id == IDOK || id == 610 || id == 611)
|
||||||
|
{
|
||||||
|
if (!checked(w, 601))
|
||||||
|
{
|
||||||
|
if (id != IDOK) return 0;
|
||||||
|
auto c = loadSyncConfig(v->app->data);
|
||||||
|
c.enabled = false;
|
||||||
|
saveSyncConfig(v->app->data, c);
|
||||||
|
value(w, 604, L"");
|
||||||
|
updateSyncControls(w, *v->app);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (id != IDOK && MessageBoxW(w, id == 610 ?
|
||||||
|
L"Replace this PC's shared settings with the remote profile? A local backup will be saved. Unsaved edits in Settings are not included." :
|
||||||
|
L"Replace the remote profile with this PC's saved settings? OpenBao retains older versions according to its retention policy. Unsaved edits are not included.",
|
||||||
|
L"Resolve synchronization", MB_YESNO | MB_ICONWARNING) != IDYES) return 0;
|
||||||
|
auto c = syncFields(w);
|
||||||
|
if (c.enabled) validateSyncConfig(c);
|
||||||
|
auto password = value(w, 604);
|
||||||
|
try { if (!password.empty()) saveSyncPassword(c, password); }
|
||||||
|
catch (...) { SecureZeroMemory(password.data(), password.size() * sizeof(wchar_t)); throw; }
|
||||||
|
SecureZeroMemory(password.data(), password.size() * sizeof(wchar_t));
|
||||||
|
value(w, 604, L"");
|
||||||
|
saveSyncConfig(v->app->data, c);
|
||||||
|
value(w, 608, v->app->syncCommand(id == 610 ? 2 : id == 611 ? 3 : 1));
|
||||||
|
updateSyncControls(w, *v->app);
|
||||||
|
SetTimer(w, 1, 1000, nullptr);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (const std::exception &e)
|
||||||
|
{
|
||||||
|
// Never display parser exceptions containing connection file contents.
|
||||||
|
value(w, 608, L"Unable to complete sync action. Check connection fields and saved configuration.");
|
||||||
|
if (dynamic_cast<const std::runtime_error *>(&e))
|
||||||
|
value(w, 608, wide(e.what()));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
|
||||||
|
if (msg == WM_NCDESTROY)
|
||||||
|
{
|
||||||
|
KillTimer(w, 1);
|
||||||
|
EnableWindow(v->parent, TRUE); SetForegroundWindow(v->parent);
|
||||||
|
disposeFont(w); delete v; SetWindowLongPtrW(w, GWLP_USERDATA, 0);
|
||||||
|
}
|
||||||
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
}
|
||||||
|
void showSyncOptions(HWND parent, App &app)
|
||||||
|
{
|
||||||
|
auto c = loadSyncConfig(app.data);
|
||||||
|
HWND w = makeWindow(app, L"KSwitcher.Sync", L"Settings synchronization - OpenBao", syncProc, 660, 672);
|
||||||
|
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||||
|
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)new SyncView{parent, &app});
|
||||||
|
checkbox(w, L"&Enable synchronization with your OpenBao (KV v2)", 601, 18, 18, 620);
|
||||||
|
check(w, 601, c.enabled);
|
||||||
|
label(w, L"Se&rver (HTTPS)", 18, 60, 160); edit(w, 602, 190, 56, 450); value(w, 602, c.server);
|
||||||
|
label(w, L"User&name", 18, 100, 160); edit(w, 603, 190, 96, 450); value(w, 603, c.username);
|
||||||
|
label(w, L"&Password", 18, 140, 160); edit(w, 604, 190, 136, 450, 25, ES_AUTOHSCROLL | ES_PASSWORD);
|
||||||
|
label(w, L"Leave password blank to use the saved Windows credential.", 190, 166, 450);
|
||||||
|
label(w, L"KV v2 &mount", 18, 210, 160); edit(w, 605, 190, 206, 450); value(w, 605, c.mount);
|
||||||
|
label(w, L"Profile pa&th", 18, 250, 160); edit(w, 606, 190, 246, 450); value(w, 606, c.path);
|
||||||
|
label(w, L"&Login mount", 18, 290, 160); edit(w, 607, 190, 286, 450); value(w, 607, c.authMount);
|
||||||
|
label(w, L"Sync uses saved settings. Close and reopen Settings after downloading changes.", 18, 330, 622);
|
||||||
|
edit(w, 608, 18, 362, 622, 76, ES_MULTILINE | ES_READONLY | WS_VSCROLL | ES_AUTOVSCROLL);
|
||||||
|
value(w, 608, app.syncCommand());
|
||||||
|
checkbox(w, L"&Keep this PC's layouts and layout shortcuts (sync other settings)", 613, 18, 446, 622);
|
||||||
|
check(w, 613, c.keepLocalLayouts);
|
||||||
|
button(w, L"&Save / Sign in / Sync", IDOK, 18, 484, 200);
|
||||||
|
button(w, L"&Download remote", 610, 230, 484, 195);
|
||||||
|
button(w, L"&Upload local", 611, 437, 484, 203);
|
||||||
|
button(w, L"Resolve &individual conflicts...", 612, 18, 530, 300);
|
||||||
|
label(w, L"Background check: every 10 minutes", 330, 534, 310);
|
||||||
|
button(w, L"Disconnect / &forget password", 609, 18, 582, 290);
|
||||||
|
button(w, L"&Close", IDCANCEL, 540, 582, 100);
|
||||||
|
updateSyncControls(w, app);
|
||||||
|
EnableWindow(parent, FALSE);
|
||||||
|
SetTimer(w, 1, 1000, nullptr);
|
||||||
|
showRequestedWindow(w); SetForegroundWindow(w);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
struct SyncConflictView
|
||||||
|
{
|
||||||
|
HWND parent;
|
||||||
|
App *app;
|
||||||
|
std::vector<SyncConflict> conflicts;
|
||||||
|
std::map<size_t, bool> choices;
|
||||||
|
};
|
||||||
|
void showConflictValues(HWND w, SyncConflictView *v)
|
||||||
|
{
|
||||||
|
int selected = (int)SendDlgItemMessageW(w, 620, LB_GETCURSEL, 0, 0);
|
||||||
|
if (selected < 0 || selected >= (int)v->conflicts.size()) return;
|
||||||
|
const auto &c = v->conflicts[selected];
|
||||||
|
value(w, 621, syncValueText(c.local)); value(w, 622, syncValueText(c.remote));
|
||||||
|
}
|
||||||
|
LRESULT CALLBACK syncConflictsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
|
{
|
||||||
|
if (msg == WM_DPICHANGED) { resizeForDpi(w, wp, lp); return 0; }
|
||||||
|
auto *v = reinterpret_cast<SyncConflictView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
|
||||||
|
if (!v) return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (msg == WM_COMMAND)
|
||||||
|
{
|
||||||
|
int id = LOWORD(wp);
|
||||||
|
if (id == 620 && HIWORD(wp) == LBN_SELCHANGE) { showConflictValues(w, v); return 0; }
|
||||||
|
if (id == 623 || id == 624)
|
||||||
|
{
|
||||||
|
int selected = (int)SendDlgItemMessageW(w, 620, LB_GETCURSEL, 0, 0);
|
||||||
|
if (selected < 0 || selected >= (int)v->conflicts.size()) return 0;
|
||||||
|
v->choices[selected] = id == 623;
|
||||||
|
auto text = v->conflicts[selected].label + (id == 623 ? L" [This PC]" : L" [Server]");
|
||||||
|
SendDlgItemMessageW(w, 620, LB_DELETESTRING, selected, 0);
|
||||||
|
SendDlgItemMessageW(w, 620, LB_INSERTSTRING, selected, (LPARAM)text.c_str());
|
||||||
|
SendDlgItemMessageW(w, 620, LB_SETCURSEL, selected, 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (id == IDOK)
|
||||||
|
{
|
||||||
|
if (v->choices.empty()) throw std::runtime_error("Select a setting and choose This PC or Server first.");
|
||||||
|
std::vector<SyncChoice> choices;
|
||||||
|
for (const auto &[index, local] : v->choices) choices.push_back({v->conflicts.at(index), local});
|
||||||
|
auto status = v->app->syncCommand(4, choices);
|
||||||
|
value(v->parent, 608, status);
|
||||||
|
if (status != L"Connecting to OpenBao...") throw std::runtime_error(utf8(status));
|
||||||
|
DestroyWindow(w); return 0;
|
||||||
|
}
|
||||||
|
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (const std::exception &e) { error(w, e); return 0; }
|
||||||
|
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
|
||||||
|
if (msg == WM_NCDESTROY)
|
||||||
|
{
|
||||||
|
EnableWindow(v->parent, TRUE); SetForegroundWindow(v->parent);
|
||||||
|
disposeFont(w); delete v; SetWindowLongPtrW(w, GWLP_USERDATA, 0);
|
||||||
|
}
|
||||||
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
}
|
||||||
|
void showSyncConflicts(HWND parent, App &app)
|
||||||
|
{
|
||||||
|
auto conflicts = decodeSyncConflicts(app.syncConflictData());
|
||||||
|
if (conflicts.empty()) { value(parent, 608, L"No pending conflicts. Run Sync to check for changes."); return; }
|
||||||
|
HWND w = makeWindow(app, L"KSwitcher.SyncConflicts", L"Resolve individual settings", syncConflictsProc, 800, 560);
|
||||||
|
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||||
|
auto *v = new SyncConflictView{parent, &app, std::move(conflicts), {}};
|
||||||
|
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
|
||||||
|
label(w, L"Choose a value for each conflicting setting. Other changes are already synchronized.", 18, 16, 760);
|
||||||
|
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | WS_BORDER, 620, 18, 48, 760, 130);
|
||||||
|
for (const auto &c : v->conflicts) SendDlgItemMessageW(w, 620, LB_ADDSTRING, 0, (LPARAM)c.label.c_str());
|
||||||
|
label(w, L"This PC", 18, 190, 360); label(w, L"Server", 410, 190, 368);
|
||||||
|
edit(w, 621, 18, 220, 368, 180, ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||||
|
edit(w, 622, 410, 220, 368, 180, ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL);
|
||||||
|
button(w, L"Use this &PC for selected setting", 623, 18, 412, 368);
|
||||||
|
button(w, L"Use &server for selected setting", 624, 410, 412, 368);
|
||||||
|
label(w, L"Unselected conflicts stay pending. Changed values will be checked again before saving.", 18, 454, 760);
|
||||||
|
button(w, L"&Apply choices", IDOK, 526, 488, 140);
|
||||||
|
button(w, L"&Cancel", IDCANCEL, 678, 488, 100);
|
||||||
|
SendDlgItemMessageW(w, 620, LB_SETCURSEL, 0, 0); showConflictValues(w, v);
|
||||||
|
EnableWindow(parent, FALSE); showRequestedWindow(w); SetForegroundWindow(w);
|
||||||
|
}
|
||||||
+5
-4
@@ -26,7 +26,7 @@ std::wstring quoteArgument(const std::wstring &value)
|
|||||||
return out + L'"';
|
return out + L'"';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
|
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool showProgress)
|
||||||
{
|
{
|
||||||
UpdateResult result;
|
UpdateResult result;
|
||||||
GUID guid{};
|
GUID guid{};
|
||||||
@@ -56,6 +56,7 @@ UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
|
|||||||
L" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) +
|
L" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) +
|
||||||
L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName);
|
L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName);
|
||||||
if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
|
if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
|
||||||
|
if (showProgress) command += L" -ShowProgress";
|
||||||
STARTUPINFOW startup{sizeof(startup)};
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
PROCESS_INFORMATION process{};
|
PROCESS_INFORMATION process{};
|
||||||
if (!CreateProcessW(powershell.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
|
if (!CreateProcessW(powershell.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
|
||||||
@@ -94,10 +95,10 @@ UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void confirmUpdatedStartup(const std::wstring &eventName)
|
bool confirmUpdatedStartup(const std::wstring &eventName)
|
||||||
{
|
{
|
||||||
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return;
|
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return false;
|
||||||
Handle event{OpenEventW(EVENT_MODIFY_STATE, FALSE, eventName.c_str())};
|
Handle event{OpenEventW(EVENT_MODIFY_STATE, FALSE, eventName.c_str())};
|
||||||
if (event.value) SetEvent(event.value);
|
return event.value && SetEvent(event.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -2,13 +2,17 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.4.0";
|
inline constexpr wchar_t AppVersion[] = L"0.6.30";
|
||||||
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 UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||||
|
bool notifyUpdateAvailable(const std::wstring &version);
|
||||||
|
std::wstring updateToastXml(const std::wstring &version);
|
||||||
struct UpdateResult
|
struct UpdateResult
|
||||||
{
|
{
|
||||||
std::wstring state, version, error;
|
std::wstring state, version, error;
|
||||||
bool interactive{}, install{};
|
bool interactive{}, install{};
|
||||||
};
|
};
|
||||||
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion);
|
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool showProgress = false);
|
||||||
void confirmUpdatedStartup(const std::wstring &eventName);
|
bool confirmUpdatedStartup(const std::wstring &eventName);
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-2
@@ -1,4 +1,4 @@
|
|||||||
param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'))
|
param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'), [switch]$LogsOnly)
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) {
|
if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) {
|
||||||
throw 'Close KSwitcher and its configurator before this isolated process test.'
|
throw 'Close KSwitcher and its configurator before this isolated process test.'
|
||||||
@@ -18,6 +18,7 @@ public static class ConfigurationTestUi {
|
|||||||
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr GetText(IntPtr w,uint msg,IntPtr count,System.Text.StringBuilder text);
|
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr GetText(IntPtr w,uint msg,IntPtr count,System.Text.StringBuilder text);
|
||||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string cls,string title);
|
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string cls,string title);
|
||||||
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr w,int id);
|
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr w,int id);
|
||||||
|
[DllImport("user32.dll")] public static extern bool IsWindowEnabled(IntPtr w);
|
||||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
|
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
|
||||||
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
|
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
|
||||||
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr SetText(IntPtr w,uint msg,IntPtr wp,string text);
|
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr SetText(IntPtr w,uint msg,IntPtr wp,string text);
|
||||||
@@ -38,7 +39,7 @@ $checks=0
|
|||||||
function Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ }
|
function Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ }
|
||||||
function Window($class) {
|
function Window($class) {
|
||||||
for($i=0;$i -lt 100;$i++) {
|
for($i=0;$i -lt 100;$i++) {
|
||||||
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'}}
|
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'};'KSwitcher.Logs'{'KSwitcher diagnostic operations'}}
|
||||||
$window=[ConfigurationTestUi]::FindWindow($class,$title)
|
$window=[ConfigurationTestUi]::FindWindow($class,$title)
|
||||||
if($window -ne [IntPtr]::Zero){return $window}
|
if($window -ne [IntPtr]::Zero){return $window}
|
||||||
Start-Sleep -Milliseconds 50
|
Start-Sleep -Milliseconds 50
|
||||||
@@ -56,6 +57,24 @@ function Launch($file,$argument) {
|
|||||||
function Click($window,$id) {
|
function Click($window,$id) {
|
||||||
$null=[ConfigurationTestUi]::PostMessage($window,0x111,[IntPtr]$id,[IntPtr]::Zero)
|
$null=[ConfigurationTestUi]::PostMessage($window,0x111,[IntPtr]$id,[IntPtr]::Zero)
|
||||||
}
|
}
|
||||||
|
function TestLogs {
|
||||||
|
'test-marker' | Set-Content -LiteralPath (Join-Path $root 'data/kswitcher.log')
|
||||||
|
$config=Launch 'KSwitcher.Config.exe' '--logs'
|
||||||
|
$logs=Window 'KSwitcher.Logs'
|
||||||
|
[uint32]$logOwner=0
|
||||||
|
$null=[ConfigurationTestUi]::GetWindowThreadProcessId($logs,[ref]$logOwner)
|
||||||
|
Assert ($logOwner -eq $config.Id -and $logOwner -ne $resident.Id) 'logs run outside the resident input-hook process'
|
||||||
|
$second=Launch 'KSwitcher.Config.exe' '--logs'
|
||||||
|
Assert ($second.WaitForExit(6000)) 'opening logs again reuses the configurator'
|
||||||
|
Assert ((Window 'KSwitcher.Logs') -eq $logs) 'log window is reused'
|
||||||
|
Click $logs 603
|
||||||
|
Assert (!$resident.HasExited) 'refreshing logs leaves resident running'
|
||||||
|
Click $logs 606
|
||||||
|
for($i=0;$i -lt 50 -and (Test-Path -LiteralPath (Join-Path $root 'data/kswitcher.log'));$i++) { Start-Sleep -Milliseconds 50 }
|
||||||
|
Assert (!(Test-Path -LiteralPath (Join-Path $root 'data/kswitcher.log'))) 'clear log is committed through resident process'
|
||||||
|
Click $logs 2
|
||||||
|
Assert ($config.WaitForExit(6000)) 'closing standalone logs exits configurator'
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null
|
New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null
|
||||||
foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) {
|
foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) {
|
||||||
@@ -67,6 +86,13 @@ try {
|
|||||||
ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8
|
ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8
|
||||||
$resident=Launch 'KSwitcher.exe' ''
|
$resident=Launch 'KSwitcher.exe' ''
|
||||||
$main=Window 'KSwitcher.Main'
|
$main=Window 'KSwitcher.Main'
|
||||||
|
if ($LogsOnly) {
|
||||||
|
TestLogs
|
||||||
|
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||||
|
Assert ($resident.WaitForExit(10000)) 'fixture resident exits normally'
|
||||||
|
"Passed $checks log process checks"
|
||||||
|
return
|
||||||
|
}
|
||||||
$config=Launch 'KSwitcher.Config.exe' ''
|
$config=Launch 'KSwitcher.Config.exe' ''
|
||||||
$settings=Window 'KSwitcher.Settings'
|
$settings=Window 'KSwitcher.Settings'
|
||||||
[uint32]$ownerId=0
|
[uint32]$ownerId=0
|
||||||
@@ -102,6 +128,29 @@ try {
|
|||||||
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'cancel preserves saved settings'
|
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'cancel preserves saved settings'
|
||||||
$config=Launch 'KSwitcher.Config.exe' '--snippets'
|
$config=Launch 'KSwitcher.Config.exe' '--snippets'
|
||||||
$snippets=Window 'KSwitcher.Snippets'
|
$snippets=Window 'KSwitcher.Snippets'
|
||||||
|
$null=[ConfigurationTestUi]::SendMessage($snippets,0,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||||
|
foreach($id in @(302,303,311,312,313)) {
|
||||||
|
Assert (![ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) "snippet control $id disabled without selection"
|
||||||
|
}
|
||||||
|
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]310,[IntPtr]::Zero)
|
||||||
|
foreach($id in @(302,303,311,312,313)) {
|
||||||
|
Assert ([ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) 'Add enables snippet editing'
|
||||||
|
}
|
||||||
|
$null=[ConfigurationTestUi]::SetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0c,[IntPtr]::Zero,'preserved snippet')
|
||||||
|
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]310,[IntPtr]::Zero)
|
||||||
|
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]311,[IntPtr]::Zero)
|
||||||
|
foreach($id in @(302,303,311,312,313)) {
|
||||||
|
Assert (![ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,$id))) 'deleting selection disables snippet editing'
|
||||||
|
}
|
||||||
|
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr]312,[IntPtr]::Zero)
|
||||||
|
$snippetText=[Text.StringBuilder]::new(128)
|
||||||
|
$null=[ConfigurationTestUi]::GetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0d,[IntPtr]128,$snippetText)
|
||||||
|
Assert ($snippetText.Length -eq 0) 'marker command cannot edit an unselected snippet'
|
||||||
|
$null=[ConfigurationTestUi]::SendMessage([ConfigurationTestUi]::GetDlgItem($snippets,301),0x186,[IntPtr]0,[IntPtr]::Zero)
|
||||||
|
$null=[ConfigurationTestUi]::SendMessage($snippets,0x111,[IntPtr](301 -bor (1 -shl 16)),[IntPtr]::Zero)
|
||||||
|
Assert ([ConfigurationTestUi]::IsWindowEnabled([ConfigurationTestUi]::GetDlgItem($snippets,303))) 'selecting existing snippet enables editing'
|
||||||
|
$null=[ConfigurationTestUi]::GetText([ConfigurationTestUi]::GetDlgItem($snippets,303),0x0d,[IntPtr]128,$snippetText)
|
||||||
|
Assert ($snippetText.ToString() -eq 'preserved snippet') 'selection restores the retained snippet content'
|
||||||
$second=Launch 'KSwitcher.Config.exe' ''
|
$second=Launch 'KSwitcher.Config.exe' ''
|
||||||
Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'settings and snippets share a single process'
|
Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'settings and snippets share a single process'
|
||||||
$settings=Window 'KSwitcher.Settings'
|
$settings=Window 'KSwitcher.Settings'
|
||||||
@@ -110,6 +159,7 @@ try {
|
|||||||
$config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive'
|
$config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive'
|
||||||
Click $snippets 2
|
Click $snippets 2
|
||||||
Assert ($config.WaitForExit(6000)) 'closing last window exits configurator'
|
Assert ($config.WaitForExit(6000)) 'closing last window exits configurator'
|
||||||
|
TestLogs
|
||||||
$config=Launch 'KSwitcher.Config.exe' ''
|
$config=Launch 'KSwitcher.Config.exe' ''
|
||||||
$null=Window 'KSwitcher.Settings'
|
$null=Window 'KSwitcher.Settings'
|
||||||
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
|
||||||
|
|||||||
+384
@@ -1,12 +1,67 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include "ui_scroll.h"
|
||||||
#include "ui_geometry.h"
|
#include "ui_geometry.h"
|
||||||
#include "screenshot.h"
|
#include "screenshot.h"
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
LRESULT CALLBACK countControlMoves(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR data)
|
||||||
|
{
|
||||||
|
if (message == WM_WINDOWPOSCHANGING) ++*reinterpret_cast<int *>(data);
|
||||||
|
return DefSubclassProc(w, message, wp, lp);
|
||||||
|
}
|
||||||
namespace ks {
|
namespace ks {
|
||||||
struct InputRegressionAccess {
|
struct InputRegressionAccess {
|
||||||
|
static inline bool delivery = true;
|
||||||
|
static inline int notifications = 0;
|
||||||
|
static bool notify(const std::wstring &) { ++notifications; return delivery; }
|
||||||
|
static void sampleConflicts(App &a) {
|
||||||
|
a.conflictConnection=loadSyncConfig(a.data);
|
||||||
|
a.pendingSyncConflicts={{"/autoCorrectMinLength",L"Minimum word length",{'4'},{'5'}}};
|
||||||
|
}
|
||||||
|
static bool staleSync(App &a, bool connectionChanged) {
|
||||||
|
auto before=encodeSettings(a.settings);
|
||||||
|
a.syncConnection=loadSyncConfig(a.data);
|
||||||
|
a.syncSnapshot=before;
|
||||||
|
if(connectionChanged) a.syncConnection.enabled=!a.syncConnection.enabled;
|
||||||
|
else a.syncSnapshot.clear();
|
||||||
|
auto remote=a.settings; remote.recent=!remote.recent;
|
||||||
|
a.syncResult={true,sharedSyncSettings(remote),2,L"test"};
|
||||||
|
a.syncWorker=std::thread([]{});
|
||||||
|
a.syncRunning=true; a.syncDone=true;
|
||||||
|
a.pollSync();
|
||||||
|
return encodeSettings(a.settings)==before && !a.syncRunning &&
|
||||||
|
!std::filesystem::exists(a.data/L"sync-base.bin");
|
||||||
|
}
|
||||||
|
static bool syncBackoff(App &a) {
|
||||||
|
a.syncRetryAfter=GetTickCount64()+15*60000;
|
||||||
|
auto previous=a.settings;
|
||||||
|
auto next=previous;
|
||||||
|
next.toggle=next.casing=next.translit=next.undo=next.clipboard=next.screenshot=L"";
|
||||||
|
next.direct.clear(); next.directIds.clear();
|
||||||
|
// This harness deliberately has no global keyboard hook.
|
||||||
|
a.applySettings(next);
|
||||||
|
bool preserved=a.nextSync==a.syncRetryAfter;
|
||||||
|
a.settings=previous;
|
||||||
|
a.syncRetryAfter=0;
|
||||||
|
return preserved;
|
||||||
|
}
|
||||||
|
static void notificationSetup(App &a) { a.updateNotifier = notify; }
|
||||||
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
|
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
|
||||||
|
static bool updateTimer(App &a) {
|
||||||
|
a.updateRunning=true; // Exercise timer scheduling without launching a network request.
|
||||||
|
App::windowProc(a.window,WM_TIMER,5,0);
|
||||||
|
bool first=KillTimer(a.window,5)!=FALSE;
|
||||||
|
App::windowProc(a.window,WM_TIMER,5,0);
|
||||||
|
bool repeated=KillTimer(a.window,5)!=FALSE;
|
||||||
|
a.updateRunning=false;
|
||||||
|
return first && repeated && !a.updateWorker.joinable();
|
||||||
|
}
|
||||||
|
static std::wstring updateResult(App &a,const wchar_t *state,const wchar_t *version) {
|
||||||
|
auto result=new UpdateResult; result->state=state; result->version=version;
|
||||||
|
App::windowProc(a.window,UpdateMessage,0,(LPARAM)result);
|
||||||
|
return a.notifiedUpdate;
|
||||||
|
}
|
||||||
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
|
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -28,9 +83,34 @@ int main(int argc, char **argv) {
|
|||||||
{
|
{
|
||||||
ks::App app(GetModuleHandleW(nullptr),true,temp);
|
ks::App app(GetModuleHandleW(nullptr),true,temp);
|
||||||
ks::InputRegressionAccess::setup(app);
|
ks::InputRegressionAccess::setup(app);
|
||||||
|
ks::InputRegressionAccess::notificationSetup(app);
|
||||||
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
|
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
|
||||||
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
|
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
|
||||||
require(app.window!=nullptr,"message-only test window");
|
require(app.window!=nullptr,"message-only test window");
|
||||||
|
require(!ks::confirmUpdatedStartup(L""),"normal startup does not report update success");
|
||||||
|
require(!ks::confirmUpdatedStartup(L"Local.Other.Event"),"unrelated event cannot report update success");
|
||||||
|
auto startedName=L"Local\\KSwitcher.Update.Started.Test."+std::to_wstring(GetCurrentProcessId());
|
||||||
|
require(!ks::confirmUpdatedStartup(startedName),"missing updater handshake does not report success");
|
||||||
|
auto started=CreateEventW(nullptr,TRUE,FALSE,startedName.c_str());
|
||||||
|
require(started&&ks::confirmUpdatedStartup(startedName)&&WaitForSingleObject(started,0)==WAIT_OBJECT_0,
|
||||||
|
"successful startup signals the updater before reporting completion");
|
||||||
|
CloseHandle(started);
|
||||||
|
require(ks::UpdateCheckIntervalMs==21600000,"automatic update interval is six hours");
|
||||||
|
require(ks::InputRegressionAccess::updateTimer(app),"update timer rearms and avoids overlapping checks");
|
||||||
|
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","background update records announced version");
|
||||||
|
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","same available version remains deduplicated");
|
||||||
|
require(ks::InputRegressionAccess::notifications==1,"successful background notification is submitted only once");
|
||||||
|
require(ks::InputRegressionAccess::updateResult(app,L"error",L"")==L"99.0.0","network error preserves announced version");
|
||||||
|
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.1")==L"99.0.1","new version can be announced after an error");
|
||||||
|
ks::InputRegressionAccess::delivery=false;
|
||||||
|
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.2")==L"99.0.1","failed notification is not marked delivered");
|
||||||
|
require(KillTimer(app.window,5)!=FALSE,"failed notification schedules a retry");
|
||||||
|
ks::InputRegressionAccess::delivery=true;
|
||||||
|
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.2")==L"99.0.2","notification can succeed on retry");
|
||||||
|
auto toast=ks::updateToastXml(L"1&2<3\"");
|
||||||
|
require(toast.find(L"1&2<3"")!=toast.npos,"update version is XML escaped");
|
||||||
|
require(toast.find(L"launch=\"check-update\"")!=toast.npos && toast.find(L"arguments=\"check-update\"")!=toast.npos,
|
||||||
|
"toast body and action both review the update instead of installing automatically");
|
||||||
for(int variant=0;variant<3;++variant) for(int icon=0;icon<15;++icon)
|
for(int variant=0;variant<3;++variant) for(int icon=0;icon<15;++icon)
|
||||||
for(int size:{22,44,55}) {
|
for(int size:{22,44,55}) {
|
||||||
auto handle=(HICON)LoadImageW(app.instance,MAKEINTRESOURCEW(icon<9?300+variant*10+icon:400+variant*10+icon-9),IMAGE_ICON,size,size,0);
|
auto handle=(HICON)LoadImageW(app.instance,MAKEINTRESOURCEW(icon<9?300+variant*10+icon:400+variant*10+icon-9),IMAGE_ICON,size,size,0);
|
||||||
@@ -159,6 +239,67 @@ int main(int argc, char **argv) {
|
|||||||
require(detached.fromNumber==0,"dropping away from badges remains detached");
|
require(detached.fromNumber==0,"dropping away from badges remains detached");
|
||||||
auto translated=ks::dragScreenshotMark(linked[2],ks::MarkPart::Body,{80,35},{85,40},160,100);
|
auto translated=ks::dragScreenshotMark(linked[2],ks::MarkPart::Body,{80,35},{85,40},160,100);
|
||||||
require(translated.fromNumber==0 && translated.toNumber==0,"moving whole arrow detaches both endpoints");
|
require(translated.fromNumber==0 && translated.toNumber==0,"moving whole arrow detaches both endpoints");
|
||||||
|
for(auto shape : {ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse}) {
|
||||||
|
std::vector<ks::ScreenshotMark> objects={{{100,50},{160,110},0},{{10,80},{100,80},0},{{10,140},{55,80},0}};
|
||||||
|
objects[0].shape=shape;
|
||||||
|
ks::linkScreenshotEndpoint(objects[1],false,{100,80},objects,6);
|
||||||
|
ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects[1].toLink.target==objects[0].id && objects[1].to.x==100,"arrow attaches to shape boundary");
|
||||||
|
ks::linkScreenshotEndpoint(objects[2],false,{55,80},objects,6);
|
||||||
|
ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects[2].toLink.target==objects[1].id,"arrow attaches to another arrow body");
|
||||||
|
auto targetId=objects[0].id,arrowId=objects[1].id;
|
||||||
|
auto snapshot=objects; ks::ScreenshotHistory linksHistory; linksHistory.remember(objects);
|
||||||
|
// Reverse storage order so dependency resolution cannot rely on insertion order.
|
||||||
|
std::reverse(objects.begin(),objects.end());
|
||||||
|
objects[2]=ks::dragScreenshotMark(objects[2],ks::MarkPart::Body,{0,0},{20,20},400,300);
|
||||||
|
ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects[1].to.x==120 && objects[1].to.y==100 && objects[0].to.x==65 && objects[0].to.y==90,
|
||||||
|
"shape-arrow-arrow chain updates in dependency order");
|
||||||
|
require(linksHistory.undo(objects) && objects[1].to.x==100 && objects[2].toLink.target==arrowId,
|
||||||
|
"undo restores geometry and generic links");
|
||||||
|
require(linksHistory.redo(objects) && objects[0].to.x==65,"redo restores moved chain");
|
||||||
|
auto resized=objects;
|
||||||
|
auto corner=resized[2].to;
|
||||||
|
resized[2]=ks::dragScreenshotMark(resized[2],ks::MarkPart::End,corner,{corner.x+20,corner.y+20},400,300);
|
||||||
|
ks::resolveScreenshotLinks(resized);
|
||||||
|
require(resized[1].to.x==120 && resized[1].to.y==110 && resized[0].to.y==95,
|
||||||
|
"resizing shapes updates both levels of attached arrows");
|
||||||
|
ks::linkScreenshotEndpoint(objects[1],true,objects[0].from,objects,6);
|
||||||
|
require(!objects[1].fromLink.target,"cyclic arrow links are rejected");
|
||||||
|
auto clone=ks::cloneScreenshotMark(objects[1],objects,400,300);
|
||||||
|
require(!clone.id && !clone.fromLink.target && !clone.toLink.target,"cloned connector has independent identity and no links");
|
||||||
|
auto endpoint=objects[1].to;
|
||||||
|
objects.erase(objects.begin()+2); ks::resolveScreenshotLinks(objects);
|
||||||
|
require(!objects[1].toLink.target && objects[1].to.x==endpoint.x && objects[0].toLink.target==arrowId,
|
||||||
|
"deleting shape detaches only its dependents without moving endpoints");
|
||||||
|
objects.push_back(snapshot[0]); objects.back().id=0; ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects.back().id!=targetId && !objects[1].toLink.target,"new objects cannot revive deleted links");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::vector<ks::ScreenshotMark> objects={{{80,40},{80,40},0},{{10,40},{80,40},0}};
|
||||||
|
objects[0].text=L"Target"; objects[0].fontSize=20;
|
||||||
|
auto bounds=ks::screenshotTextBounds(objects[0]);
|
||||||
|
ks::linkScreenshotEndpoint(objects[1],false,{bounds.left,(bounds.top+bounds.bottom)/2},objects,6);
|
||||||
|
ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects[1].toLink.target==objects[0].id,"arrow attaches to text block");
|
||||||
|
auto old=objects[1].to;
|
||||||
|
objects[0]=ks::dragScreenshotMark(objects[0],ks::MarkPart::Body,{0,0},{30,20},400,300);
|
||||||
|
ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects[1].to.x==old.x+30 && objects[1].to.y==old.y+20,"text attachment follows movement");
|
||||||
|
objects[0].rotation=90; ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects[1].to.x!=old.x+30 || objects[1].to.y!=old.y+20,"text attachment follows rotation");
|
||||||
|
}
|
||||||
|
for(bool freehand : {false,true}) {
|
||||||
|
std::vector<ks::ScreenshotMark> objects={{{80,60},{160,80},0},{{10,140},{110,60},0}};
|
||||||
|
objects[0].points={{80,60},{140,60},{160,80}}; objects[0].freehand=freehand;
|
||||||
|
ks::linkScreenshotEndpoint(objects[1],false,{110,60},objects,6);
|
||||||
|
ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects[1].toLink.target==objects[0].id,"arrow attaches to freehand and highlight strokes");
|
||||||
|
objects[0]=ks::dragScreenshotMark(objects[0],ks::MarkPart::Body,{0,0},{20,20},400,300);
|
||||||
|
ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects[1].to.x==130 && objects[1].to.y==80,"stroke attachment follows the original segment");
|
||||||
|
}
|
||||||
auto lastPoint=linked[2].from;
|
auto lastPoint=linked[2].from;
|
||||||
linked.erase(linked.begin());
|
linked.erase(linked.begin());
|
||||||
ks::resolveScreenshotLinks(linked);
|
ks::resolveScreenshotLinks(linked);
|
||||||
@@ -458,6 +599,9 @@ int main(int argc, char **argv) {
|
|||||||
require(!app.handleKeyboard(f12,true,MOD_CONTROL|MOD_SHIFT),"exact modifiers only");
|
require(!app.handleKeyboard(f12,true,MOD_CONTROL|MOD_SHIFT),"exact modifiers only");
|
||||||
app.resetInput();
|
app.resetInput();
|
||||||
app.handleKeyboard(event('A',0x1e),true,0);
|
app.handleKeyboard(event('A',0x1e),true,0);
|
||||||
|
if (app.run.keys!=L"a" && app.run.keys!=L"A")
|
||||||
|
std::cerr << "Input fixture foreground=" << (uintptr_t)GetForegroundWindow()
|
||||||
|
<< " recorded=" << ks::utf8(app.run.keys) << '\n';
|
||||||
require(app.run.keys==L"a"||app.run.keys==L"A","external scan recorded with current CapsLock");
|
require(app.run.keys==L"a"||app.run.keys==L"A","external scan recorded with current CapsLock");
|
||||||
auto keys=app.run.keys; auto version=app.inputVersion.load();
|
auto keys=app.run.keys; auto version=app.inputVersion.load();
|
||||||
for(WORD key:{WORD('V'),WORD(VK_SHIFT),WORD(VK_LEFT),WORD(VK_F15),WORD(0xE8)})
|
for(WORD key:{WORD('V'),WORD(VK_SHIFT),WORD(VK_LEFT),WORD(VK_F15),WORD(0xE8)})
|
||||||
@@ -485,8 +629,224 @@ int main(int argc, char **argv) {
|
|||||||
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
|
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
|
||||||
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
|
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
|
||||||
ks::showSettings(app,false);
|
ks::showSettings(app,false);
|
||||||
|
require(GetDlgItem(app.settingsWindow,258)!=nullptr,"sync available without local opt-in");
|
||||||
|
// Mnemonics only target visible controls; show without taking foreground focus.
|
||||||
|
ShowWindow(app.settingsWindow,SW_SHOWNOACTIVATE);
|
||||||
|
auto previousActive=SetActiveWindow(app.settingsWindow);
|
||||||
|
auto awakeBefore=SendDlgItemMessageW(app.settingsWindow,205,BM_GETCHECK,0,0);
|
||||||
|
MSG accessKey{}; accessKey.hwnd=GetDlgItem(app.settingsWindow,201);
|
||||||
|
accessKey.message=WM_SYSCHAR; accessKey.wParam=L'k'; accessKey.lParam=1LL<<29;
|
||||||
|
require(IsDialogMessageW(app.settingsWindow,&accessKey),"Alt+K is handled as a dialog access key");
|
||||||
|
MSG dispatched{};
|
||||||
|
while(PeekMessageW(&dispatched,nullptr,0,0,PM_REMOVE)) { TranslateMessage(&dispatched); DispatchMessageW(&dispatched); }
|
||||||
|
require(SendDlgItemMessageW(app.settingsWindow,205,BM_GETCHECK,0,0)!=awakeBefore,
|
||||||
|
"Alt+K toggles Keep screen awake");
|
||||||
|
IsDialogMessageW(app.settingsWindow,&accessKey);
|
||||||
|
while(PeekMessageW(&dispatched,nullptr,0,0,PM_REMOVE)) { TranslateMessage(&dispatched); DispatchMessageW(&dispatched); }
|
||||||
|
require(SendDlgItemMessageW(app.settingsWindow,205,BM_GETCHECK,0,0)==awakeBefore,
|
||||||
|
"repeated access key restores checkbox state");
|
||||||
|
ShowWindow(app.settingsWindow,SW_HIDE);
|
||||||
|
SetActiveWindow(previousActive);
|
||||||
|
|
||||||
|
DestroyWindow(app.settingsWindow);
|
||||||
|
ks::saveSyncConfig(app.data,ks::SyncConfig{});
|
||||||
|
ks::showSettings(app,false);
|
||||||
|
require(GetDlgItem(app.settingsWindow,258)!=nullptr,"sync button available after local opt-in");
|
||||||
|
SendMessageW(app.settingsWindow,WM_COMMAND,258,0);
|
||||||
|
auto syncWindow=FindWindowW(L"KSwitcher.Sync",nullptr);
|
||||||
|
require(syncWindow!=nullptr,"private sync dialog opens");
|
||||||
|
ShowWindow(syncWindow,SW_HIDE);
|
||||||
|
require((GetWindowLongPtrW(GetDlgItem(syncWindow,604),GWL_STYLE)&ES_PASSWORD)!=0,
|
||||||
|
"sync password is masked");
|
||||||
|
require(!IsWindowEnabled(app.settingsWindow),"sync dialog owns settings interaction");
|
||||||
|
for(int id:{602,603,604,605,606,607,610,611,612,613,IDOK})
|
||||||
|
require(!IsWindowEnabled(GetDlgItem(syncWindow,id)),"disabled synchronization disables dependent controls");
|
||||||
|
require(IsWindowEnabled(GetDlgItem(syncWindow,601)) && IsWindowEnabled(GetDlgItem(syncWindow,609)) &&
|
||||||
|
IsWindowEnabled(GetDlgItem(syncWindow,IDCANCEL)),"enable, forget password and close remain available");
|
||||||
|
SendMessageW(syncWindow,WM_COMMAND,612,0);
|
||||||
|
require(!FindWindowW(L"KSwitcher.SyncConflicts",nullptr),"disabled conflict action is ignored");
|
||||||
|
SendDlgItemMessageW(syncWindow,601,BM_SETCHECK,BST_CHECKED,0);
|
||||||
|
SendMessageW(syncWindow,WM_COMMAND,MAKEWPARAM(601,BN_CLICKED),0);
|
||||||
|
for(int id:{602,603,604,605,606,607,610,611,613,IDOK})
|
||||||
|
require(IsWindowEnabled(GetDlgItem(syncWindow,id)),"enabling synchronization immediately enables connection controls");
|
||||||
|
require(!ks::loadSyncConfig(app.data).enabled,"checking enable alone does not connect or persist");
|
||||||
|
auto enabledSync=ks::loadSyncConfig(app.data);
|
||||||
|
enabledSync.enabled=true;
|
||||||
|
enabledSync.server=L"https://sync.example.test";
|
||||||
|
enabledSync.username=L"test";
|
||||||
|
ks::saveSyncConfig(app.data,enabledSync);
|
||||||
|
SendMessageW(syncWindow,WM_COMMAND,MAKEWPARAM(601,BN_CLICKED),0);
|
||||||
|
ks::InputRegressionAccess::sampleConflicts(app);
|
||||||
|
SendMessageW(syncWindow,WM_COMMAND,612,0);
|
||||||
|
auto conflictsWindow=FindWindowW(L"KSwitcher.SyncConflicts",nullptr);
|
||||||
|
require(conflictsWindow!=nullptr,"per-setting conflict dialog opens");
|
||||||
|
ShowWindow(conflictsWindow,SW_HIDE);
|
||||||
|
require(SendDlgItemMessageW(conflictsWindow,620,LB_GETCOUNT,0,0)==1,"conflict shown as separate setting");
|
||||||
|
wchar_t conflictValue[20]{}; GetDlgItemTextW(conflictsWindow,621,conflictValue,20);
|
||||||
|
require(std::wstring(conflictValue)==L"4","local conflict value shown");
|
||||||
|
GetDlgItemTextW(conflictsWindow,622,conflictValue,20);
|
||||||
|
require(std::wstring(conflictValue)==L"5","remote conflict value shown");
|
||||||
|
for(int id:{620,621,622,623,624,IDOK,IDCANCEL}) {
|
||||||
|
RECT control{},client{};
|
||||||
|
GetWindowRect(GetDlgItem(conflictsWindow,id),&control);
|
||||||
|
MapWindowPoints(nullptr,conflictsWindow,reinterpret_cast<POINT*>(&control),2);
|
||||||
|
GetClientRect(conflictsWindow,&client);
|
||||||
|
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
|
||||||
|
"conflict controls fit dialog");
|
||||||
|
}
|
||||||
|
SendMessageW(conflictsWindow,WM_COMMAND,623,0);
|
||||||
|
require(!IsWindowEnabled(syncWindow),"conflict dialog owns sync interaction");
|
||||||
|
SendMessageW(conflictsWindow,WM_COMMAND,IDCANCEL,0);
|
||||||
|
require(IsWindowEnabled(syncWindow),"closing conflict dialog restores sync window");
|
||||||
|
for(int id:{601,602,603,604,605,606,607,608,609,610,611,612,613,IDOK,IDCANCEL}) {
|
||||||
|
RECT control{},client{};
|
||||||
|
GetWindowRect(GetDlgItem(syncWindow,id),&control);
|
||||||
|
MapWindowPoints(nullptr,syncWindow,reinterpret_cast<POINT*>(&control),2);
|
||||||
|
GetClientRect(syncWindow,&client);
|
||||||
|
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
|
||||||
|
"sync controls fit dialog");
|
||||||
|
}
|
||||||
|
SendDlgItemMessageW(syncWindow,601,BM_SETCHECK,BST_UNCHECKED,0);
|
||||||
|
SendMessageW(syncWindow,WM_COMMAND,MAKEWPARAM(601,BN_CLICKED),0);
|
||||||
|
require(IsWindowEnabled(GetDlgItem(syncWindow,IDOK)),"Save remains available to persist disabling sync");
|
||||||
|
SendMessageW(syncWindow,WM_COMMAND,IDOK,0);
|
||||||
|
require(!ks::loadSyncConfig(app.data).enabled,"saving disabled sync does not enable network access");
|
||||||
|
require(!IsWindowEnabled(GetDlgItem(syncWindow,IDOK)),"Save disables after disabled state is persisted");
|
||||||
|
SendMessageW(syncWindow,WM_COMMAND,IDCANCEL,0);
|
||||||
|
require(IsWindowEnabled(app.settingsWindow),"closing sync restores parent");
|
||||||
|
require(ks::InputRegressionAccess::staleSync(app,false),"in-flight sync cannot overwrite newer local edits");
|
||||||
|
require(ks::InputRegressionAccess::staleSync(app,true),"disconnect discards in-flight downloaded profile");
|
||||||
|
require(ks::InputRegressionAccess::syncBackoff(app),"saving settings preserves authentication error backoff");
|
||||||
|
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
||||||
|
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||||
|
require(autoOptions!=nullptr,"auto-correction options open");
|
||||||
|
ShowWindow(autoOptions,SW_HIDE);
|
||||||
|
{
|
||||||
|
require((GetWindowLongPtrW(autoOptions,GWL_STYLE)&WS_THICKFRAME)!=0,
|
||||||
|
"auto-correction dialog supports resizing");
|
||||||
|
RECT original{}, before[3]{}, after[3]{}, buttonBefore{}, buttonAfter{};
|
||||||
|
GetWindowRect(autoOptions,&original);
|
||||||
|
// Use a client size above the scrolling baseline at every DPI.
|
||||||
|
UINT dpi=GetDpiForWindow(autoOptions);
|
||||||
|
SetWindowPos(autoOptions,nullptr,0,0,MulDiv(800,dpi,96),MulDiv(1100,dpi,96),
|
||||||
|
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||||
|
RECT baseline{}; GetWindowRect(autoOptions,&baseline);
|
||||||
|
RECT clientBefore{}; GetClientRect(autoOptions,&clientBefore);
|
||||||
|
for(int i=0;i<3;++i) GetWindowRect(GetDlgItem(autoOptions,411+i),&before[i]);
|
||||||
|
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonBefore);
|
||||||
|
SetWindowPos(autoOptions,nullptr,0,0,baseline.right-baseline.left+240,
|
||||||
|
baseline.bottom-baseline.top+120,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||||
|
RECT clientAfter{}; GetClientRect(autoOptions,&clientAfter);
|
||||||
|
// Windows can constrain oversized windows to the current desktop.
|
||||||
|
// Check layout against the actual available client space.
|
||||||
|
int extraBeforeX=std::max(0,int(clientBefore.right)-MulDiv(560,dpi,96));
|
||||||
|
int extraAfterX=std::max(0,int(clientAfter.right)-MulDiv(560,dpi,96));
|
||||||
|
int extraY=std::max(0,int(clientAfter.bottom)-MulDiv(880,dpi,96))-
|
||||||
|
std::max(0,int(clientBefore.bottom)-MulDiv(880,dpi,96));
|
||||||
|
for(int i=0;i<3;++i) {
|
||||||
|
GetWindowRect(GetDlgItem(autoOptions,411+i),&after[i]);
|
||||||
|
int extraX=(extraAfterX*(i+1)/3-extraAfterX*i/3)-
|
||||||
|
(extraBeforeX*(i+1)/3-extraBeforeX*i/3);
|
||||||
|
require(after[i].right-after[i].left==before[i].right-before[i].left+extraX &&
|
||||||
|
after[i].bottom-after[i].top==before[i].bottom-before[i].top+extraY,
|
||||||
|
"dictionary columns share extra width and receive extra height");
|
||||||
|
}
|
||||||
|
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonAfter);
|
||||||
|
require(buttonAfter.left-buttonBefore.left==extraAfterX-extraBeforeX && buttonAfter.top-buttonBefore.top==extraY,
|
||||||
|
"auto-correction buttons follow bottom-right corner");
|
||||||
|
require(after[0].right<after[1].left && after[1].right<after[2].left &&
|
||||||
|
after[2].bottom<buttonAfter.top,
|
||||||
|
"resized dictionary fields and footer do not overlap");
|
||||||
|
SetWindowPos(autoOptions,nullptr,0,0,original.right-original.left,original.bottom-original.top,
|
||||||
|
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||||
|
}
|
||||||
|
for(int id:{411,412,413}) {
|
||||||
|
RECT control{},client{};
|
||||||
|
GetWindowRect(GetDlgItem(autoOptions,id),&control);
|
||||||
|
MapWindowPoints(nullptr,autoOptions,reinterpret_cast<POINT*>(&control),2);
|
||||||
|
GetClientRect(autoOptions,&client);
|
||||||
|
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
|
||||||
|
"personal dictionary field fits options window");
|
||||||
|
}
|
||||||
|
SetDlgItemTextW(autoOptions,412,L"Релізик\r\nрелізик, словечко");
|
||||||
|
SetDlgItemTextW(autoOptions,416,L" Првиіт = Привіт\r\nhelo=hello");
|
||||||
|
SendDlgItemMessageW(autoOptions,414,BM_SETCHECK,BST_CHECKED,0);
|
||||||
|
SendDlgItemMessageW(autoOptions,415,BM_SETCHECK,BST_CHECKED,0);
|
||||||
|
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
|
||||||
|
require(!IsWindow(autoOptions),"personal words accepted without file editing");
|
||||||
|
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
|
||||||
|
autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
|
||||||
|
ShowWindow(autoOptions,SW_HIDE);
|
||||||
|
wchar_t personalWords[128]{};
|
||||||
|
GetDlgItemTextW(autoOptions,412,personalWords,128);
|
||||||
|
require(std::wstring(personalWords)==L"релізик, словечко",
|
||||||
|
"personal dictionary normalizes case, newlines and duplicate words");
|
||||||
|
wchar_t pairs[128]{};
|
||||||
|
GetDlgItemTextW(autoOptions,416,pairs,128);
|
||||||
|
require(std::wstring(pairs)==L"helo=hello\r\nпрвиіт=привіт\r\n" &&
|
||||||
|
SendDlgItemMessageW(autoOptions,414,BM_GETCHECK,0,0)==BST_CHECKED &&
|
||||||
|
SendDlgItemMessageW(autoOptions,415,BM_GETCHECK,0,0)==BST_CHECKED,
|
||||||
|
"typo options and normalized replacement pairs survive reopening");
|
||||||
|
SendMessageW(autoOptions,WM_COMMAND,IDCANCEL,0);
|
||||||
|
if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
|
||||||
|
ks::showHistory(app,false);
|
||||||
|
auto history=app.historyWindow;
|
||||||
|
require(history&&!IsWindowVisible(history),"history layout tested without changing focus");
|
||||||
|
{
|
||||||
|
app.history=std::make_unique<ks::History>(temp/L"history-search");
|
||||||
|
app.history->add(ks::textClip(L"згенеруй приклад"),L"test",app.settings);
|
||||||
|
app.history->add(ks::textClip(L"unrelated entry"),L"test",app.settings);
|
||||||
|
// This App has no general worker: a search on that queue can never finish.
|
||||||
|
auto originalFocus=GetFocus();
|
||||||
|
auto pumpUntil=[&](int count) {
|
||||||
|
auto deadline=GetTickCount64()+3000;
|
||||||
|
do {
|
||||||
|
MSG message{};
|
||||||
|
while(PeekMessageW(&message,nullptr,0,0,PM_REMOVE)) {
|
||||||
|
TranslateMessage(&message); DispatchMessageW(&message);
|
||||||
|
}
|
||||||
|
if(SendDlgItemMessageW(history,102,LB_GETCOUNT,0,0)==count) return true;
|
||||||
|
Sleep(5);
|
||||||
|
} while(GetTickCount64()<deadline);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
ks::refreshHistory(app);
|
||||||
|
require(pumpUntil(2),"history loads independently of general worker queue");
|
||||||
|
SetDlgItemTextW(history,101,L"згенеруй");
|
||||||
|
require(pumpUntil(1),"typing filters history via debounce without focus change");
|
||||||
|
SetDlgItemTextW(history,101,L"missing query");
|
||||||
|
require(pumpUntil(0),"unmatched search clears history list");
|
||||||
|
SetDlgItemTextW(history,101,L"");
|
||||||
|
require(pumpUntil(2),"clearing search restores history");
|
||||||
|
require(GetFocus()==originalFocus,"history search preserves focus");
|
||||||
|
}
|
||||||
|
auto childRect=[&](int id) { RECT r{}; GetWindowRect(GetDlgItem(history,id),&r); MapWindowPoints(nullptr,history,(POINT*)&r,2); return r; };
|
||||||
|
RECT historyFrame{}; GetWindowRect(history,&historyFrame);
|
||||||
|
auto listBefore=childRect(102),searchBefore=childRect(101),buttonBefore=childRect(110);
|
||||||
|
SetWindowPos(history,nullptr,0,0,historyFrame.right-historyFrame.left+120,historyFrame.bottom-historyFrame.top+180,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||||
|
auto listAfter=childRect(102),searchAfter=childRect(101),buttonAfter=childRect(110);
|
||||||
|
require(listAfter.right-listBefore.right==120 && listAfter.bottom-listBefore.bottom==180,"history list fills added width and height");
|
||||||
|
require(searchAfter.right-searchBefore.right==120 && searchAfter.top==searchBefore.top,"history search grows horizontally only");
|
||||||
|
require(buttonAfter.top-buttonBefore.top==180 && buttonAfter.bottom-listAfter.bottom==buttonBefore.bottom-listBefore.bottom,"history actions stay below list after resize");
|
||||||
|
SetWindowPos(history,nullptr,0,0,historyFrame.right-historyFrame.left,historyFrame.bottom-historyFrame.top,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||||
|
require(EqualRect(&listBefore,&(listAfter=childRect(102))),"history resize roundtrip restores geometry");
|
||||||
|
// Reproduce a row height left over from another DPI without changing desktop settings.
|
||||||
|
UINT historyDpi=GetDpiForWindow(history);
|
||||||
|
for (int staleDpi : {96, 144, 192, 240}) {
|
||||||
|
SendDlgItemMessageW(history,102,LB_SETITEMHEIGHT,0,MulDiv(88,staleDpi,96));
|
||||||
|
SendMessageW(history,WM_DPICHANGED,MAKELONG(historyDpi,historyDpi),(LPARAM)&historyFrame);
|
||||||
|
require(SendDlgItemMessageW(history,102,LB_GETITEMHEIGHT,0,0)==MulDiv(88,historyDpi,96),
|
||||||
|
"DPI change updates owner-drawn history row height through layout subclass");
|
||||||
|
require(EqualRect(&listBefore,&(listAfter=childRect(102))),"DPI refresh preserves history list geometry");
|
||||||
|
}
|
||||||
auto settings=app.settingsWindow;
|
auto settings=app.settingsWindow;
|
||||||
|
require(SendDlgItemMessageW(settings,209,BM_GETCHECK,0,0)==BST_UNCHECKED,"automatic screenshot editor checkbox defaults off");
|
||||||
require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus");
|
require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus");
|
||||||
|
for(auto pair:{std::pair{209,215},std::pair{209,207},std::pair{209,220},std::pair{209,232},std::pair{207,215},std::pair{207,232},std::pair{207,220},std::pair{232,230},std::pair{230,231},std::pair{231,253}}) {
|
||||||
|
RECT first{},second{},overlap{};
|
||||||
|
GetWindowRect(GetDlgItem(settings,pair.first),&first); GetWindowRect(GetDlgItem(settings,pair.second),&second);
|
||||||
|
require(!IntersectRect(&overlap,&first,&second),"capture hotkey and layout controls do not overlap");
|
||||||
|
}
|
||||||
RECT frame{},client{},cancel{};
|
RECT frame{},client{},cancel{};
|
||||||
GetWindowRect(settings,&frame);
|
GetWindowRect(settings,&frame);
|
||||||
MONITORINFO monitor{sizeof(monitor)};
|
MONITORINFO monitor{sizeof(monitor)};
|
||||||
@@ -502,8 +862,32 @@ int main(int argc, char **argv) {
|
|||||||
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has vertical scrolling");
|
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has vertical scrolling");
|
||||||
GetScrollInfo(settings,SB_HORZ,&scroll);
|
GetScrollInfo(settings,SB_HORZ,&scroll);
|
||||||
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has horizontal scrolling");
|
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has horizontal scrolling");
|
||||||
|
int controlMoves = 0;
|
||||||
|
auto trackedControl = GetDlgItem(settings,201);
|
||||||
|
SetWindowSubclass(trackedControl,countControlMoves,99,(DWORD_PTR)&controlMoves);
|
||||||
|
SendMessageW(settings,WM_VSCROLL,SB_TOP,0);
|
||||||
|
SendMessageW(settings,WM_HSCROLL,SB_LEFT,0);
|
||||||
|
controlMoves=0;
|
||||||
|
ValidateRect(settings,nullptr);
|
||||||
|
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
||||||
|
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||||
|
require(controlMoves==0 && !GetUpdateRect(settings,nullptr,FALSE),"wheel at top does not move controls or erase the window");
|
||||||
|
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
||||||
|
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||||
|
require(controlMoves==0,"partial wheel step does not rearrange controls");
|
||||||
|
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
|
||||||
|
require(controlMoves==0,"wheel changes wait for the shared frame timer");
|
||||||
|
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||||
|
require(controlMoves==1,"wheel scroll batches each control movement once");
|
||||||
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0);
|
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0);
|
||||||
SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0);
|
SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0);
|
||||||
|
controlMoves=0;
|
||||||
|
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0);
|
||||||
|
SendMessageW(settings,WM_MOUSEHWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
|
||||||
|
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
|
||||||
|
require(controlMoves==0,"wheel at bottom and right boundaries does not rearrange controls");
|
||||||
|
require((GetWindowLongPtrW(settings,GWL_STYLE)&WS_CLIPCHILDREN)!=0,"parent background excludes child controls");
|
||||||
|
RemoveWindowSubclass(trackedControl,countControlMoves,99);
|
||||||
GetClientRect(settings,&client);
|
GetClientRect(settings,&client);
|
||||||
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
|
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
|
||||||
MapWindowPoints(nullptr,settings,(POINT*)&cancel,2);
|
MapWindowPoints(nullptr,settings,(POINT*)&cancel,2);
|
||||||
|
|||||||
+515
-7
@@ -1,16 +1,96 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include "diagnostics.h"
|
||||||
|
#include <algorithm>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
|
|
||||||
|
namespace ks
|
||||||
|
{
|
||||||
|
struct InputRegressionAccess
|
||||||
|
{
|
||||||
|
static bool hasLast(App &app) { return app.last.has_value(); }
|
||||||
|
static bool deferring(App &app)
|
||||||
|
{
|
||||||
|
std::lock_guard lock(app.stateMutex);
|
||||||
|
return app.typingTarget != nullptr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
ks::App *app{};
|
ks::App *app{};
|
||||||
HWND host{}, field{};
|
HWND host{}, field{};
|
||||||
ks::Clip saved;
|
ks::Clip saved;
|
||||||
bool captured = false, failed = false;
|
bool captured = false, failed = false;
|
||||||
|
bool conversionOnly = false;
|
||||||
|
bool typosOnly = false;
|
||||||
|
int pasteBehavior = 0;
|
||||||
|
int enterPresses = 0;
|
||||||
|
int copyFailuresRemaining = 0, copyRequests = 0;
|
||||||
|
int droppedSelectionKeys = 0;
|
||||||
|
bool temporaryClipboardExcluded = true;
|
||||||
|
bool clipboardAccessTimedOut = false;
|
||||||
|
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||||
|
{
|
||||||
|
if (message == WM_KEYDOWN && wp == VK_LEFT && droppedSelectionKeys > 0 &&
|
||||||
|
(GetKeyState(VK_SHIFT) & 0x8000))
|
||||||
|
{
|
||||||
|
--droppedSelectionKeys;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// This bare EDIT control is our test fixture, not a full editor with
|
||||||
|
// clipboard retries. Keep clipboard access on its owning UI thread while
|
||||||
|
// it handles Ctrl+C/Ctrl+V, so unrelated Windows clipboard listeners cannot
|
||||||
|
// make the fixture silently discard the operation under test.
|
||||||
|
struct ClipboardAccess
|
||||||
|
{
|
||||||
|
bool opened = false;
|
||||||
|
~ClipboardAccess() { if (opened) CloseClipboard(); }
|
||||||
|
} access;
|
||||||
|
if (message == WM_CHAR && (wp == 3 || wp == 22))
|
||||||
|
{
|
||||||
|
auto deadline = GetTickCount64() + 1000;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
if (OpenClipboard(w)) { access.opened = true; break; }
|
||||||
|
Sleep(10);
|
||||||
|
} while (GetTickCount64() < deadline);
|
||||||
|
if (!access.opened)
|
||||||
|
{
|
||||||
|
clipboardAccessTimedOut = true;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((message == WM_CHAR && wp == 3) || message == WM_PASTE)
|
||||||
|
{
|
||||||
|
temporaryClipboardExcluded &= IsClipboardFormatAvailable(
|
||||||
|
RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing")) != FALSE;
|
||||||
|
if (message == WM_CHAR)
|
||||||
|
{
|
||||||
|
++copyRequests;
|
||||||
|
// Native EDIT silently ignores Ctrl+C when OpenClipboard fails.
|
||||||
|
// Reproduce that outcome deterministically, without depending on
|
||||||
|
// the timing of the Windows clipboard-history service.
|
||||||
|
if (copyFailuresRemaining > 0)
|
||||||
|
{
|
||||||
|
--copyFailuresRemaining;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (message == WM_PASTE && pasteBehavior)
|
||||||
|
{
|
||||||
|
if (pasteBehavior == 3) { pasteBehavior = 0; return 0; }
|
||||||
|
if (pasteBehavior == 2) SendMessageW(w, EM_REPLACESEL, TRUE, (LPARAM)L"other!");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (message == WM_KEYDOWN && wp == VK_RETURN) ++enterPresses;
|
||||||
|
return DefSubclassProc(w, message, wp, lp);
|
||||||
|
}
|
||||||
int stage = 0, ru = -1, en = -1;
|
int stage = 0, ru = -1, en = -1;
|
||||||
size_t punctuationCase = 0;
|
size_t punctuationCase = 0;
|
||||||
|
bool typeDuringCorrection = false, typedDuringCorrection = false;
|
||||||
const std::vector<std::pair<std::wstring, std::wstring>> punctuationCases{
|
const std::vector<std::pair<std::wstring, std::wstring>> punctuationCases{
|
||||||
{L"(ljvf) ", L"(дома) "}, {L"[ljvf] ", L"[дома] "}, {L"{ljvf} ", L"{дома} "},
|
{L"(ljvf) ", L"(дома) "}, {L"[ljvf] ", L"[дома] "}, {L"{ljvf} ", L"{дома} "},
|
||||||
{L"ljvf: ", L"дома: "}, {L"ljvf; ", L"дома; "}, {L"#ljvf%; ", L"#дома%; "}};
|
{L"ljvf: ", L"дома: "}, {L"ljvf; ", L"дома; "}, {L"#ljvf%; ", L"#дома%; "}};
|
||||||
@@ -24,8 +104,17 @@ std::string diagnosticLog()
|
|||||||
size_t diagnosticStart{};
|
size_t diagnosticStart{};
|
||||||
void require(bool ok, const char *name)
|
void require(bool ok, const char *name)
|
||||||
{
|
{
|
||||||
if (!ok)
|
if (!ok || clipboardAccessTimedOut)
|
||||||
|
{
|
||||||
|
wchar_t observed[2048]{};
|
||||||
|
GetWindowTextW(field, observed, 2048);
|
||||||
|
std::cerr << "Editor at failure: " << ks::utf8(observed)
|
||||||
|
<< "; foregroundSame=" << (GetForegroundWindow() == host)
|
||||||
|
<< "; inputVersion=" << app->inputVersion.load() << '\n';
|
||||||
|
if (clipboardAccessTimedOut)
|
||||||
|
throw std::runtime_error("Test editor could not acquire clipboard within 1000 ms");
|
||||||
throw std::runtime_error(name);
|
throw std::runtime_error(name);
|
||||||
|
}
|
||||||
std::cout << "PASS " << name << '\n';
|
std::cout << "PASS " << name << '\n';
|
||||||
}
|
}
|
||||||
std::wstring text()
|
std::wstring text()
|
||||||
@@ -75,12 +164,34 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
return 0;
|
return 0;
|
||||||
if (msg == WM_TIMER)
|
if (msg == WM_TIMER)
|
||||||
{
|
{
|
||||||
|
// Input tests must not start a network update check. Its close veto can
|
||||||
|
// otherwise leave a completed test running after the editor is gone.
|
||||||
|
if (app->window) KillTimer(app->window, 5);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (GetTickCount64() > deadline)
|
if (GetTickCount64() > deadline)
|
||||||
throw std::runtime_error("Native integration timed out");
|
throw std::runtime_error("Native integration timed out");
|
||||||
if (app->busy)
|
if (app->busy)
|
||||||
|
{
|
||||||
|
if (typeDuringCorrection && ks::InputRegressionAccess::deferring(*app))
|
||||||
|
{
|
||||||
|
typeDuringCorrection = false;
|
||||||
|
typedDuringCorrection = true;
|
||||||
|
std::vector<INPUT> inputs;
|
||||||
|
for (auto [key, up] : std::vector<std::pair<WORD, bool>>{
|
||||||
|
{'F', false}, {'F', true}, {VK_LSHIFT, false},
|
||||||
|
{'F', false}, {'F', true}, {VK_LSHIFT, true},
|
||||||
|
{'B', false}, {'B', true}, {VK_BACK, false}, {VK_BACK, true}})
|
||||||
|
{
|
||||||
|
auto input = ks::keyboardInput(key, up ? KEYEVENTF_KEYUP : 0);
|
||||||
|
input.ki.dwExtraInfo = 123; // External input must traverse the actual hook.
|
||||||
|
inputs.push_back(input);
|
||||||
|
}
|
||||||
|
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||||
|
"concurrent typing submitted during selection/paste transaction");
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
|
}
|
||||||
switch (stage++)
|
switch (stage++)
|
||||||
{
|
{
|
||||||
case 0:
|
case 0:
|
||||||
@@ -104,10 +215,12 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
require(text() == L"Hello World", "case conversion");
|
require(text() == L"Hello World", "case conversion");
|
||||||
|
droppedSelectionKeys = 5;
|
||||||
app->transform(ks::Case);
|
app->transform(ks::Case);
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 4:
|
||||||
require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result");
|
require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result");
|
||||||
|
require(droppedSelectionKeys == 0, "partial suffix selection recovered after dropped arrow keys");
|
||||||
prepare(L"OLDghbdtn", false);
|
prepare(L"OLDghbdtn", false);
|
||||||
record(L"ghbdtn");
|
record(L"ghbdtn");
|
||||||
app->transform(ks::Direct + ru);
|
app->transform(ks::Direct + ru);
|
||||||
@@ -133,12 +246,20 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
"busy conversion attempt is logged");
|
"busy conversion attempt is logged");
|
||||||
require(diagnostics.find("repeat=3") != std::string::npos,
|
require(diagnostics.find("repeat=3") != std::string::npos,
|
||||||
"three repeated conversion attempts are logged");
|
"three repeated conversion attempts are logged");
|
||||||
require(diagnostics.find("reason=recent-verification-mismatch") != std::string::npos,
|
require(diagnostics.find("\"outcome\":\"recent-verification-mismatch\"") != std::string::npos,
|
||||||
"failed recent-word verification has explicit outcome");
|
"failed recent-word verification has explicit outcome");
|
||||||
require(diagnostics.find("copied=\"abcdef\"") != std::string::npos &&
|
require(diagnostics.find("\"original\":\"abcdef\"") != std::string::npos &&
|
||||||
diagnostics.find("physicalKeys=\"ghbdtn\"") != std::string::npos,
|
diagnostics.find("physicalKeys=\\\"ghbdtn\\\"") != std::string::npos,
|
||||||
"conversion diagnostics contain actual text and physical input for reproduction");
|
"conversion diagnostics contain actual text and physical input for reproduction");
|
||||||
}
|
}
|
||||||
|
if (conversionOnly)
|
||||||
|
{
|
||||||
|
pasteBehavior = 1;
|
||||||
|
prepare(L"ghbdtn", true);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
stage = 40;
|
||||||
|
break;
|
||||||
|
}
|
||||||
app->settings.snippets = {{L";addr", L"LINE1\r\n{cursor}LINE2"}};
|
app->settings.snippets = {{L";addr", L"LINE1\r\n{cursor}LINE2"}};
|
||||||
prepare(L"prefix ;addr", false);
|
prepare(L"prefix ;addr", false);
|
||||||
record(L";addr");
|
record(L";addr");
|
||||||
@@ -196,6 +317,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
DestroyWindow(field);
|
DestroyWindow(field);
|
||||||
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_PASSWORD, 20, 20,
|
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_PASSWORD, 20, 20,
|
||||||
590, 30, host, nullptr, GetModuleHandleW(nullptr), nullptr);
|
590, 30, host, nullptr, GetModuleHandleW(nullptr), nullptr);
|
||||||
|
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||||
prepare(L"ghbdtn ", false);
|
prepare(L"ghbdtn ", false);
|
||||||
record(L"ghbdtn ");
|
record(L"ghbdtn ");
|
||||||
app->transform(ks::AutoCorrect);
|
app->transform(ks::AutoCorrect);
|
||||||
@@ -207,6 +329,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
DestroyWindow(field);
|
DestroyWindow(field);
|
||||||
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER, 20, 20, 590, 30, host,
|
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER, 20, 20, 590, 30, host,
|
||||||
nullptr, GetModuleHandleW(nullptr), nullptr);
|
nullptr, GetModuleHandleW(nullptr), nullptr);
|
||||||
|
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||||
prepare(L"(ljvf ", false);
|
prepare(L"(ljvf ", false);
|
||||||
record(L"(ljvf ");
|
record(L"(ljvf ");
|
||||||
app->transform(ks::AutoCorrect);
|
app->transform(ks::AutoCorrect);
|
||||||
@@ -242,9 +365,334 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
break;
|
break;
|
||||||
case 17:
|
case 17:
|
||||||
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
|
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
|
||||||
|
stage = 20;
|
||||||
|
break;
|
||||||
|
case 40:
|
||||||
|
{
|
||||||
|
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||||||
|
require(text() == L"ghbdtn" && rows.back().outcome == L"Original text remained",
|
||||||
|
"blocked paste records original text remaining in the actual editor");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "readback preserves clipboard after blocked paste");
|
||||||
|
require(!ks::InputRegressionAccess::hasLast(*app), "failed paste does not retain a fictional previous result");
|
||||||
|
pasteBehavior = 2;
|
||||||
|
prepare(L"ghbdtn", true);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 41:
|
||||||
|
{
|
||||||
|
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||||||
|
require(text() == L"other!" && rows.back().outcome == L"Readback differs",
|
||||||
|
"unexpected editor content records readback mismatch");
|
||||||
|
require(!ks::InputRegressionAccess::hasLast(*app), "changed readback cannot seed repeat conversion");
|
||||||
|
pasteBehavior = 0;
|
||||||
|
prepare(L"привет", true);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 42:
|
||||||
|
{
|
||||||
|
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||||||
|
require(text() == L"привет" && rows.back().outcome == L"Conversion unchanged" && rows.back().problem,
|
||||||
|
"manual unchanged conversion is recorded as a problem");
|
||||||
|
diagnosticStart = diagnosticLog().size();
|
||||||
|
app->settings.writeLog = false;
|
||||||
|
ks::setLoggingEnabled(false);
|
||||||
|
prepare(L"ghbdtn", true);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 43:
|
||||||
|
require(text() == L"привет" && diagnosticLog().size() == diagnosticStart,
|
||||||
|
"disabled diagnostics preserves conversion and writes no records");
|
||||||
|
ks::setLoggingEnabled(true);
|
||||||
|
prepare(L"PuntoSwitcher ", true);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
case 44:
|
||||||
|
app->transform(ks::Direct + en);
|
||||||
|
break;
|
||||||
|
case 45:
|
||||||
|
require(text() == L"PuntoSwitcher ", "previous conversion restores CamelCase and trailing space");
|
||||||
|
droppedSelectionKeys = 5;
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
case 46:
|
||||||
|
require(text() == ks::convertTo(L"PuntoSwitcher ", app->layouts, ru, en),
|
||||||
|
"repeat conversion recovers Switcher suffix and preserves full result");
|
||||||
|
require(droppedSelectionKeys == 0, "CamelCase suffix regression dropped five selection keys");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel,
|
||||||
|
"suffix recovery restores original clipboard");
|
||||||
|
// Derive the actual text from the physical keys, preserving an older prefix.
|
||||||
|
prepare((L"OLD" + ks::convertTo(L"snipping ", app->layouts, ru, en)).c_str(), false);
|
||||||
|
record(L"snipping ");
|
||||||
|
droppedSelectionKeys = 4;
|
||||||
|
app->transform(ks::Direct + en);
|
||||||
|
break;
|
||||||
|
case 47:
|
||||||
|
require(text() == L"OLDsnipping ", "recent Cyrillic suffix recovery preserves older prefix");
|
||||||
|
require(droppedSelectionKeys == 0, "recent suffix regression dropped four selection keys");
|
||||||
|
app->settings.writeLog = true;
|
||||||
|
prepare(L"prefix wrong ", false);
|
||||||
|
record(L"other ");
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
break;
|
||||||
|
case 48:
|
||||||
|
{
|
||||||
|
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||||||
|
require(text() == L"prefix wrong " && !rows.empty() &&
|
||||||
|
rows.back().outcome == L"recent-verification-mismatch",
|
||||||
|
"diagnostic mismatch does not replace unverified text");
|
||||||
|
auto details = rows.back().details;
|
||||||
|
require(details.find(L"selection-mismatch") != details.npos &&
|
||||||
|
details.find(L"Expected selection:\r\nother ") != details.npos &&
|
||||||
|
details.find(L"Read back:\r\nwrong ") != details.npos,
|
||||||
|
"failed verification includes expected and copied text in exported details");
|
||||||
|
require(details.find(L"Copy begin number=") != details.npos &&
|
||||||
|
details.find(L"Copy result number=") != details.npos &&
|
||||||
|
details.find(L"Selection initial match=0") != details.npos &&
|
||||||
|
details.find(L"SelectPrevious") != details.npos,
|
||||||
|
"incident includes clipboard and selection stages");
|
||||||
|
pasteBehavior = 3;
|
||||||
|
prepare(L"=nhtdjuf", true);
|
||||||
|
app->transform(ks::Direct + ru);
|
||||||
|
stage = 49;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 49:
|
||||||
|
{
|
||||||
|
require(text() == L"=тревога" && pasteBehavior == 0,
|
||||||
|
"native Edit retries one ignored paste only while original remains selected");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "native paste retry restores clipboard");
|
||||||
finish();
|
finish();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 30:
|
||||||
|
case 32:
|
||||||
|
case 34:
|
||||||
|
case 37:
|
||||||
|
// Earlier password/word tests replace the original multiline
|
||||||
|
// editor with a single-line EDIT. Restore the snippet fixture:
|
||||||
|
// a single-line control cannot insert the tested Enter action.
|
||||||
|
if (!(GetWindowLongPtrW(field, GWL_STYLE) & ES_MULTILINE))
|
||||||
|
{
|
||||||
|
DestroyWindow(field);
|
||||||
|
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER |
|
||||||
|
ES_MULTILINE | ES_WANTRETURN, 20, 20, 590, 230, host, nullptr,
|
||||||
|
GetModuleHandleW(nullptr), nullptr);
|
||||||
|
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||||
|
}
|
||||||
|
if (!captured)
|
||||||
|
{
|
||||||
|
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
||||||
|
captured = true;
|
||||||
|
}
|
||||||
|
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
|
||||||
|
app->settings.autoCorrect = stage == 35;
|
||||||
|
app->settings.autoLanguages = {L"en", L"ru"};
|
||||||
|
app->settings.cycle = {app->layouts[en].id, app->layouts[ru].id};
|
||||||
|
app->settings.switchLayout = false;
|
||||||
|
app->autoCorrector = ks::AutoCorrector{};
|
||||||
|
app->autoPredictor = ks::AutoCorrector{};
|
||||||
|
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||||
|
app->settings.snippets = {{L";r", stage == 31 ? L"rider .{cursor}" : L"rider .{cursor}{enter}"}};
|
||||||
|
enterPresses = 0;
|
||||||
|
copyRequests = 0;
|
||||||
|
copyFailuresRemaining = stage == 38 ? 10 : stage == 33 ? 1 : 0;
|
||||||
|
temporaryClipboardExcluded = true;
|
||||||
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
|
prepare(L"prefix ", false);
|
||||||
|
{
|
||||||
|
std::vector<INPUT> inputs;
|
||||||
|
const std::vector<WORD> keys = stage == 35
|
||||||
|
? std::vector<WORD>{VK_OEM_1, 'R', 'G', 'H', 'B', 'D', 'T', 'N', VK_SPACE}
|
||||||
|
: std::vector<WORD>{VK_OEM_1, 'R', VK_SPACE, 'X'};
|
||||||
|
for (WORD key : keys)
|
||||||
|
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||||||
|
{
|
||||||
|
auto input = ks::keyboardInput(key, flags);
|
||||||
|
input.ki.dwExtraInfo = 123;
|
||||||
|
inputs.push_back(input);
|
||||||
|
}
|
||||||
|
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||||
|
"snippet trigger and following text submitted without a pause");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 31:
|
||||||
|
require(text() == L"prefix rider . x", "snippet expands through keyboard hook and preserves following typing");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "hook snippet restores clipboard");
|
||||||
|
require(enterPresses == 0, "snippet without Enter marker sends no Enter");
|
||||||
|
stage = 32;
|
||||||
|
break;
|
||||||
|
case 33:
|
||||||
|
require(text() == L"prefix rider .\r\n x", "Enter action precedes buffered typing");
|
||||||
|
require(copyRequests == 2 && copyFailuresRemaining == 0,
|
||||||
|
"snippet retries a silently dropped copy without duplicating input");
|
||||||
|
require(temporaryClipboardExcluded,
|
||||||
|
"temporary snippet clipboard contents are excluded from Windows history");
|
||||||
|
require(enterPresses == 1, "Enter marker sends an actual Enter key press");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "Enter snippet restores clipboard");
|
||||||
|
stage = 34;
|
||||||
|
break;
|
||||||
|
case 35:
|
||||||
|
require(text() == L"prefix rider .\r\nghbdtn ", "snippet replay does not trigger automatic correction");
|
||||||
|
require(app->settings.autoCorrect, "snippet preserves global auto-correction setting");
|
||||||
|
require(enterPresses == 1, "snippet punctuation and Enter survive with auto-correction enabled");
|
||||||
|
{
|
||||||
|
std::vector<INPUT> inputs;
|
||||||
|
for (WORD key : std::wstring(L"GHBDTN "))
|
||||||
|
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||||||
|
{
|
||||||
|
auto input = ks::keyboardInput(key == L' ' ? VK_SPACE : key, flags);
|
||||||
|
input.ki.dwExtraInfo = 123;
|
||||||
|
inputs.push_back(input);
|
||||||
|
}
|
||||||
|
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||||
|
"normal typing submitted after snippet completes");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 36:
|
||||||
|
require(text() == L"prefix rider .\r\nghbdtn привет ", "automatic correction resumes for subsequent normal typing");
|
||||||
|
stage = 37;
|
||||||
|
break;
|
||||||
|
case 38:
|
||||||
|
require(copyRequests == 3, "copy retries are bounded to three attempts");
|
||||||
|
require(text() == L"prefix ;r x" && enterPresses == 0,
|
||||||
|
"exhausted copy retries preserve trigger and following input without Enter");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel,
|
||||||
|
"exhausted copy retries restore the clipboard");
|
||||||
|
copyFailuresRemaining = 0;
|
||||||
|
finish();
|
||||||
|
break;
|
||||||
|
case 20:
|
||||||
|
if (!captured)
|
||||||
|
{
|
||||||
|
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
||||||
|
captured = true;
|
||||||
|
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
|
||||||
|
}
|
||||||
|
app->settings.autoCorrect = true;
|
||||||
|
app->settings.switchLayout = true;
|
||||||
|
app->settings.autoLanguages = {L"en", L"ru"};
|
||||||
|
app->settings.cycle = {app->layouts[en].id, app->layouts[ru].id};
|
||||||
|
app->autoCorrector = ks::AutoCorrector{};
|
||||||
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
|
prepare(L"ghbdtn ", false);
|
||||||
|
record(L"ghbdtn ");
|
||||||
|
typeDuringCorrection = true;
|
||||||
|
app->transform(ks::AutoCorrect);
|
||||||
|
stage = 18;
|
||||||
|
break;
|
||||||
|
case 18:
|
||||||
|
require(typedDuringCorrection && text() == L"привет аА",
|
||||||
|
"typing during correction replays in order with shift and backspace in new layout");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel,
|
||||||
|
"concurrent typing preserves clipboard restoration");
|
||||||
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
|
prepare(L"OLDghbdtn ", false);
|
||||||
|
record(L"ghbdtn ");
|
||||||
|
typedDuringCorrection = false;
|
||||||
|
typeDuringCorrection = true;
|
||||||
|
app->transform(ks::AutoCorrect);
|
||||||
|
break;
|
||||||
|
case 19:
|
||||||
|
require(typedDuringCorrection && text() == L"OLDghbdtn fF",
|
||||||
|
"rejected correction collapses selection before replaying typing in original layout");
|
||||||
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
|
prepare(L"", false);
|
||||||
|
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||||
|
{
|
||||||
|
std::vector<INPUT> inputs;
|
||||||
|
for (WORD key : std::wstring(L"GHBDTN F"))
|
||||||
|
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||||||
|
{
|
||||||
|
auto input = ks::keyboardInput(key == L' ' ? VK_SPACE : key, flags);
|
||||||
|
input.ki.dwExtraInfo = 123;
|
||||||
|
inputs.push_back(input);
|
||||||
|
}
|
||||||
|
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||||
|
"continuous typing submitted in a single batch without a pause after Space");
|
||||||
|
}
|
||||||
|
stage = 21;
|
||||||
|
break;
|
||||||
|
case 21:
|
||||||
|
{
|
||||||
|
require(text() == L"привет а", "automatic correction preserves immediate next-word typing without a pause");
|
||||||
|
auto uk = std::find_if(app->layouts.begin(), app->layouts.end(), [](const auto &layout) {
|
||||||
|
return ks::layoutLanguage(layout) == L"uk";
|
||||||
|
});
|
||||||
|
require(uk != app->layouts.end(), "Ukrainian layout available for punctuation-key regression");
|
||||||
|
app->settings.autoLanguages = {L"en", L"uk"};
|
||||||
|
app->settings.cycle = {app->layouts[en].id, uk->id};
|
||||||
|
app->settings.autoWords[L"uk"] = {L"робить"};
|
||||||
|
app->autoCorrector = ks::AutoCorrector{};
|
||||||
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
|
prepare(L"hj,bnm ", false);
|
||||||
|
record(L"hj,bnm ");
|
||||||
|
app->transform(ks::AutoCorrect);
|
||||||
|
stage = 22;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 22:
|
||||||
|
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
|
||||||
|
app->settings.autoReplace = true;
|
||||||
|
app->settings.autoReplacements = {{L"helo", L"hello"}};
|
||||||
|
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||||
|
prepare(L"(Helo)! ", false);
|
||||||
|
record(L"(Helo)! ");
|
||||||
|
app->transform(ks::AutoCorrect);
|
||||||
|
break;
|
||||||
|
case 23:
|
||||||
|
require(text() == L"(Hello)! ", "explicit typo pair replaces verified text with punctuation and capital");
|
||||||
|
app->transform(ks::Undo);
|
||||||
|
break;
|
||||||
|
case 24:
|
||||||
|
require(text() == L"(Helo)! ", "Undo restores explicit typo replacement");
|
||||||
|
app->settings.autoReplace = false;
|
||||||
|
app->settings.autoTypos = true;
|
||||||
|
app->settings.autoWords[L"en"].push_back(L"kswitcher");
|
||||||
|
// Use a personal target to avoid bundled dictionary ambiguity.
|
||||||
|
prepare(L"kswithcer ", false);
|
||||||
|
record(L"kswithcer ");
|
||||||
|
app->transform(ks::AutoCorrect);
|
||||||
|
break;
|
||||||
|
case 25:
|
||||||
|
require(text() == L"kswitcher ", "dictionary typo correction replaces text in the current layout");
|
||||||
|
require(ks::readClipboard(app->window).text() == sentinel, "typo correction restores clipboard");
|
||||||
|
app->transform(ks::Undo);
|
||||||
|
break;
|
||||||
|
case 26:
|
||||||
|
require(text() == L"kswithcer ", "Undo restores dictionary typo correction");
|
||||||
|
record(L"kswithcer ");
|
||||||
|
app->transform(ks::AutoCorrect);
|
||||||
|
break;
|
||||||
|
case 27:
|
||||||
|
require(text() == L"kswithcer ", "Undo suppresses the rejected spelling correction");
|
||||||
|
app->settings.autoReplace = true;
|
||||||
|
app->settings.autoReplacements = {{L"tehh", L"the"}};
|
||||||
|
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||||||
|
prepare(L"", false);
|
||||||
|
{
|
||||||
|
std::vector<INPUT> inputs;
|
||||||
|
for (WORD key : std::wstring(L"TEHH X"))
|
||||||
|
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||||||
|
{
|
||||||
|
auto input = ks::keyboardInput(key == L' ' ? VK_SPACE : key, flags);
|
||||||
|
input.ki.dwExtraInfo = 123;
|
||||||
|
inputs.push_back(input);
|
||||||
|
}
|
||||||
|
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||||||
|
"custom typo and following word submitted without a pause");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 28:
|
||||||
|
require(text() == L"the x", "custom replacement through keyboard hook preserves subsequent typing");
|
||||||
|
app->settings.autoReplace = false;
|
||||||
|
app->settings.autoTypos = false;
|
||||||
|
if (typosOnly) { finish(); break; }
|
||||||
|
stage = 30;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
catch (const std::exception &e)
|
||||||
{
|
{
|
||||||
@@ -265,6 +713,8 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
} // namespace
|
} // namespace
|
||||||
int main(int argc, char **argv)
|
int main(int argc, char **argv)
|
||||||
{
|
{
|
||||||
|
conversionOnly = argc > 1 && std::string(argv[1]) == "--conversion-only";
|
||||||
|
typosOnly = argc > 1 && std::string(argv[1]) == "--typos-only";
|
||||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||||
auto root = std::filesystem::temp_directory_path() /
|
auto root = std::filesystem::temp_directory_path() /
|
||||||
(L"KSwitcher-integration-" + std::to_wstring(GetCurrentProcessId()));
|
(L"KSwitcher-integration-" + std::to_wstring(GetCurrentProcessId()));
|
||||||
@@ -280,9 +730,22 @@ int main(int argc, char **argv)
|
|||||||
ks::showLogs(instance);
|
ks::showLogs(instance);
|
||||||
HWND viewer = instance.logWindow;
|
HWND viewer = instance.logWindow;
|
||||||
HWND rows = GetDlgItem(viewer, 602);
|
HWND rows = GetDlgItem(viewer, 602);
|
||||||
|
if (argc > 2 && std::string(argv[2]) == "--preview")
|
||||||
|
{
|
||||||
|
MSG message{};
|
||||||
|
while (IsWindow(viewer) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
||||||
|
{
|
||||||
|
TranslateMessage(&message); DispatchMessageW(&message);
|
||||||
|
}
|
||||||
|
CoUninitialize();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
require(rows && (GetWindowLongPtrW(rows, GWL_STYLE) & LVS_TYPEMASK) == LVS_REPORT,
|
require(rows && (GetWindowLongPtrW(rows, GWL_STYLE) & LVS_TYPEMASK) == LVS_REPORT,
|
||||||
"log viewer uses report ListView");
|
"log viewer uses report ListView");
|
||||||
require(Header_GetItemCount(ListView_GetHeader(rows)) == 4, "log viewer has four columns");
|
require(Header_GetItemCount(ListView_GetHeader(rows)) == 6, "log viewer has six sortable columns");
|
||||||
|
require(GetDlgItem(viewer, 607) && GetDlgItem(viewer, 608) && GetDlgItem(viewer, 609) &&
|
||||||
|
GetDlgItem(viewer, 610) && GetDlgItem(viewer, 611),
|
||||||
|
"log viewer exposes program, command, result, problems filter and detail panel");
|
||||||
require(GetDlgItem(viewer, IDCANCEL) != rows, "log rows and Close have different IDs");
|
require(GetDlgItem(viewer, IDCANCEL) != rows, "log rows and Close have different IDs");
|
||||||
SetFocus(rows);
|
SetFocus(rows);
|
||||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, EN_SETFOCUS), (LPARAM)rows);
|
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, EN_SETFOCUS), (LPARAM)rows);
|
||||||
@@ -292,10 +755,11 @@ int main(int argc, char **argv)
|
|||||||
ks::showLogs(instance);
|
ks::showLogs(instance);
|
||||||
require(instance.logWindow == viewer, "opening logs reuses the existing window");
|
require(instance.logWindow == viewer, "opening logs reuses the existing window");
|
||||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 603));
|
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 603));
|
||||||
require(IsWindow(viewer) && ListView_GetItemCount(rows) > 0, "refresh populates log rows");
|
require(IsWindow(viewer), "refresh keeps log viewer open");
|
||||||
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 1, 0);
|
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 1, 0);
|
||||||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(601, CBN_SELCHANGE), (LPARAM)GetDlgItem(viewer, 601));
|
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(601, CBN_SELCHANGE), (LPARAM)GetDlgItem(viewer, 601));
|
||||||
require(IsWindow(viewer) && ListView_GetItemCount(rows) > 0, "previous log or empty state is displayed");
|
require(IsWindow(viewer) && GetWindowTextLengthW(GetDlgItem(viewer, 611)) > 0,
|
||||||
|
"previous log or explicit empty state is displayed");
|
||||||
if (argc > 2 && std::string(argv[2]) == "--clear-test")
|
if (argc > 2 && std::string(argv[2]) == "--clear-test")
|
||||||
{
|
{
|
||||||
require(std::filesystem::path(ks::executable()).parent_path().filename() == L"log-clear-test",
|
require(std::filesystem::path(ks::executable()).parent_path().filename() == L"log-clear-test",
|
||||||
@@ -320,7 +784,46 @@ int main(int argc, char **argv)
|
|||||||
require(newLog.find(L"new-problem") != std::wstring::npos &&
|
require(newLog.find(L"new-problem") != std::wstring::npos &&
|
||||||
newLog.find(L"buffered-before-clear") == std::wstring::npos,
|
newLog.find(L"buffered-before-clear") == std::wstring::npos,
|
||||||
"logging resumes after clear without stale context");
|
"logging resumes after clear without stale context");
|
||||||
|
ks::DiagnosticJson operation{{"attempt", "1"}, {"process", "alpha.exe"},
|
||||||
|
{"command", "Convert / cycle"}, {"source", "English"}, {"target", "Ukrainian"},
|
||||||
|
{"outcome", "Paste verified"}, {"problem", false}, {"original", "ghbdsn"},
|
||||||
|
{"expected", ks::utf8(L"привіт")}, {"actual", ks::utf8(L"привіт")}};
|
||||||
|
ks::clearDiagnosticLog(false);
|
||||||
|
const auto successfulOperation = operation;
|
||||||
|
for (int attempt = 0; attempt < 1000; ++attempt)
|
||||||
|
ks::diagnosticOperation(operation.dump());
|
||||||
|
require(!std::filesystem::exists(logRoot / L"kswitcher.log"),
|
||||||
|
"one thousand successful operations create no log file");
|
||||||
|
operation["attempt"] = "2"; operation["process"] = "beta.exe";
|
||||||
|
operation["outcome"] = "Original text remained"; operation["problem"] = true;
|
||||||
|
operation["actual"] = operation["original"];
|
||||||
|
ks::diagnosticOperation(operation.dump());
|
||||||
|
auto problems = ks::parseOperationLog(ks::readDiagnosticLog(false));
|
||||||
|
require(problems.size() == 1 && problems[0].process == L"beta.exe" &&
|
||||||
|
problems[0].details.find(L"Completed operation") != std::wstring::npos &&
|
||||||
|
problems[0].details.find(L"Paste verified") != std::wstring::npos,
|
||||||
|
"failure writes one row with bounded preceding successful context");
|
||||||
|
// Pre-existing successful records remain viewable and filterable.
|
||||||
|
ks::log(L"Operation " + ks::wide(successfulOperation.dump()));
|
||||||
|
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), 0);
|
||||||
|
require(ListView_GetItemCount(rows) == 2, "one visible row per recorded operation");
|
||||||
|
CheckDlgButton(viewer, 610, BST_CHECKED);
|
||||||
|
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(610, BN_CLICKED), 0);
|
||||||
|
require(ListView_GetItemCount(rows) == 1, "only problems hides verified operations");
|
||||||
|
SendDlgItemMessageW(viewer, 607, CB_SETCURSEL, 1, 0);
|
||||||
|
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(607, CBN_SELCHANGE), 0);
|
||||||
|
require(ListView_GetItemCount(rows) == 0, "program filter combines with problems filter");
|
||||||
|
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(612, BN_CLICKED), 0);
|
||||||
|
require(ListView_GetItemCount(rows) == 2, "reset restores all operation rows");
|
||||||
|
NMLISTVIEW sort{}; sort.hdr = {rows, 602, LVN_COLUMNCLICK}; sort.iSubItem = 1;
|
||||||
|
SendMessageW(viewer, WM_NOTIFY, 602, (LPARAM)&sort);
|
||||||
|
wchar_t firstProgram[64]{};
|
||||||
|
ListView_GetItemText(rows, 0, 1, firstProgram, 64);
|
||||||
|
require(std::wstring(firstProgram) == L"alpha.exe", "clicking program column sorts rows");
|
||||||
|
require(GetWindowTextLengthW(GetDlgItem(viewer, 611)) > 0, "selected operation populates detail panel");
|
||||||
}
|
}
|
||||||
|
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 0, 0);
|
||||||
|
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), 0);
|
||||||
auto savedClipboard = ks::readClipboard(viewer);
|
auto savedClipboard = ks::readClipboard(viewer);
|
||||||
instance.window = viewer;
|
instance.window = viewer;
|
||||||
try
|
try
|
||||||
@@ -366,6 +869,11 @@ int main(int argc, char **argv)
|
|||||||
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
|
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
|
||||||
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
|
||||||
deadline = GetTickCount64() + 30000;
|
deadline = GetTickCount64() + 30000;
|
||||||
|
SetWindowSubclass(field, observeEnter, 1, 0);
|
||||||
|
if (typosOnly || (argc > 1 && std::string(argv[1]) == "--typing-only"))
|
||||||
|
stage = 20;
|
||||||
|
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
|
||||||
|
stage = 30;
|
||||||
SetTimer(host, 1, 100, nullptr);
|
SetTimer(host, 1, 100, nullptr);
|
||||||
app->execute();
|
app->execute();
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-4
@@ -1,7 +1,14 @@
|
|||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
|
#include "app.h"
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
int main()
|
namespace ks {
|
||||||
|
struct InputRegressionAccess {
|
||||||
|
static void scheduleOcr(App &app) { app.scheduleHistoryOcr(app.settings); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
int main(int argc, char **argv)
|
||||||
{
|
{
|
||||||
|
const bool tesseract = argc == 2 && std::string(argv[1]) == "--tesseract";
|
||||||
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -24,7 +31,8 @@ int main()
|
|||||||
auto oldFont = SelectObject(dc, font);
|
auto oldFont = SelectObject(dc, font);
|
||||||
SetTextColor(dc, RGB(0, 0, 0));
|
SetTextColor(dc, RGB(0, 0, 0));
|
||||||
SetBkColor(dc, RGB(255, 255, 255));
|
SetBkColor(dc, RGB(255, 255, 255));
|
||||||
TextOutW(dc, 20, 40, L"HELLO KSWITCHER", 15);
|
std::wstring fixture = tesseract ? L"Привіт Україно HELLO" : L"HELLO KSWITCHER";
|
||||||
|
TextOutW(dc, 20, 40, fixture.c_str(), (int)fixture.size());
|
||||||
GdiFlush();
|
GdiFlush();
|
||||||
ks::Pixels pixels{800, 150, ks::Bytes((BYTE *)data, (BYTE *)data + 800 * 150 * 4)};
|
ks::Pixels pixels{800, 150, ks::Bytes((BYTE *)data, (BYTE *)data + 800 * 150 * 4)};
|
||||||
SelectObject(dc, oldFont);
|
SelectObject(dc, oldFont);
|
||||||
@@ -36,11 +44,45 @@ int main()
|
|||||||
auto decoded = ks::decodeImage(clip);
|
auto decoded = ks::decodeImage(clip);
|
||||||
if (decoded.width != 800 || decoded.height != 150)
|
if (decoded.width != 800 || decoded.height != 150)
|
||||||
throw std::runtime_error("WIC decode size mismatch");
|
throw std::runtime_error("WIC decode size mismatch");
|
||||||
auto text = ks::recognizeText(clip);
|
ks::Settings config;
|
||||||
|
config.useTesseract = tesseract;
|
||||||
|
auto text = ks::recognizeText(clip, config);
|
||||||
std::cout << "OCR: " << ks::utf8(text) << '\n';
|
std::cout << "OCR: " << ks::utf8(text) << '\n';
|
||||||
if (ks::upper(text).find(L"HELLO") == std::wstring::npos)
|
if (ks::upper(text).find(L"HELLO") == std::wstring::npos)
|
||||||
throw std::runtime_error("OCR fixture was not recognized");
|
throw std::runtime_error("OCR fixture was not recognized");
|
||||||
std::cout << "PASS native Windows OCR, DIB decoding and async result retrieval\n";
|
if (tesseract && ks::lower(text).find(L"привіт") == std::wstring::npos)
|
||||||
|
throw std::runtime_error("Ukrainian OCR fixture was not recognized");
|
||||||
|
auto temp = std::filesystem::temp_directory_path() / (L"KSwitcher-auto-ocr-" + std::to_wstring(GetCurrentProcessId()));
|
||||||
|
auto sequence = GetClipboardSequenceNumber();
|
||||||
|
{
|
||||||
|
ks::App app(GetModuleHandleW(nullptr), true, temp);
|
||||||
|
app.settings.history = true;
|
||||||
|
app.history->add(clip, L"OCR fixture", app.settings);
|
||||||
|
auto id = app.history->entries().front().id;
|
||||||
|
if (tesseract)
|
||||||
|
{
|
||||||
|
app.history->saveOcr(id, L"old Windows result", app.settings);
|
||||||
|
app.settings.useTesseract = true;
|
||||||
|
}
|
||||||
|
ks::InputRegressionAccess::scheduleOcr(app);
|
||||||
|
auto deadline = GetTickCount64() + 35000;
|
||||||
|
while ((app.history->entries().front().ocrState == 1 ||
|
||||||
|
app.history->entries().front().ocrProfile != ks::ocrProfile(app.settings)) &&
|
||||||
|
GetTickCount64() < deadline) Sleep(20);
|
||||||
|
if (app.history->search(L"HELLO").size() != 1)
|
||||||
|
throw std::runtime_error("Background OCR did not make the image searchable");
|
||||||
|
ks::History restored(temp / L"history");
|
||||||
|
if (!restored.ocrText(id) || restored.search(L"HELLO").size() != 1)
|
||||||
|
throw std::runtime_error("Background OCR did not persist its result");
|
||||||
|
if (tesseract && (restored.search(L"привіт").size() != 1 || !restored.search(L"old Windows result").empty()))
|
||||||
|
throw std::runtime_error("Engine change did not replace cached Windows OCR with Ukrainian text");
|
||||||
|
if (GetClipboardSequenceNumber() != sequence)
|
||||||
|
throw std::runtime_error("Background OCR changed the clipboard");
|
||||||
|
std::cout << "PASS automatic background OCR, persisted search and unchanged clipboard\n";
|
||||||
|
}
|
||||||
|
std::filesystem::remove_all(temp);
|
||||||
|
std::cout << (tesseract ? "PASS optional Tesseract, mixed Ukrainian/English and cache migration\n" :
|
||||||
|
"PASS native Windows OCR, DIB decoding and async result retrieval\n");
|
||||||
CoUninitialize();
|
CoUninitialize();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
param([string]$Executable = "$PSScriptRoot\..\build\staged\KSwitcher.Screenshot.exe")
|
||||||
|
$ErrorActionPreference='Stop'
|
||||||
|
if (Get-Process KSwitcher.Screenshot -ErrorAction SilentlyContinue) { throw 'Close existing screenshot editors before this test.' }
|
||||||
|
Add-Type @'
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
public static class ScreenshotProcessTest {
|
||||||
|
public delegate bool EnumProc(IntPtr w, IntPtr p);
|
||||||
|
[DllImport("user32.dll")] static extern bool EnumWindows(EnumProc callback, IntPtr param);
|
||||||
|
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr w, out uint pid);
|
||||||
|
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr w, StringBuilder name, int count);
|
||||||
|
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetWindowText(IntPtr w, StringBuilder name, int count);
|
||||||
|
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w, uint msg, IntPtr wp, IntPtr lp);
|
||||||
|
public static IntPtr[] Windows(int process, string cls) {
|
||||||
|
var windows=new List<IntPtr>();
|
||||||
|
EnumWindows((w,p)=>{ uint pid; GetWindowThreadProcessId(w,out pid);
|
||||||
|
var name=new StringBuilder(256); GetClassName(w,name,256);
|
||||||
|
if(pid==process && name.ToString()==cls) windows.Add(w); return true;
|
||||||
|
},IntPtr.Zero); return windows.ToArray();
|
||||||
|
}
|
||||||
|
public static string Title(IntPtr w) { var s=new StringBuilder(256); GetWindowText(w,s,256); return s.ToString(); }
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
function Wait-For([scriptblock]$Check,[string]$Message) {
|
||||||
|
for($attempt=0;$attempt -lt 100;$attempt++) { if (& $Check) { return }; Start-Sleep -Milliseconds 50 }
|
||||||
|
throw $Message
|
||||||
|
}
|
||||||
|
function Send($Window,$Message,$Wparam=0,$Lparam=0) {
|
||||||
|
[ScreenshotProcessTest]::PostMessage($Window,$Message,[IntPtr]$Wparam,[IntPtr]$Lparam) | Out-Null
|
||||||
|
}
|
||||||
|
function Capture-Windows { @([ScreenshotProcessTest]::Windows($script:process.Id,'KSwitcher.Capture')) }
|
||||||
|
function Select-Region($Window) {
|
||||||
|
Send $Window 0x201 1 ((40 -shl 16) -bor 40)
|
||||||
|
Send $Window 0x200 1 ((240 -shl 16) -bor 360)
|
||||||
|
Send $Window 0x202 0 ((240 -shl 16) -bor 360)
|
||||||
|
Wait-For { (Capture-Windows) -notcontains $Window } 'Selection did not close after copying (notifications may be disabled).'
|
||||||
|
}
|
||||||
|
function Close-Editor($Window) {
|
||||||
|
Send $Window 0x10
|
||||||
|
Wait-For { (Capture-Windows) -notcontains $Window } 'Unedited copied screenshot did not close.'
|
||||||
|
}
|
||||||
|
function Latest-Token {
|
||||||
|
(Get-ChildItem -LiteralPath (Join-Path $env:LOCALAPPDATA 'KSwitcher/ScreenshotNotifications') -Filter '*.capture' | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1).BaseName
|
||||||
|
}
|
||||||
|
$process=$null
|
||||||
|
try {
|
||||||
|
$process=Start-Process -FilePath $Executable -ArgumentList @("--auto-open-editor=0") -WindowStyle Hidden -PassThru
|
||||||
|
Wait-For { (Capture-Windows).Count -eq 1 } 'First selection did not open.'
|
||||||
|
Select-Region (Capture-Windows)[0]
|
||||||
|
if (!$process.WaitForExit(5000)) { throw 'Process remained alive after notification.' }
|
||||||
|
$token=Latest-Token
|
||||||
|
$process=Start-Process -FilePath $Executable -ArgumentList @('--open',"kswitcher-screenshot:$token") -WindowStyle Hidden -PassThru
|
||||||
|
Wait-For { (Capture-Windows).Count -eq 1 } 'Notification activation did not open an editor.'
|
||||||
|
$first=(Capture-Windows)[0]
|
||||||
|
$secondLaunch=Start-Process -FilePath $Executable -ArgumentList @('--open',"kswitcher-screenshot:$token") -WindowStyle Hidden -PassThru
|
||||||
|
if (!$secondLaunch.WaitForExit(10000) -or $secondLaunch.ExitCode -ne 0) { throw 'Second activation did not hand off.' }
|
||||||
|
Wait-For { (Capture-Windows).Count -eq 2 } 'Second editor did not open in same process.'
|
||||||
|
$second=(Capture-Windows | Where-Object { $_ -ne $first })[0]
|
||||||
|
if ([ScreenshotProcessTest]::Title($first) -eq [ScreenshotProcessTest]::Title($second)) { throw 'Editor indices collide.' }
|
||||||
|
Close-Editor $first
|
||||||
|
if ($process.HasExited) { throw 'Process exited with an editor open.' }
|
||||||
|
Close-Editor $second
|
||||||
|
if (!$process.WaitForExit(5000) -or $process.ExitCode -ne 0) { throw 'Last editor did not exit process.' }
|
||||||
|
$process=Start-Process -FilePath $Executable -ArgumentList @("--auto-open-editor=0") -WindowStyle Hidden -PassThru
|
||||||
|
Wait-For { (Capture-Windows).Count -eq 1 } 'New process did not open selection.'
|
||||||
|
Send (Capture-Windows)[0] 0x100 27 # Esc cancels a selection only.
|
||||||
|
if (!$process.WaitForExit(5000) -or $process.ExitCode -ne 0) { throw 'Cancelling selection did not exit process.' }
|
||||||
|
'Passed screenshot process lifecycle checks'
|
||||||
|
} finally {
|
||||||
|
if ($process -and !$process.HasExited) { Stop-Process -Id $process.Id }
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Redirect HKCU and the shortcut destination; never replace the user's registration.
|
||||||
|
#include "../src/screenshot_notifications.h"
|
||||||
|
#include <shlobj.h>
|
||||||
|
#include <iostream>
|
||||||
|
static std::wstring programs;
|
||||||
|
static std::wstring activationExecutable, activationCommand;
|
||||||
|
static int activationCalls{};
|
||||||
|
static BOOL WINAPI testCreateProcess(LPCWSTR path, LPWSTR command, LPSECURITY_ATTRIBUTES,
|
||||||
|
LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, LPCWSTR, LPSTARTUPINFOW, LPPROCESS_INFORMATION)
|
||||||
|
{
|
||||||
|
++activationCalls; activationExecutable=path; activationCommand=command;
|
||||||
|
SetLastError(ERROR_CANCELLED); return FALSE; // Inspect activation without starting any program.
|
||||||
|
}
|
||||||
|
static HRESULT testKnownFolder(REFKNOWNFOLDERID, DWORD, HANDLE, PWSTR *out)
|
||||||
|
{
|
||||||
|
*out=(PWSTR)CoTaskMemAlloc((programs.size()+1)*sizeof(wchar_t));
|
||||||
|
if(!*out) return E_OUTOFMEMORY;
|
||||||
|
wcscpy(*out,programs.c_str()); return S_OK;
|
||||||
|
}
|
||||||
|
#define SHGetKnownFolderPath testKnownFolder
|
||||||
|
#define CreateProcessW testCreateProcess
|
||||||
|
#include "../src/screenshot_activation.cpp"
|
||||||
|
#undef SHGetKnownFolderPath
|
||||||
|
#undef CreateProcessW
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||||
|
auto suffix=std::to_wstring(GetCurrentProcessId());
|
||||||
|
auto keyPath=L"Software\\KSwitcherRegistrationTest-"+suffix;
|
||||||
|
auto directory=std::filesystem::temp_directory_path()/(L"KSwitcherRegistrationTest-"+suffix);
|
||||||
|
std::filesystem::create_directories(directory); programs=directory.wstring();
|
||||||
|
HKEY isolated{}; int result=0;
|
||||||
|
try {
|
||||||
|
if(RegCreateKeyExW(HKEY_CURRENT_USER,keyPath.c_str(),0,nullptr,0,KEY_ALL_ACCESS,nullptr,&isolated,nullptr)!=ERROR_SUCCESS ||
|
||||||
|
RegOverridePredefKey(HKEY_CURRENT_USER,isolated)!=ERROR_SUCCESS) throw std::runtime_error("registry isolation failed");
|
||||||
|
ks::registerScreenshotNotifications();
|
||||||
|
wchar_t command[32768]{}; DWORD size=sizeof(command);
|
||||||
|
auto path=L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32";
|
||||||
|
if(RegGetValueW(HKEY_CURRENT_USER,path,nullptr,RRF_RT_REG_SZ,nullptr,command,&size)!=ERROR_SUCCESS)
|
||||||
|
throw std::runtime_error("COM server was not registered");
|
||||||
|
auto helper=std::filesystem::path(ks::executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
||||||
|
if(command!=L"\""+helper.wstring()+L"\" --toast-activated") throw std::runtime_error("COM server targets wrong executable");
|
||||||
|
Microsoft::WRL::ComPtr<IShellLinkW> link;
|
||||||
|
if(FAILED(CoCreateInstance(CLSID_ShellLink,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&link)))) throw std::runtime_error("read shortcut");
|
||||||
|
Microsoft::WRL::ComPtr<IPersistFile> file; link.As(&file);
|
||||||
|
if(FAILED(file->Load((directory/L"KSwitcher Screenshot.lnk").c_str(),STGM_READ))) throw std::runtime_error("shortcut missing");
|
||||||
|
wchar_t target[32768]{}; link->GetPath(target,32768,nullptr,SLGP_RAWPATH);
|
||||||
|
if(target!=helper.wstring()) throw std::runtime_error("shortcut targets wrong executable");
|
||||||
|
Microsoft::WRL::ComPtr<IPropertyStore> properties; link.As(&properties);
|
||||||
|
PROPVARIANT id{}; properties->GetValue(PKEY_AppUserModel_ID,&id);
|
||||||
|
bool valid=id.vt==VT_LPWSTR && std::wstring(id.pwszVal)==L"HrynCo.KSwitcher.Screenshot";
|
||||||
|
PropVariantClear(&id);
|
||||||
|
if(!valid) throw std::runtime_error("shortcut identity missing");
|
||||||
|
properties->GetValue(PKEY_AppUserModel_ToastActivatorCLSID,&id);
|
||||||
|
valid=id.vt==VT_CLSID && *id.puuid==ks::ActivatorId; PropVariantClear(&id);
|
||||||
|
if(!valid) throw std::runtime_error("shortcut activator missing");
|
||||||
|
ks::registerScreenshotNotifications(); // Registration is safe to repeat.
|
||||||
|
ks::Activator activator(helper.wstring(),false);
|
||||||
|
activator.Activate(L"HrynCo.KSwitcher.Screenshot",L"check-update",nullptr,0);
|
||||||
|
auto main=(helper.parent_path()/L"KSwitcher.exe").wstring();
|
||||||
|
if(activationCalls!=1 || activationExecutable!=main || activationCommand!=L"\""+main+L"\" --check-update")
|
||||||
|
throw std::runtime_error("update toast must open review, never install automatically");
|
||||||
|
if(activator.Activate(L"other-app",L"check-update",nullptr,0)!=E_INVALIDARG ||
|
||||||
|
activator.Activate(L"HrynCo.KSwitcher.Screenshot",L"--install",nullptr,0)!=E_INVALIDARG || activationCalls!=1)
|
||||||
|
throw std::runtime_error("invalid notification activation must not launch a process");
|
||||||
|
const wchar_t *token=L"12345678-1234-1234-1234-123456789abc";
|
||||||
|
activator.Activate(L"HrynCo.KSwitcher.Screenshot",token,nullptr,0);
|
||||||
|
if(activationCalls!=2 || activationExecutable!=helper.wstring() || activationCommand.find(token)==activationCommand.npos)
|
||||||
|
throw std::runtime_error("screenshot activation must retain its existing route");
|
||||||
|
std::cout<<"Passed isolated screenshot registration checks\n";
|
||||||
|
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; result=1; }
|
||||||
|
RegOverridePredefKey(HKEY_CURRENT_USER,nullptr);
|
||||||
|
if(isolated) RegCloseKey(isolated);
|
||||||
|
RegDeleteTreeW(HKEY_CURRENT_USER,keyPath.c_str());
|
||||||
|
std::filesystem::remove_all(directory);
|
||||||
|
CoUninitialize(); return result;
|
||||||
|
}
|
||||||
+592
-7
@@ -2,12 +2,71 @@
|
|||||||
// Including the implementation lets this integration test drive private editor state directly.
|
// Including the implementation lets this integration test drive private editor state directly.
|
||||||
#include "../src/screenshot.cpp"
|
#include "../src/screenshot.cpp"
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
int main()
|
#include "stroke_improvement.h"
|
||||||
|
int main(int argc, char **argv)
|
||||||
{
|
{
|
||||||
auto temp=std::filesystem::temp_directory_path()/(L"KSwitcher-editor-test-"+std::to_wstring(GetCurrentProcessId()));
|
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||||
try {
|
try {
|
||||||
|
testStrokeImprovement();
|
||||||
|
if(argc>1 && std::string(argv[1])=="--save-only") {
|
||||||
|
auto require=[](bool ok,const char *message){if(!ok) throw std::runtime_error(message);};
|
||||||
|
auto root=std::filesystem::temp_directory_path()/(L"KSwitcher-save-"+std::to_wstring(GetCurrentProcessId()));
|
||||||
|
std::filesystem::create_directories(root);
|
||||||
|
struct Cleanup {std::filesystem::path root; ~Cleanup(){std::error_code ec; std::filesystem::remove_all(root,ec);}} cleanup{root};
|
||||||
|
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
|
||||||
|
ks::Capture c(app);
|
||||||
|
c.image=std::make_unique<ks::Surface>(320,240);
|
||||||
|
PatBlt(c.image->dc,0,0,320,240,WHITENESS);
|
||||||
|
c.marks={ks::Mark{{40,40},{180,140},0}};
|
||||||
|
auto expected=ks::decodeImage(ks::screenshotClip(c.image->dc,320,240,c.marks));
|
||||||
|
for(size_t i=3;i<expected.bgra.size();i+=4) expected.bgra[i]=255;
|
||||||
|
auto path=root/L"Знімок.png";
|
||||||
|
const auto clipboard=GetClipboardSequenceNumber();
|
||||||
|
ks::saveToFile(nullptr,c,path);
|
||||||
|
ks::Clip encoded{{{0,L"PNG",ks::readFile(path,16*1024*1024)}}};
|
||||||
|
auto actual=ks::decodeImage(encoded);
|
||||||
|
require(actual.width==320 && actual.height==240 && actual.bgra==expected.bgra,
|
||||||
|
"PNG export preserves full resolution, annotations and opaque pixels");
|
||||||
|
require(c.savedPath==path && c.copied,"successful save records destination and exported state");
|
||||||
|
require(GetClipboardSequenceNumber()==clipboard,"saving does not change clipboard");
|
||||||
|
c.remember(c.marks); c.marks.clear();
|
||||||
|
require(!c.copied,"editing after save marks changes unexported");
|
||||||
|
ks::save(nullptr,c);
|
||||||
|
actual=ks::decodeImage(ks::Clip{{{0,L"PNG",ks::readFile(path,16*1024*1024)}}});
|
||||||
|
require(actual.bgra!=expected.bgra && c.copied,"Save overwrites remembered path with current edits without a dialog");
|
||||||
|
c.copied=false;
|
||||||
|
ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
|
||||||
|
if(!(dialog->Flags&OFN_OVERWRITEPROMPT) || std::wstring(dialog->lpstrDefExt)!=L"png" ||
|
||||||
|
!std::wstring(dialog->lpstrFile).ends_with(L"Знімок.png"))
|
||||||
|
throw std::runtime_error("Save As dialog must preserve filename, PNG extension and overwrite confirmation");
|
||||||
|
return FALSE;
|
||||||
|
});
|
||||||
|
require(!c.copied && c.savedPath==path,"cancelled Save As preserves dirty state and remembered path");
|
||||||
|
auto another=root/L"Another.png";
|
||||||
|
ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
|
||||||
|
auto destination=std::filesystem::path(dialog->lpstrFile).parent_path()/L"Another.png";
|
||||||
|
wcscpy_s(dialog->lpstrFile,dialog->nMaxFile,destination.c_str());
|
||||||
|
return TRUE;
|
||||||
|
});
|
||||||
|
require(c.savedPath==another && std::filesystem::exists(path),"new destination preserves previous file");
|
||||||
|
c.copied=false;
|
||||||
|
bool failed=false;
|
||||||
|
try {ks::saveToFile(nullptr,c,root/L"missing"/L"fail.png");} catch(const std::exception &){failed=true;}
|
||||||
|
require(failed && !c.copied && c.savedPath==another,"failed save preserves dirty state and previous destination");
|
||||||
|
require(std::distance(std::filesystem::directory_iterator(root),std::filesystem::directory_iterator{})==2,
|
||||||
|
"saving leaves no temporary files");
|
||||||
|
bool invalid=false;
|
||||||
|
try {ks::save(nullptr,c,true,[](LPOPENFILENAMEW dialog)->BOOL {
|
||||||
|
auto destination=std::filesystem::path(dialog->lpstrFile).parent_path()/L"Wrong.jpg";
|
||||||
|
wcscpy_s(dialog->lpstrFile,dialog->nMaxFile,destination.c_str()); return TRUE;
|
||||||
|
});} catch(const std::exception &){invalid=true;}
|
||||||
|
require(invalid && !std::filesystem::exists(root/L"Wrong.jpg"),"PNG export rejects a misleading file extension");
|
||||||
|
CoUninitialize();
|
||||||
|
std::cout<<"Passed screenshot PNG save regressions without opening windows\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls);
|
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls);
|
||||||
ks::App app(GetModuleHandleW(nullptr),true,temp);
|
ks::ScreenshotHost app{GetModuleHandleW(nullptr)};
|
||||||
app.window=CreateWindowW(L"STATIC",L"",0,0,0,0,0,HWND_MESSAGE,nullptr,app.instance,nullptr);
|
app.window=CreateWindowW(L"STATIC",L"",0,0,0,0,0,HWND_MESSAGE,nullptr,app.instance,nullptr);
|
||||||
ks::Capture c(app);
|
ks::Capture c(app);
|
||||||
c.image=std::make_unique<ks::Surface>(320,240);
|
c.image=std::make_unique<ks::Surface>(320,240);
|
||||||
@@ -18,8 +77,325 @@ int main()
|
|||||||
auto require=[](bool ok,const char *message){ if(!ok) throw std::runtime_error(message); };
|
auto require=[](bool ok,const char *message){ if(!ok) throw std::runtime_error(message); };
|
||||||
require(window!=nullptr,"editor creation");
|
require(window!=nullptr,"editor creation");
|
||||||
ks::edit(window,c); ShowWindow(window,SW_HIDE);
|
ks::edit(window,c); ShowWindow(window,SW_HIDE);
|
||||||
|
require(GetWindow(window,GW_OWNER)==nullptr && (GetWindowLongPtrW(window,GWL_EXSTYLE)&WS_EX_APPWINDOW),
|
||||||
|
"editor is an independent Alt+Tab application window");
|
||||||
require(GetDlgItem(window,50)!=nullptr && c.statusHeight>0,"status bar is created");
|
require(GetDlgItem(window,50)!=nullptr && c.statusHeight>0,"status bar is created");
|
||||||
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
|
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
|
||||||
|
require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor,
|
||||||
|
"new screenshot editor starts with the pencil tool");
|
||||||
|
{
|
||||||
|
require(GetDlgItem(window,36)!=nullptr,"triangle has a dedicated toolbar button");
|
||||||
|
SendMessageW(window,WM_KEYDOWN,'G',0);
|
||||||
|
require(c.shape==ks::ScreenshotShape::Triangle && c.arrow && !c.freehand,"G selects triangle drawing tool");
|
||||||
|
auto mouse=[&](UINT message,POINT p) {
|
||||||
|
int x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||||
|
int y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
||||||
|
SendMessageW(window,message,message==WM_LBUTTONUP?0:MK_LBUTTON,MAKELPARAM(x,y));
|
||||||
|
};
|
||||||
|
mouse(WM_LBUTTONDOWN,{80,80}); mouse(WM_LBUTTONUP,{220,180});
|
||||||
|
require(c.marks.size()==1 && c.marks[0].shape==ks::ScreenshotShape::Triangle && c.marks[0].points.size()==4,
|
||||||
|
"triangle tool creates a closed three-vertex shape");
|
||||||
|
auto triangle=c.marks[0];
|
||||||
|
for(double angle:{0.0,37.0,90.0,-125.0}) for(int i=0;i<3;++i) {
|
||||||
|
auto original=triangle; original.rotation=angle;
|
||||||
|
auto vertices=ks::shapeOutline(original);
|
||||||
|
auto part=ks::MarkPart(int(ks::MarkPart::Vertex0)+i);
|
||||||
|
require(ks::hitScreenshotMark(original,vertices[i],4)==part,"each rotated triangle vertex has its own handle");
|
||||||
|
POINT target{vertices[i].x+13,vertices[i].y-9};
|
||||||
|
auto changed=ks::dragScreenshotMark(original,part,vertices[i],target,320,240);
|
||||||
|
auto moved=ks::shapeOutline(changed);
|
||||||
|
for(int j=0;j<3;++j) {
|
||||||
|
auto expected=j==i?target:vertices[j];
|
||||||
|
require(std::abs(moved[j].x-expected.x)<=2 && std::abs(moved[j].y-expected.y)<=2,
|
||||||
|
"dragging one vertex preserves both other world vertices after rotation");
|
||||||
|
}
|
||||||
|
require(changed.points.front().x==changed.points.back().x && changed.points.front().y==changed.points.back().y,
|
||||||
|
"triangle remains closed after editing any vertex");
|
||||||
|
}
|
||||||
|
auto vertices=ks::shapeOutline(triangle);
|
||||||
|
mouse(WM_LBUTTONDOWN,vertices[0]);
|
||||||
|
require(c.part==ks::MarkPart::Vertex0,"mouse selects apex vertex");
|
||||||
|
mouse(WM_LBUTTONUP,{vertices[0].x+20,vertices[0].y+10});
|
||||||
|
require(c.marks[0].points[0].x==triangle.points[0].x+20 && c.marks[0].points[1].x==triangle.points[1].x,
|
||||||
|
"mouse vertex drag changes only requested corner");
|
||||||
|
SendMessageW(window,WM_COMMAND,12,0);
|
||||||
|
require(c.marks[0].points[0].x==triangle.points[0].x,"undo restores triangle vertex");
|
||||||
|
SendMessageW(window,WM_COMMAND,18,0);
|
||||||
|
require(c.marks[0].points[0].x==triangle.points[0].x+20,"redo reapplies triangle vertex edit");
|
||||||
|
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||||
|
require(GetDlgItem(window,37)!=nullptr,"trapezoid has a dedicated toolbar button");
|
||||||
|
SendMessageW(window,WM_KEYDOWN,'B',0);
|
||||||
|
require(c.shape==ks::ScreenshotShape::Trapezoid && c.arrow && !c.freehand,"B selects trapezoid tool");
|
||||||
|
mouse(WM_LBUTTONDOWN,{60,70}); mouse(WM_LBUTTONUP,{240,180});
|
||||||
|
require(c.marks.size()==1 && c.marks[0].shape==ks::ScreenshotShape::Trapezoid && c.marks[0].points.size()==5 && !c.marks[0].isHighlight(),
|
||||||
|
"trapezoid tool creates a closed shape with unequal parallel bases");
|
||||||
|
auto trapezoid=c.marks[0];
|
||||||
|
require(trapezoid.points[0].y==trapezoid.points[1].y && trapezoid.points[2].y==trapezoid.points[3].y &&
|
||||||
|
trapezoid.points[1].x-trapezoid.points[0].x<trapezoid.points[2].x-trapezoid.points[3].x,"toolbar trapezoid has parallel unequal bases");
|
||||||
|
mouse(WM_LBUTTONDOWN,trapezoid.to); mouse(WM_LBUTTONUP,{260,200});
|
||||||
|
require(c.marks[0].to.x==260 && c.marks[0].points[0].y==c.marks[0].points[1].y,"trapezoid resizes as a shape");
|
||||||
|
auto grip=ks::screenshotShapeRotationHandle(c.marks[0]);
|
||||||
|
auto rotated=ks::dragScreenshotMark(c.marks[0],ks::MarkPart::Rotation,grip,{280,135},320,240);
|
||||||
|
require(rotated.rotation!=0 && rotated.shape==ks::ScreenshotShape::Trapezoid,"trapezoid supports shape rotation");
|
||||||
|
SendMessageW(window,WM_COMMAND,12,0); require(c.marks[0].to.x==240,"undo trapezoid resize");
|
||||||
|
SendMessageW(window,WM_COMMAND,12,0); require(c.marks.empty(),"undo trapezoid creation");
|
||||||
|
SendMessageW(window,WM_COMMAND,18,0); require(c.marks[0].shape==ks::ScreenshotShape::Trapezoid,"redo trapezoid creation");
|
||||||
|
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||||
|
SendMessageW(window,WM_COMMAND,21,0);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
for(auto kind:{ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse,ks::ScreenshotShape::Triangle})
|
||||||
|
for(bool recognized:{false,true}) {
|
||||||
|
if(kind==ks::ScreenshotShape::Triangle && !recognized) continue;
|
||||||
|
ks::Mark shape{{100,90},{200,170},0}; shape.shape=kind; shape.ends=ks::LineEnds::None;
|
||||||
|
if(kind==ks::ScreenshotShape::Circle) shape.to.y=190;
|
||||||
|
if(recognized) shape.points=kind==ks::ScreenshotShape::Triangle?
|
||||||
|
std::vector<POINT>{{150,90},{200,170},{100,170},{150,90}}:ks::shapeOutline(shape);
|
||||||
|
auto grip=ks::screenshotShapeRotationHandle(shape);
|
||||||
|
POINT center{(shape.from.x+shape.to.x)/2,(shape.from.y+shape.to.y)/2};
|
||||||
|
POINT right{center.x+center.y-grip.y,center.y};
|
||||||
|
auto rotated=ks::dragScreenshotMark(shape,ks::MarkPart::Rotation,grip,right,320,240);
|
||||||
|
require(std::abs(rotated.rotation-90)<.01 && rotated.from.x==shape.from.x && rotated.to.y==shape.to.y,
|
||||||
|
"all native and recognized shapes rotate around their center");
|
||||||
|
auto corner=ks::rotateShapePoint(rotated,rotated.to);
|
||||||
|
require(ks::hitScreenshotMark(rotated,corner,4)==(kind==ks::ScreenshotShape::Triangle?ks::MarkPart::Vertex1:ks::MarkPart::End),"rotated resize handles remain hittable");
|
||||||
|
auto fixed=ks::rotateShapePoint(rotated,rotated.from);
|
||||||
|
auto resized=ks::dragScreenshotMark(rotated,ks::MarkPart::End,corner,{corner.x-15,corner.y+20},320,240);
|
||||||
|
auto stillFixed=ks::rotateShapePoint(resized,resized.from);
|
||||||
|
require(std::abs(stillFixed.x-fixed.x)<=1 && std::abs(stillFixed.y-fixed.y)<=1 && resized.rotation==90,
|
||||||
|
"resizing rotated shapes keeps the opposite world corner fixed");
|
||||||
|
auto snapped=ks::dragScreenshotMark(shape,ks::MarkPart::Rotation,grip,{center.x+60,center.y-20},320,240,true);
|
||||||
|
require(std::abs(std::remainder(snapped.rotation,15))<.01,"Shift snaps shape rotation to 15 degrees");
|
||||||
|
auto cloned=ks::cloneScreenshotMark(rotated,{rotated},320,240);
|
||||||
|
require(cloned.rotation==rotated.rotation && cloned.shape==kind,"clone preserves shape rotation");
|
||||||
|
auto boundary=ks::shapeOutline(shape).front();
|
||||||
|
std::vector<ks::Mark> linked{shape,{{20,20},boundary,0}};
|
||||||
|
ks::linkScreenshotEndpoint(linked[1],false,boundary,linked,4);
|
||||||
|
require(linked[1].toLink.target!=0,"connector attaches to shape before rotation");
|
||||||
|
linked[0].rotation=90; ks::resolveScreenshotLinks(linked);
|
||||||
|
auto expected=ks::rotateShapePoint(linked[0],boundary);
|
||||||
|
require(std::abs(linked[1].to.x-expected.x)<=1 && std::abs(linked[1].to.y-expected.y)<=1,
|
||||||
|
"connector follows rotated shape boundary");
|
||||||
|
}
|
||||||
|
ks::Mark shape{{100,90},{200,170},0}; shape.shape=ks::ScreenshotShape::Rectangle;
|
||||||
|
c.marks={shape}; c.selected=0;
|
||||||
|
auto mouse=[&](UINT message,POINT p) {
|
||||||
|
auto x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||||
|
auto y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
||||||
|
SendMessageW(window,message,message==WM_LBUTTONUP?0:MK_LBUTTON,MAKELPARAM(x,y));
|
||||||
|
};
|
||||||
|
auto grip=ks::screenshotShapeRotationHandle(shape,double(c.unit)*c.image->width/(c.view.right-c.view.left));
|
||||||
|
require(ks::markAt(c,grip).second==ks::MarkPart::Rotation,"shape rotation grip is distinct from thickness grip");
|
||||||
|
mouse(WM_LBUTTONDOWN,grip); mouse(WM_LBUTTONUP,{150+130-grip.y,130});
|
||||||
|
require(std::abs(c.marks[0].rotation-90)<1,"mouse rotation updates selected shape");
|
||||||
|
SendMessageW(window,WM_COMMAND,12,0); require(c.marks[0].rotation==0,"undo restores original shape orientation");
|
||||||
|
SendMessageW(window,WM_COMMAND,18,0); require(std::abs(c.marks[0].rotation-90)<1,"redo restores shape rotation");
|
||||||
|
c.selected=0;
|
||||||
|
ks::Surface rotatedPixels(320,240); PatBlt(rotatedPixels.dc,0,0,320,240,WHITENESS);
|
||||||
|
auto rendered=ks::decodeImage(ks::screenshotClip(rotatedPixels.dc,320,240,c.marks));
|
||||||
|
auto pixel=(100*320+190)*4;
|
||||||
|
require(rendered.bgra[pixel+2]==GetRValue(shape.color) && rendered.bgra[pixel+1]==GetGValue(shape.color),
|
||||||
|
"export renders rotated shape at its new boundary");
|
||||||
|
POINT center{(c.canvas.left+c.canvas.right)/2,(c.canvas.top+c.canvas.bottom)/2};
|
||||||
|
ks::zoomCanvas(window,c,2,center);
|
||||||
|
grip=ks::screenshotShapeRotationHandle(c.marks[0],double(c.unit)*c.image->width/(c.view.right-c.view.left));
|
||||||
|
require(ks::markAt(c,grip).second==ks::MarkPart::Rotation,"rotation grip works on zoomed canvas");
|
||||||
|
ks::zoomCanvas(window,c,1,center,true);
|
||||||
|
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||||
|
}
|
||||||
|
{
|
||||||
|
BYTE savedKeys[256]{},keys[256]{}; GetKeyboardState(savedKeys);
|
||||||
|
std::copy(savedKeys,savedKeys+256,keys); keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
|
||||||
|
auto fit=c.view;
|
||||||
|
SendMessageW(window,WM_KEYDOWN,VK_OEM_PLUS,0);
|
||||||
|
require(c.zoom==1.25 && c.view.right-c.view.left>fit.right-fit.left,"Ctrl+plus zooms canvas");
|
||||||
|
SendMessageW(window,WM_KEYDOWN,VK_SUBTRACT,0);
|
||||||
|
require(std::abs(c.zoom-1)<.001,"Ctrl+numpad minus zooms out");
|
||||||
|
SendMessageW(window,WM_KEYDOWN,VK_ADD,0);
|
||||||
|
SendMessageW(window,WM_KEYDOWN,VK_OEM_MINUS,0);
|
||||||
|
require(std::abs(c.zoom-1)<.001,"both keyboard plus/minus variants work");
|
||||||
|
POINT center{(c.canvas.left+c.canvas.right)/2,(c.canvas.top+c.canvas.bottom)/2};
|
||||||
|
ks::zoomCanvas(window,c,4,center);
|
||||||
|
POINT anchor{center.x+12,center.y+8},screen=anchor; ClientToScreen(window,&screen);
|
||||||
|
auto before=ks::imagePoint(c,anchor,false); double zoom=c.zoom;
|
||||||
|
SendMessageW(window,WM_MOUSEWHEEL,MAKEWPARAM(MK_CONTROL,60),MAKELPARAM(screen.x,screen.y));
|
||||||
|
require(c.zoom==zoom,"partial wheel delta waits for a full step");
|
||||||
|
SendMessageW(window,WM_MOUSEWHEEL,MAKEWPARAM(MK_CONTROL,60),MAKELPARAM(screen.x,screen.y));
|
||||||
|
auto after=ks::imagePoint(c,anchor,false);
|
||||||
|
require(c.zoom>zoom && std::abs(before.x-after.x)<=1 && std::abs(before.y-after.y)<=1,
|
||||||
|
"Ctrl+wheel zoom keeps image point under cursor");
|
||||||
|
require(!c.history.canUndo(),"zoom does not add image edits to undo history");
|
||||||
|
auto view=c.view;
|
||||||
|
SendMessageW(window,WM_MBUTTONDOWN,MK_MBUTTON,MAKELPARAM(center.x,center.y));
|
||||||
|
SendMessageW(window,WM_MOUSEMOVE,MK_MBUTTON,MAKELPARAM(center.x+15,center.y+10));
|
||||||
|
SendMessageW(window,WM_MBUTTONUP,0,MAKELPARAM(center.x+15,center.y+10));
|
||||||
|
require(c.view.left==view.left+15 && c.view.top==view.top+10 && !c.panning,"middle drag pans enlarged canvas");
|
||||||
|
keys[VK_CONTROL]=0; SetKeyboardState(keys);
|
||||||
|
auto a=ks::imagePoint(c,center),b=ks::imagePoint(c,{center.x+30,center.y+20});
|
||||||
|
SendMessageW(window,WM_LBUTTONDOWN,MK_LBUTTON,MAKELPARAM(center.x,center.y));
|
||||||
|
SendMessageW(window,WM_LBUTTONUP,0,MAKELPARAM(center.x+30,center.y+20));
|
||||||
|
require(c.marks.size()==1 && c.marks[0].from.x==a.x && c.marks[0].to.x==b.x && c.marks[0].to.y==b.y,
|
||||||
|
"drawing after zoom and pan uses image coordinates");
|
||||||
|
auto original=c.marks[0];
|
||||||
|
keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
|
||||||
|
SendMessageW(window,WM_KEYDOWN,'0',0);
|
||||||
|
require(c.zoom==1 && c.panX==0 && c.panY==0 && c.marks[0].from.x==original.from.x && c.marks[0].to.x==original.to.x,
|
||||||
|
"Ctrl+0 resets view without transforming annotations");
|
||||||
|
ks::zoomCanvas(window,c,1e6,center); require(c.zoom==16,"zoom has upper bound");
|
||||||
|
ks::zoomCanvas(window,c,1e-6,center); require(c.zoom==.1,"zoom has lower bound");
|
||||||
|
SendMessageW(window,WM_KEYDOWN,'0',0); SetKeyboardState(savedKeys);
|
||||||
|
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||||
|
}
|
||||||
|
{
|
||||||
|
BYTE savedKeys[256]{},keys[256]{}; GetKeyboardState(savedKeys);
|
||||||
|
std::copy(savedKeys,savedKeys+256,keys); keys[VK_SHIFT]=0; SetKeyboardState(keys);
|
||||||
|
auto mouse=[&](UINT message,POINT p,bool shift=false) {
|
||||||
|
int x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||||
|
int y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
||||||
|
SendMessageW(window,message,(message==WM_LBUTTONUP?0:MK_LBUTTON)|(shift?MK_SHIFT:0),MAKELPARAM(x,y));
|
||||||
|
};
|
||||||
|
mouse(WM_LBUTTONDOWN,{30,190});
|
||||||
|
for(int x=35;x<=200;x+=5) mouse(WM_MOUSEMOVE,{x,190+(x%10?2:-2)});
|
||||||
|
auto original=c.stroke.points;
|
||||||
|
mouse(WM_LBUTTONUP,c.stroke.to);
|
||||||
|
require(c.marks.size()==1,"improved stroke is one object");
|
||||||
|
require(!c.marks[0].freehand && c.marks[0].points.empty() &&
|
||||||
|
c.marks[0].shape==ks::ScreenshotShape::None && !c.marks[0].isHighlight(),
|
||||||
|
"near-straight pencil stroke becomes an editable line on mouse release");
|
||||||
|
require(c.marks[0].color==c.currentColor && c.marks[0].thickness==3 &&
|
||||||
|
c.marks[0].style==c.lineStyle && c.marks[0].ends==c.freehandEnds,
|
||||||
|
"line recognition preserves drawing style");
|
||||||
|
auto improved=c.marks.back().points;
|
||||||
|
SendMessageW(window,WM_COMMAND,12,0);
|
||||||
|
require(c.marks.size()==1 && c.marks.back().freehand && c.marks.back().points.size()==original.size() &&
|
||||||
|
std::equal(original.begin(),original.end(),c.marks.back().points.begin(),
|
||||||
|
[](POINT a,POINT b){return a.x==b.x && a.y==b.y;}),"first undo restores exact raw stroke");
|
||||||
|
SendMessageW(window,WM_COMMAND,12,0);
|
||||||
|
require(c.marks.empty(),"second undo removes stroke");
|
||||||
|
SendMessageW(window,WM_COMMAND,18,0); SendMessageW(window,WM_COMMAND,18,0);
|
||||||
|
require(c.marks.size()==1 && !c.marks.back().freehand && c.marks.back().points.size()==improved.size(),"redo restores improvement");
|
||||||
|
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||||
|
mouse(WM_LBUTTONDOWN,{180,120});
|
||||||
|
for(int i=1;i<=120;++i) {
|
||||||
|
double a=i*2*3.141592653589793/120;
|
||||||
|
mouse(WM_MOUSEMOVE,{LONG(std::lround(130+50*std::cos(a))),LONG(std::lround(120+50*std::sin(a)))});
|
||||||
|
}
|
||||||
|
mouse(WM_LBUTTONUP,c.stroke.to);
|
||||||
|
require(c.marks.size()==1 && c.marks[0].shape==ks::ScreenshotShape::Circle &&
|
||||||
|
!c.marks[0].freehand && !c.marks[0].isHighlight(),"recognized circle is a shape, not a pencil or highlight");
|
||||||
|
auto circle=c.marks[0];
|
||||||
|
require(ks::hitScreenshotMark(circle,circle.to,6)==ks::MarkPart::End,"recognized shape exposes bounding-box resize handle");
|
||||||
|
mouse(WM_LBUTTONDOWN,circle.to);
|
||||||
|
mouse(WM_MOUSEMOVE,{circle.to.x+30,circle.to.y+20});
|
||||||
|
mouse(WM_LBUTTONUP,{circle.to.x+30,circle.to.y+20});
|
||||||
|
require(c.marks[0].to.x>circle.to.x && c.marks[0].to.x-c.marks[0].from.x==c.marks[0].to.y-c.marks[0].from.y &&
|
||||||
|
c.marks[0].points[0].x>circle.points[0].x,"dragging recognized circle handle scales the whole circle proportionally");
|
||||||
|
SendMessageW(window,WM_COMMAND,12,0);
|
||||||
|
require(c.marks[0].shape==ks::ScreenshotShape::Circle && c.marks[0].to.x==circle.to.x,"undo restores shape size");
|
||||||
|
SendMessageW(window,WM_COMMAND,12,0);
|
||||||
|
require(c.marks[0].freehand && c.marks[0].shape==ks::ScreenshotShape::None,"undo recognition restores editable pencil stroke");
|
||||||
|
SendMessageW(window,WM_COMMAND,18,0);
|
||||||
|
require(c.marks[0].shape==ks::ScreenshotShape::Circle,"redo restores shape type");
|
||||||
|
for(auto kind:{ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Ellipse,ks::ScreenshotShape::Triangle}) {
|
||||||
|
auto shape=circle; shape.shape=kind;
|
||||||
|
auto resized=ks::dragScreenshotMark(shape,ks::MarkPart::End,shape.to,{shape.to.x+50,shape.to.y+20},320,240);
|
||||||
|
require(resized.points[0].x!=shape.points[0].x && resized.points[24].y!=shape.points[24].y,
|
||||||
|
"shape resize transforms all outline points in both axes");
|
||||||
|
auto moved=ks::cloneScreenshotMark(resized,{resized},320,240);
|
||||||
|
require(moved.shape==kind && !moved.isHighlight(),"clone preserves recognized shape type");
|
||||||
|
}
|
||||||
|
c.marks.clear(); c.selected=-1; c.history=ks::ScreenshotHistory{};
|
||||||
|
mouse(WM_LBUTTONDOWN,{40,40},true);
|
||||||
|
mouse(WM_MOUSEMOVE,{100,50},true);
|
||||||
|
require(c.stroke.to.x==100 && c.stroke.to.y==40,"Shift constrains pencil horizontally");
|
||||||
|
mouse(WM_MOUSEMOVE,{100,90},true);
|
||||||
|
require(c.stroke.to.x==100 && c.stroke.to.y==40 && c.stroke.points.size()==2,
|
||||||
|
"50 pixel deviation keeps the current axis");
|
||||||
|
mouse(WM_MOUSEMOVE,{100,91},true);
|
||||||
|
require(c.stroke.to.x==100 && c.stroke.to.y==91 && c.stroke.points.size()==3 && c.stroke.points[1].y==40,
|
||||||
|
"51 pixel deviation turns vertically and preserves the horizontal segment");
|
||||||
|
mouse(WM_MOUSEMOVE,{70,140});
|
||||||
|
require(c.stroke.to.x==70 && c.stroke.to.y==140 && c.stroke.points.size()==4,
|
||||||
|
"releasing Shift resumes freehand in the same stroke");
|
||||||
|
SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0);
|
||||||
|
mouse(WM_MOUSEMOVE,{140,150},true);
|
||||||
|
require(c.stroke.to.x==140 && c.stroke.to.y==140 && c.stroke.points[3].x==70,
|
||||||
|
"pressing Shift mid-stroke anchors at the last drawn point");
|
||||||
|
SendMessageW(window,WM_KEYUP,VK_SHIFT,0);
|
||||||
|
SendMessageW(window,WM_KEYDOWN,VK_SHIFT,0);
|
||||||
|
mouse(WM_MOUSEMOVE,{150,200},true);
|
||||||
|
mouse(WM_LBUTTONUP,{150,210},true);
|
||||||
|
SetKeyboardState(savedKeys);
|
||||||
|
require(c.marks.size()==1 && c.marks[0].to.x==140 && c.marks[0].to.y==210,
|
||||||
|
"repressing Shift without mouse movement starts a new segment and constrains mouse-up");
|
||||||
|
SendMessageW(window,WM_COMMAND,12,0);
|
||||||
|
require(c.marks.empty(),"mixed freehand and constrained segments undo as one stroke");
|
||||||
|
SendMessageW(window,WM_COMMAND,18,0);
|
||||||
|
require(c.marks.size()==1 && c.marks[0].points.size()==6,"redo preserves constrained stroke geometry");
|
||||||
|
c.marks.clear(); c.selected=-1;
|
||||||
|
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
||||||
|
mouse(WM_LBUTTONDOWN,{160,120},true);
|
||||||
|
mouse(WM_MOUSEMOVE,{160+sx,120},true);
|
||||||
|
for(int step=2;step<=90;++step)
|
||||||
|
mouse(WM_MOUSEMOVE,{160+sx*step,120+sy*step},true);
|
||||||
|
require(c.stroke.points.size()==2 && c.stroke.to.x==160+sx*90 && c.stroke.to.y==120+sy*90,
|
||||||
|
"smooth diagonal after one-pixel horizontal jitter stays diagonal without stairs");
|
||||||
|
mouse(WM_LBUTTONUP,{160+sx*90,120+sy*90},true);
|
||||||
|
c.marks.clear(); c.selected=-1;
|
||||||
|
mouse(WM_LBUTTONDOWN,{160-sx*40,120-sy*70},true);
|
||||||
|
mouse(WM_MOUSEMOVE,{160,120-sy*70},true);
|
||||||
|
for(int step=1;step<=90;++step)
|
||||||
|
mouse(WM_MOUSEMOVE,{160+sx*step,120-sy*70+sy*step},true);
|
||||||
|
require(c.stroke.points.size()==3 && c.strokeAxis>=3 &&
|
||||||
|
std::abs(c.stroke.to.x-c.stroke.points[1].x)==std::abs(c.stroke.to.y-c.stroke.points[1].y),
|
||||||
|
"smooth turn from horizontal into diagonal uses raw mouse direction without stairs");
|
||||||
|
mouse(WM_LBUTTONUP,{160+sx*90,120-sy*70+sy*90},true);
|
||||||
|
c.marks.clear(); c.selected=-1;
|
||||||
|
}
|
||||||
|
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
||||||
|
mouse(WM_LBUTTONDOWN,{160,120},true);
|
||||||
|
mouse(WM_MOUSEMOVE,{160+sx*60,120+sy*50},true);
|
||||||
|
require(c.stroke.to.x==160+sx*55 && c.stroke.to.y==120+sy*55,
|
||||||
|
"Shift snaps to exact 45 degree diagonals in all four directions");
|
||||||
|
mouse(WM_LBUTTONUP,{160+sx*60,120+sy*50},true);
|
||||||
|
c.marks.clear(); c.selected=-1;
|
||||||
|
}
|
||||||
|
mouse(WM_LBUTTONDOWN,{40,40},true);
|
||||||
|
mouse(WM_MOUSEMOVE,{100,40},true);
|
||||||
|
mouse(WM_MOUSEMOVE,{160,100},true);
|
||||||
|
require(c.stroke.points.size()==3 && c.stroke.to.x==160 && c.stroke.to.y==100 &&
|
||||||
|
c.stroke.points[1].x==100 && c.stroke.points[1].y==40,
|
||||||
|
"large deviation turns a horizontal segment diagonally without losing its corner");
|
||||||
|
mouse(WM_MOUSEMOVE,{200,100},true);
|
||||||
|
require(c.stroke.to.x==180 && c.stroke.to.y==120 && c.stroke.points.size()==3,
|
||||||
|
"small perpendicular deviation keeps the diagonal axis");
|
||||||
|
mouse(WM_MOUSEMOVE,{260,120},true);
|
||||||
|
require(c.stroke.to.x==260 && c.stroke.to.y==120 && c.stroke.points.size()==4,
|
||||||
|
"diagonal deviation beyond 50 pixels turns back to horizontal");
|
||||||
|
mouse(WM_LBUTTONUP,{260,120},true);
|
||||||
|
c.marks.clear(); c.selected=-1;
|
||||||
|
for(int sx:{-1,1}) for(int sy:{-1,1}) {
|
||||||
|
POINT start{sx>0?310L:9L,120};
|
||||||
|
mouse(WM_LBUTTONDOWN,start,true);
|
||||||
|
mouse(WM_MOUSEMOVE,{start.x+sx*8,start.y+sy*8},true);
|
||||||
|
mouse(WM_MOUSEMOVE,{sx>0?319L:0L,start.y+sy*25},true);
|
||||||
|
auto end=c.stroke.to;
|
||||||
|
require(end.x>=0 && end.x<320 && end.y>=0 && end.y<240 &&
|
||||||
|
std::abs(end.x-start.x)==std::abs(end.y-start.y),
|
||||||
|
"diagonal stays at 45 degrees and inside image boundaries");
|
||||||
|
mouse(WM_LBUTTONUP,{sx>0?319L:0L,start.y+sy*25},true);
|
||||||
|
c.marks.clear(); c.selected=-1;
|
||||||
|
}
|
||||||
|
keys[VK_CONTROL]=0x80; SetKeyboardState(keys);
|
||||||
|
mouse(WM_LBUTTONDOWN,{40,40});
|
||||||
|
mouse(WM_MOUSEMOVE,{100,70});
|
||||||
|
mouse(WM_LBUTTONUP,{120,90});
|
||||||
|
SetKeyboardState(savedKeys);
|
||||||
|
require(c.marks.size()==1 && c.marks[0].points[1].x==100 && c.marks[0].points[1].y==70 &&
|
||||||
|
c.marks[0].to.x==120 && c.marks[0].to.y==90,
|
||||||
|
"Ctrl keeps its existing drawing behavior without constraining the pencil");
|
||||||
|
c.marks.clear(); c.selected=-1;
|
||||||
|
}
|
||||||
for(int size:{32,48,64,96}) {
|
for(int size:{32,48,64,96}) {
|
||||||
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
|
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
|
||||||
ICONINFO info{};
|
ICONINFO info{};
|
||||||
@@ -29,16 +405,58 @@ int main()
|
|||||||
if(info.hbmColor) DeleteObject(info.hbmColor);
|
if(info.hbmColor) DeleteObject(info.hbmColor);
|
||||||
DestroyCursor(cursor);
|
DestroyCursor(cursor);
|
||||||
}
|
}
|
||||||
|
ks::Surface cursorPreview(784,448);
|
||||||
|
PatBlt(cursorPreview.dc,0,0,784,448,WHITENESS);
|
||||||
|
int row=0;
|
||||||
|
for(int size:{32,48,64,96}) {
|
||||||
|
int column=0;
|
||||||
|
for(int tool:ks::CursorTools) {
|
||||||
|
auto cursor=ks::makeToolCursor(tool,size); ICONINFO info{};
|
||||||
|
require(cursor && GetIconInfo(cursor,&info) && !info.fIcon,"tool cursors support standard and high DPI");
|
||||||
|
require(info.xHotspot==UINT(MulDiv(7,size,32)) && info.yHotspot==info.xHotspot,"tool cursor hotspot stays on crosshair center");
|
||||||
|
BITMAP mask{}; GetObjectW(info.hbmMask,sizeof(mask),&mask);
|
||||||
|
require(mask.bmWidth==size && mask.bmHeight==size*2,"cursor transparency mask matches requested DPI");
|
||||||
|
if(column%2) {
|
||||||
|
RECT tile{column*112,row*112,(column+1)*112,(row+1)*112};
|
||||||
|
auto dark=CreateSolidBrush(RGB(40,40,40)); FillRect(cursorPreview.dc,&tile,dark); DeleteObject(dark);
|
||||||
|
}
|
||||||
|
DrawIconEx(cursorPreview.dc,column*112,row*112,cursor,size,size,0,nullptr,DI_NORMAL);
|
||||||
|
if(info.hbmMask) DeleteObject(info.hbmMask);
|
||||||
|
if(info.hbmColor) DeleteObject(info.hbmColor);
|
||||||
|
DestroyCursor(cursor); ++column;
|
||||||
|
}
|
||||||
|
++row;
|
||||||
|
}
|
||||||
|
auto dib=ks::screenshotClip(cursorPreview.dc,784,448,{}).formats[0].data;
|
||||||
|
BITMAPFILEHEADER bmp{}; bmp.bfType=0x4d42; bmp.bfOffBits=sizeof(bmp)+sizeof(BITMAPINFOHEADER); bmp.bfSize=DWORD(sizeof(bmp)+dib.size());
|
||||||
|
ks::Bytes preview(sizeof(bmp)); memcpy(preview.data(),&bmp,sizeof(bmp)); preview.insert(preview.end(),dib.begin(),dib.end());
|
||||||
|
ks::atomicWrite(L"build/cursor-preview.bmp",preview);
|
||||||
|
for(int tool:{27,26,21,10,11,22,23,24,25,19}) {
|
||||||
|
SendMessageW(window,WM_COMMAND,tool,0);
|
||||||
|
HCURSOR expected=nullptr;
|
||||||
|
if(tool==27) expected=LoadCursorW(nullptr,IDC_ARROW);
|
||||||
|
else if(tool==26) expected=LoadCursorW(nullptr,IDC_IBEAM);
|
||||||
|
else if(tool==21) expected=c.pencilCursor;
|
||||||
|
else for(size_t i=0;i<std::size(ks::CursorTools);++i) if(ks::CursorTools[i]==tool) expected=c.drawingCursors[i];
|
||||||
|
require(expected && ks::actionCursor(c,{-1,ks::MarkPart::None},true)==expected,"each tool uses its own drawing cursor");
|
||||||
|
require(ks::actionCursor(c,{-1,ks::MarkPart::None},false)==LoadCursorW(nullptr,IDC_ARROW),"outside canvas uses normal pointer");
|
||||||
|
c.marks={{{20,20},{120,20},0}};
|
||||||
|
require(ks::actionCursor(c,{0,ks::MarkPart::Body},true)==LoadCursorW(nullptr,IDC_SIZEALL),"move cursor overrides drawing tool");
|
||||||
|
require(ks::actionCursor(c,{0,ks::MarkPart::Rotation},true)==LoadCursorW(nullptr,IDC_HAND),"rotation cursor overrides drawing tool");
|
||||||
|
require(ks::actionCursor(c,{0,ks::MarkPart::End},true)==LoadCursorW(nullptr,IDC_SIZEWE),"endpoint resize overrides drawing tool");
|
||||||
|
require(ks::actionCursor(c,{0,ks::MarkPart::Thickness},true)==LoadCursorW(nullptr,IDC_SIZENS),"thickness resize is perpendicular to line");
|
||||||
|
}
|
||||||
|
c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0);
|
||||||
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
|
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
|
||||||
require(std::wstring(windowTitle)==L"KSwitcher — Screenshot","title has no usage hints");
|
require(std::wstring(windowTitle)==ks::localBuildTitle(L"KSwitcher — Screenshot #1"),"title includes editor index and local build marker without usage hints");
|
||||||
RECT client{}; GetClientRect(window,&client);
|
RECT client{}; GetClientRect(window,&client);
|
||||||
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
|
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
|
||||||
ks::updateStatus(c);
|
ks::updateStatus(c);
|
||||||
require(c.statusText.find(L"Freehand")!=std::wstring::npos,"default tool hint is shown");
|
require(c.statusText.find(L"Pointer")!=std::wstring::npos,"default tool hint is shown");
|
||||||
SendMessageW(GetDlgItem(window,13),WM_MOUSEMOVE,0,0);
|
SendMessageW(GetDlgItem(window,13),WM_MOUSEMOVE,0,0);
|
||||||
require(c.statusText.find(L"Ctrl+C")!=std::wstring::npos,"button hover shows action shortcut");
|
require(c.statusText.find(L"Ctrl+C")!=std::wstring::npos,"button hover shows action shortcut");
|
||||||
SendMessageW(GetDlgItem(window,13),WM_MOUSELEAVE,0,0);
|
SendMessageW(GetDlgItem(window,13),WM_MOUSELEAVE,0,0);
|
||||||
require(c.statusText.find(L"Freehand")!=std::wstring::npos,"button leave restores contextual hint");
|
require(c.statusText.find(L"Pointer")!=std::wstring::npos,"button leave restores contextual hint");
|
||||||
for(int id:ks::ToolIds) require(GetDlgItem(window,id)!=nullptr,"all toolbar buttons created");
|
for(int id:ks::ToolIds) require(GetDlgItem(window,id)!=nullptr,"all toolbar buttons created");
|
||||||
require(GetDlgItem(window,34)!=nullptr && c.toolIcons[0][14]!=nullptr,"font icon created and loaded");
|
require(GetDlgItem(window,34)!=nullptr && c.toolIcons[0][14]!=nullptr,"font icon created and loaded");
|
||||||
ks::Mark text{{20,20},{20,20},0}; text.fontSize=18;
|
ks::Mark text{{20,20},{20,20},0}; text.fontSize=18;
|
||||||
@@ -92,6 +510,124 @@ int main()
|
|||||||
c.hoveredPart=ks::MarkPart::Rotation; ks::updateStatus(c);
|
c.hoveredPart=ks::MarkPart::Rotation; ks::updateStatus(c);
|
||||||
require(c.statusText.find(L"15-degree")!=std::wstring::npos,"rotation hint explains Shift snapping");
|
require(c.statusText.find(L"15-degree")!=std::wstring::npos,"rotation hint explains Shift snapping");
|
||||||
c.hoveredPart=ks::MarkPart::None;
|
c.hoveredPart=ks::MarkPart::None;
|
||||||
|
// Drag the two badges in turn, including a click near the attached endpoint.
|
||||||
|
for(bool freehand : {false,true}) for(auto ends : {ks::LineEnds::End,ks::LineEnds::None}) {
|
||||||
|
c.marks={{{60,80},{60,80},1},{{230,80},{230,80},2},{{60,80},{230,80},0}};
|
||||||
|
auto &connector=c.marks[2]; connector.fromNumber=1; connector.toNumber=2;
|
||||||
|
connector.ends=ends; connector.freehand=freehand;
|
||||||
|
if(freehand) connector.points={{60,80},{140,80},{230,80}};
|
||||||
|
ks::resolveScreenshotLinks(c.marks); c.selected=-1; c.selectedLabel=false;
|
||||||
|
auto mouse=[&](UINT message,POINT p) {
|
||||||
|
LONG x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||||
|
LONG y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
||||||
|
SendMessageW(window,message,message==WM_LBUTTONUP?0:MK_LBUTTON,MAKELPARAM(x,y));
|
||||||
|
};
|
||||||
|
for(int badge : {0,1,0,1}) {
|
||||||
|
auto origin=c.marks[badge].from;
|
||||||
|
// The second badge's inner edge overlaps the connector endpoint hit area.
|
||||||
|
if(badge==1) origin.x-=12;
|
||||||
|
mouse(WM_LBUTTONDOWN,origin);
|
||||||
|
require(c.selected==badge && c.part==ks::MarkPart::Body,"click on linked badge selects badge instead of connector endpoint");
|
||||||
|
mouse(WM_MOUSEMOVE,{origin.x,origin.y+20});
|
||||||
|
mouse(WM_LBUTTONUP,{origin.x,origin.y+20});
|
||||||
|
require(c.marks[2].fromNumber==1 && c.marks[2].toNumber==2,"alternating badge drags preserve both connector links");
|
||||||
|
auto start=c.marks[0].from,end=c.marks[1].from;
|
||||||
|
require(std::abs(std::hypot(double(c.marks[2].from.x-start.x),double(c.marks[2].from.y-start.y))-15)<1 &&
|
||||||
|
std::abs(std::hypot(double(c.marks[2].to.x-end.x),double(c.marks[2].to.y-end.y))-15)<1,
|
||||||
|
"connector endpoints follow both badge rims");
|
||||||
|
}
|
||||||
|
// Selecting a linked connector (also the first click of a double-click)
|
||||||
|
// must not detach it before opening its caption editor.
|
||||||
|
for(int entry : {0,1,2}) {
|
||||||
|
c.selected=-1;
|
||||||
|
POINT body{110,c.marks[0].from.y};
|
||||||
|
if(freehand) body={(c.marks[2].points[0].x+c.marks[2].points[1].x)/2,
|
||||||
|
(c.marks[2].points[0].y+c.marks[2].points[1].y)/2};
|
||||||
|
mouse(WM_LBUTTONDOWN,body); mouse(WM_LBUTTONUP,body);
|
||||||
|
require(c.selected==2 && c.marks[2].fromNumber==1 && c.marks[2].toNumber==2,
|
||||||
|
"clicking a connector without moving preserves both links");
|
||||||
|
if(entry==0) { mouse(WM_LBUTTONDBLCLK,body); mouse(WM_LBUTTONUP,body); }
|
||||||
|
else if(entry==1) SendMessageW(window,WM_KEYDOWN,VK_F2,0);
|
||||||
|
else SendMessageW(window,WM_COMMAND,35,0);
|
||||||
|
require(c.inlineText && c.editingLabel,"linked connector caption opens through every entry point");
|
||||||
|
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Linked caption");
|
||||||
|
ks::finishInlineText(window,c,true);
|
||||||
|
require(c.marks[2].label==L"Linked caption" && c.marks[2].fromNumber==1 && c.marks[2].toNumber==2,
|
||||||
|
"adding or editing a caption preserves both connector links");
|
||||||
|
}
|
||||||
|
c.selected=2;
|
||||||
|
require(ks::markAt(c,c.marks[1].from).first==1,"selected connector cannot steal a badge click");
|
||||||
|
auto outside=c.marks[2].to; outside.x-=4;
|
||||||
|
require(ks::markAt(c,outside)==std::pair{2,ks::MarkPart::End},"endpoint remains draggable outside badge");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
auto mouse=[&](UINT message,POINT p) {
|
||||||
|
LONG x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||||
|
LONG y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
||||||
|
SendMessageW(window,message,message==WM_LBUTTONUP?0:MK_LBUTTON,MAKELPARAM(x,y));
|
||||||
|
};
|
||||||
|
SendMessageW(window,WM_COMMAND,27,0); c.marks.clear();
|
||||||
|
mouse(WM_LBUTTONDOWN,{40,160}); mouse(WM_MOUSEMOVE,{100,190}); mouse(WM_LBUTTONUP,{100,190});
|
||||||
|
require(c.marks.empty() && !c.dragging && c.selected==-1,"pointer drag on empty canvas never adds an object");
|
||||||
|
for(int tool : {11,22}) for(int target : {0,1,2}) {
|
||||||
|
if(target==0) c.marks={{{55,160},{55,160},1},{{255,160},{255,160},2}};
|
||||||
|
else {
|
||||||
|
c.marks={{{30,140},{80,190},0},{{230,140},{280,190},0}};
|
||||||
|
if(target==1) for(auto &mark:c.marks) mark.shape=ks::ScreenshotShape::Rectangle;
|
||||||
|
else for(auto &mark:c.marks) mark.from.y=mark.to.y=160;
|
||||||
|
}
|
||||||
|
SendMessageW(window,WM_COMMAND,tool,0);
|
||||||
|
BYTE savedKeys[256]{},drawingKeys[256]{};
|
||||||
|
require(GetKeyboardState(savedKeys)!=FALSE,"read thread keyboard state");
|
||||||
|
std::copy(savedKeys,savedKeys+256,drawingKeys); drawingKeys[VK_CONTROL]=0x80;
|
||||||
|
require(SetKeyboardState(drawingKeys)!=FALSE,"set thread-local Ctrl for drawing test");
|
||||||
|
mouse(WM_LBUTTONDOWN,{55,160}); mouse(WM_MOUSEMOVE,{255,160}); mouse(WM_LBUTTONUP,{255,160});
|
||||||
|
SetKeyboardState(savedKeys);
|
||||||
|
require(c.marks.size()==3,"drawing over existing objects creates a connector with Ctrl");
|
||||||
|
auto &connector=c.marks[2];
|
||||||
|
require(target==0?(connector.fromNumber==1 && connector.toNumber==2):
|
||||||
|
(connector.fromLink.target==c.marks[0].id && connector.toLink.target==c.marks[1].id),
|
||||||
|
"new arrow or line automatically attaches both endpoints to targets");
|
||||||
|
require(connector.ends==(tool==11?c.arrowEnds:ks::LineEnds::None),"arrow and straight-line tools retain their end styles");
|
||||||
|
auto before=connector.from;
|
||||||
|
SendMessageW(window,WM_KEYDOWN,'V',0);
|
||||||
|
require(c.pointer && !c.arrow && !c.freehand,"V selects pointer mode");
|
||||||
|
POINT body=target==0?POINT{55,160}:target==1?POINT{30,175}:POINT{30,160};
|
||||||
|
mouse(WM_LBUTTONDOWN,body); mouse(WM_MOUSEMOVE,{body.x,body.y-20}); mouse(WM_LBUTTONUP,{body.x,body.y-20});
|
||||||
|
require(c.selected==0 && c.marks.size()==3 && c.marks[2].from.y<before.y,
|
||||||
|
"pointer moves the target and the new connector follows without adding objects");
|
||||||
|
mouse(WM_LBUTTONDOWN,{150,220}); mouse(WM_LBUTTONUP,{150,220});
|
||||||
|
require(c.selected==-1 && c.marks.size()==3,"pointer click on empty space only clears selection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for(bool manualAngle : {false,true}) {
|
||||||
|
c.marks={{{40,140},{180,60},0}}; c.selected=0;
|
||||||
|
c.marks[0].fontSize=18; c.marks[0].fontWeight=FW_BOLD;
|
||||||
|
c.marks[0].rotation=31; c.marks[0].labelRotationManual=manualAngle;
|
||||||
|
SendMessageW(window,WM_COMMAND,35,0);
|
||||||
|
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Sloped caption");
|
||||||
|
ks::finishInlineText(window,c,true);
|
||||||
|
auto captionAngle=ks::screenshotArrowLabel(c.marks[0]).rotation;
|
||||||
|
require(captionAngle!=0,"caption retains its automatic or manual angle");
|
||||||
|
SendMessageW(window,WM_COMMAND,26,0);
|
||||||
|
LONG x=c.view.left+MulDiv(220,c.view.right-c.view.left,c.image->width);
|
||||||
|
LONG y=c.view.top+MulDiv(190,c.view.bottom-c.view.top,c.image->height);
|
||||||
|
SendMessageW(window,WM_LBUTTONDOWN,MK_LBUTTON,MAKELPARAM(x,y));
|
||||||
|
require(c.inlineText && !c.editingLabel,"standalone text starts independently of caption");
|
||||||
|
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Independent text");
|
||||||
|
ks::finishInlineText(window,c,true);
|
||||||
|
require(c.marks.size()==2 && c.marks[1].isText() && c.marks[1].rotation==0,
|
||||||
|
"new standalone text does not inherit caption rotation");
|
||||||
|
require(c.marks[1].fontSize==18 && c.marks[1].fontWeight==FW_BOLD,
|
||||||
|
"new text still inherits font styling");
|
||||||
|
require(ks::screenshotArrowLabel(c.marks[0]).rotation==captionAngle,"new text does not change caption angle");
|
||||||
|
c.marks[1].rotation=22; c.selected=1;
|
||||||
|
SendMessageW(window,WM_KEYDOWN,VK_F2,0);
|
||||||
|
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Edited standalone text");
|
||||||
|
ks::finishInlineText(window,c,true);
|
||||||
|
require(c.marks[1].rotation==22,"editing existing rotated text preserves its own angle");
|
||||||
|
SendMessageW(window,WM_COMMAND,27,0);
|
||||||
|
}
|
||||||
std::vector<ks::Mark> nudgeMarks;
|
std::vector<ks::Mark> nudgeMarks;
|
||||||
nudgeMarks.push_back({{60,60},{60,60},1});
|
nudgeMarks.push_back({{60,60},{60,60},1});
|
||||||
nudgeMarks.push_back({{60,60},{120,100},0});
|
nudgeMarks.push_back({{60,60},{120,100},0});
|
||||||
@@ -124,6 +660,18 @@ int main()
|
|||||||
SendMessageW(window,WM_COMMAND,18,0);
|
SendMessageW(window,WM_COMMAND,18,0);
|
||||||
require(ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0])).left==beforeNudge.left+5,"redo restores one nudge");
|
require(ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0])).left==beforeNudge.left+5,"redo restores one nudge");
|
||||||
SetWindowPos(window,nullptr,80,60,720,500,SWP_NOZORDER|SWP_SHOWWINDOW);
|
SetWindowPos(window,nullptr,80,60,720,500,SWP_NOZORDER|SWP_SHOWWINDOW);
|
||||||
|
ks::copy(window,c,[](HWND,const ks::Clip &clip)->DWORD {
|
||||||
|
if(clip.formats.empty()) throw std::runtime_error("copy produced no image");
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
require(IsWindow(window) && c.copied,"copy publishes screenshot and keeps editor open");
|
||||||
|
c.selected=0; c.selectedLabel=false;
|
||||||
|
SendMessageW(window,WM_KEYDOWN,VK_RIGHT,0);
|
||||||
|
require(!c.copied,"editing after copy requires confirmation again");
|
||||||
|
c.copied=true; SendMessageW(window,WM_COMMAND,12,0);
|
||||||
|
require(!c.copied,"undo after copy requires confirmation again");
|
||||||
|
c.copied=true; SendMessageW(window,WM_COMMAND,18,0);
|
||||||
|
require(!c.copied,"redo after copy requires confirmation again");
|
||||||
SetPropW(window,L"ConfirmationCentered",nullptr);
|
SetPropW(window,L"ConfirmationCentered",nullptr);
|
||||||
SetTimer(window,987,10,[](HWND owner,UINT,UINT_PTR id,DWORD) {
|
SetTimer(window,987,10,[](HWND owner,UINT,UINT_PTR id,DWORD) {
|
||||||
EnumThreadWindows(GetCurrentThreadId(),[](HWND popup,LPARAM parent)->BOOL {
|
EnumThreadWindows(GetCurrentThreadId(),[](HWND popup,LPARAM parent)->BOOL {
|
||||||
@@ -149,8 +697,45 @@ int main()
|
|||||||
require(IsWindow(window),"Escape does not close the screenshot editor");
|
require(IsWindow(window),"Escape does not close the screenshot editor");
|
||||||
SendMessageW(window,WM_CLOSE,0,0);
|
SendMessageW(window,WM_CLOSE,0,0);
|
||||||
require(!IsWindow(window),"copied screenshot closes without confirmation");
|
require(!IsWindow(window),"copied screenshot closes without confirmation");
|
||||||
|
static bool published=false;
|
||||||
|
static bool notificationAccepted=false;
|
||||||
|
static HWND notificationWindow{};
|
||||||
|
static bool expectHidden=false;
|
||||||
|
app.notify=[](const ks::Clip &) {
|
||||||
|
if(!published) throw std::runtime_error("notification preceded clipboard publication");
|
||||||
|
if(expectHidden && IsWindowVisible(notificationWindow))
|
||||||
|
throw std::runtime_error("selection overlay is still visible when notification is sent");
|
||||||
|
return notificationAccepted;
|
||||||
|
};
|
||||||
|
for(bool autoOpen : {true,false}) for (bool accepted : {true, false}) {
|
||||||
|
app.autoOpenEditor=autoOpen;
|
||||||
|
published=false;
|
||||||
|
notificationAccepted=accepted;
|
||||||
|
ks::Capture selection(app);
|
||||||
|
selection.image=std::make_unique<ks::Surface>(100,100);
|
||||||
|
PatBlt(selection.image->dc,0,0,100,100,WHITENESS);
|
||||||
|
selection.start={10,20}; selection.end={70,60};
|
||||||
|
auto overlay=CreateWindowW(cls.lpszClassName,L"Selection test",WS_POPUP,0,0,100,100,app.window,nullptr,app.instance,&selection);
|
||||||
|
notificationWindow=overlay; expectHidden=!autoOpen;
|
||||||
|
ShowWindow(overlay,SW_SHOWNOACTIVATE);
|
||||||
|
ks::finishSelection(overlay,selection,[](HWND,const ks::Clip &clip)->DWORD {
|
||||||
|
auto pixels=ks::decodeImage(clip);
|
||||||
|
if(pixels.width!=60 || pixels.height!=40) throw std::runtime_error("automatic copy did not crop selection");
|
||||||
|
published=true; return 0;
|
||||||
|
});
|
||||||
|
if(!autoOpen && accepted) {
|
||||||
|
require(published && !IsWindow(overlay),"notification-first closes selection after successful submission");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
require(published && selection.copied && IsWindowVisible(overlay) && selection.editing,
|
||||||
|
"selection opens editor whether notification is accepted or rejected");
|
||||||
|
require(selection.image->width==60 && selection.image->height==40,
|
||||||
|
"automatically opened editor contains the selected region");
|
||||||
|
SendMessageW(overlay,WM_CLOSE,0,0);
|
||||||
|
require(!IsWindow(overlay),"automatically copied screenshot closes without confirmation");
|
||||||
|
}
|
||||||
DestroyWindow(app.window); app.window=nullptr;
|
DestroyWindow(app.window); app.window=nullptr;
|
||||||
std::filesystem::remove_all(temp);
|
CoUninitialize();
|
||||||
std::cout<<"Passed screenshot editor UI integration checks\n";
|
std::cout<<"Passed screenshot editor UI integration checks\n";
|
||||||
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; return 1; }
|
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; return 1; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#include "ui_scroll.h"
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
int forwarded{};
|
||||||
|
long long totalDelta{};
|
||||||
|
LRESULT CALLBACK observe(HWND w,UINT msg,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
|
||||||
|
{
|
||||||
|
if(msg==WM_MOUSEWHEEL) { ++forwarded; totalDelta+=GET_WHEEL_DELTA_WPARAM(wp); }
|
||||||
|
return DefSubclassProc(w,msg,wp,lp);
|
||||||
|
}
|
||||||
|
void require(bool ok,const char *message) { if(!ok) throw std::runtime_error(message); }
|
||||||
|
void wheel(HWND w,int delta) { SendMessageW(w,WM_MOUSEWHEEL,MAKEWPARAM(0,(WORD)(short)delta),0); }
|
||||||
|
void flush(HWND w) { SendMessageW(w,WM_TIMER,ks::ScrollFrameTimer,0); }
|
||||||
|
}
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
INITCOMMONCONTROLSEX init{sizeof(init),ICC_STANDARD_CLASSES|ICC_LISTVIEW_CLASSES};
|
||||||
|
InitCommonControlsEx(&init);
|
||||||
|
auto instance=GetModuleHandleW(nullptr);
|
||||||
|
auto parent=CreateWindowW(L"STATIC",L"Scroll regression",WS_OVERLAPPEDWINDOW,0,0,500,400,nullptr,nullptr,instance,nullptr);
|
||||||
|
try {
|
||||||
|
auto list=CreateWindowW(L"LISTBOX",L"",WS_CHILD|WS_VSCROLL,0,0,300,200,parent,nullptr,instance,nullptr);
|
||||||
|
for(int i=0;i<2000;++i) SendMessageW(list,LB_ADDSTRING,0,(LPARAM)L"row");
|
||||||
|
SendMessageW(list,LB_SETCURSEL,5,0);
|
||||||
|
SendMessageW(list,LB_SETTOPINDEX,0,0);
|
||||||
|
ks::enableWheelBatching(list,true);
|
||||||
|
for(int i=0;i<1000;++i) wheel(list,-120);
|
||||||
|
require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"burst is deferred to one frame");
|
||||||
|
flush(list);
|
||||||
|
UINT lines{}; SystemParametersInfoW(SPI_GETWHEELSCROLLLINES,0,&lines,0);
|
||||||
|
require(!lines || SendMessageW(list,LB_GETTOPINDEX,0,0)>0,"burst moves list");
|
||||||
|
require(SendMessageW(list,LB_GETCURSEL,0,0)==5,"scroll does not change selection");
|
||||||
|
for(int i=0;i<1000;++i) wheel(list,120);
|
||||||
|
flush(list);
|
||||||
|
require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"reverse burst clamps at top");
|
||||||
|
wheel(list,-60); flush(list);
|
||||||
|
require(SendMessageW(list,LB_GETTOPINDEX,0,0)==0,"partial wheel step is retained");
|
||||||
|
wheel(list,-60); flush(list);
|
||||||
|
require(!lines || SendMessageW(list,LB_GETTOPINDEX,0,0)>0,"partial steps combine across frames");
|
||||||
|
DestroyWindow(list);
|
||||||
|
for(auto cls:{L"EDIT",WC_LISTVIEWW,L"COMBOBOX",L"STATIC"}) {
|
||||||
|
auto w=CreateWindowW(cls,L"",WS_CHILD|WS_VSCROLL,0,0,300,200,parent,nullptr,instance,nullptr);
|
||||||
|
require(w!=nullptr,"native scroll control created");
|
||||||
|
SetWindowSubclass(w,observe,1,0); ks::enableWheelBatching(w);
|
||||||
|
forwarded=0; totalDelta=0;
|
||||||
|
for(int i=0;i<100;++i) wheel(w,-120);
|
||||||
|
require(forwarded==0,"native wheel burst is batched");
|
||||||
|
flush(w);
|
||||||
|
require(forwarded==1 && totalDelta==-12000,"one native dispatch preserves burst distance");
|
||||||
|
wheel(w,60); flush(w);
|
||||||
|
require(forwarded==2 && totalDelta==-11940,"native partial steps are preserved");
|
||||||
|
forwarded=0; totalDelta=0;
|
||||||
|
for(int i=0;i<400;++i) wheel(w,-120);
|
||||||
|
flush(w); flush(w);
|
||||||
|
require(forwarded==2 && totalDelta==-48000,"large native burst is not truncated to 16 bits");
|
||||||
|
SendMessageW(w,WM_TIMER,ks::ScrollIdleTimer,0);
|
||||||
|
DestroyWindow(w);
|
||||||
|
}
|
||||||
|
DestroyWindow(parent);
|
||||||
|
std::cout<<"Passed shared wheel batching regressions (list, edit, table, combo, window)\n";
|
||||||
|
return 0;
|
||||||
|
} catch(const std::exception &e) {
|
||||||
|
DestroyWindow(parent); std::cerr<<e.what()<<'\n'; return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
#pragma once
|
||||||
|
inline void testStrokeImprovement() {
|
||||||
|
auto require=[](bool ok,const char *message){if(!ok) throw std::runtime_error(message);};
|
||||||
|
for(double angle:{0.0,.37,1.57079632679,2.8}) for(bool reverse:{false,true}) {
|
||||||
|
std::vector<POINT> p;
|
||||||
|
for(int i=0;i<=20;++i) {
|
||||||
|
double x=i*10,y=i==0 || i==20?0:(i%3)-1;
|
||||||
|
p.push_back({LONG(std::lround(300+x*std::cos(angle)-y*std::sin(angle))),
|
||||||
|
LONG(std::lround(300+x*std::sin(angle)+y*std::cos(angle)))});
|
||||||
|
}
|
||||||
|
if(reverse) std::reverse(p.begin(),p.end());
|
||||||
|
bool line=false; ks::ScreenshotShape shape{};
|
||||||
|
auto result=ks::improveScreenshotStroke(p,&shape,&line);
|
||||||
|
require(line && shape==ks::ScreenshotShape::None && result.size()==2 &&
|
||||||
|
result.front().x==p.front().x && result.front().y==p.front().y &&
|
||||||
|
result.back().x==p.back().x && result.back().y==p.back().y,
|
||||||
|
"recognize noisy lines at arbitrary angles and preserve directed endpoints");
|
||||||
|
}
|
||||||
|
for(auto p:std::vector<std::vector<POINT>>{
|
||||||
|
{{0,0},{50,1},{100,0}}, {{0,0},{0,0},{50,0},{100,0}}}) {
|
||||||
|
bool line=false;
|
||||||
|
require(ks::improveScreenshotStroke(p,nullptr,&line).size()==2 && line,
|
||||||
|
"recognize sparsely sampled lines and repeated mouse positions");
|
||||||
|
}
|
||||||
|
for(auto p:std::vector<std::vector<POINT>>{
|
||||||
|
{{0,0},{8,1},{16,0}}, {{0,0},{50,0},{40,0},{80,0},{100,0}},
|
||||||
|
{{0,0},{25,8},{50,12},{75,8},{100,0}},
|
||||||
|
{{0,0},{50,0},{51,10},{52,0},{100,0}}, {{0,0},{0,0},{0,0}}}) {
|
||||||
|
bool line=true;
|
||||||
|
ks::improveScreenshotStroke(p,nullptr,&line);
|
||||||
|
require(!line,"do not straighten short strokes, retracing, curves, spikes or stationary input");
|
||||||
|
}
|
||||||
|
auto path=[](std::vector<POINT> corners) {
|
||||||
|
std::vector<POINT> p;
|
||||||
|
for(size_t i=1;i<corners.size();++i) for(int j=0;j<25;++j) {
|
||||||
|
double t=j/25.0;
|
||||||
|
p.push_back({LONG(std::lround(corners[i-1].x*(1-t)+corners[i].x*t)),
|
||||||
|
LONG(std::lround(corners[i-1].y*(1-t)+corners[i].y*t))});
|
||||||
|
}
|
||||||
|
p.push_back(corners.back()); return p;
|
||||||
|
};
|
||||||
|
for(bool tilted:{false,true}) {
|
||||||
|
auto p=path({{40,40},{240,40},{240,140},{40,140},{40,40}});
|
||||||
|
if(tilted) for(auto &q:p) {double x=q.x,y=q.y;q={LONG(std::lround(x*.866-y*.5+100)),LONG(std::lround(x*.5+y*.866))};}
|
||||||
|
for(size_t i=1;i+1<p.size();++i) p[i].y+=int(i%3)-1;
|
||||||
|
auto result=ks::improveScreenshotStroke(p);
|
||||||
|
require(result.size()==5,"recognize noisy and tilted rectangles");
|
||||||
|
double ax=result[1].x-result[0].x,ay=result[1].y-result[0].y;
|
||||||
|
double bx=result[2].x-result[1].x,by=result[2].y-result[1].y;
|
||||||
|
require(std::abs(ax*bx+ay*by)<300,"rectangle has perpendicular sides");
|
||||||
|
}
|
||||||
|
auto triangle=ks::improveScreenshotStroke(path({{130,30},{240,180},{30,180},{130,30}}));
|
||||||
|
for(auto corners:std::vector<std::vector<POINT>>{
|
||||||
|
{{435,172},{455,135},{481,76},{514,78},{567,78},{605,76},{631,103},{652,135},{681,171},{609,172},{539,174},{435,172}},
|
||||||
|
{{531,311},{553,280},{582,235},{616,236},{672,233},{686,264},{693,287},{710,318},{640,314},{531,311}},
|
||||||
|
{{70,40},{160,40},{220,150},{20,150},{70,40}}})
|
||||||
|
for(double angle:{0.0,.6,2.2}) for(bool reverse:{false,true}) {
|
||||||
|
auto p=path(corners);
|
||||||
|
for(auto &q:p) { double x=q.x,y=q.y; q={LONG(std::lround(x*std::cos(angle)-y*std::sin(angle)+700)),
|
||||||
|
LONG(std::lround(x*std::sin(angle)+y*std::cos(angle)+700))}; }
|
||||||
|
if(reverse) std::reverse(p.begin(),p.end());
|
||||||
|
ks::ScreenshotShape kind{}; auto result=ks::improveScreenshotStroke(p,&kind);
|
||||||
|
require(kind==ks::ScreenshotShape::Trapezoid && result.size()==5,"recognize hand-drawn and tilted trapezoids");
|
||||||
|
double ax=result[1].x-result[0].x,ay=result[1].y-result[0].y;
|
||||||
|
double bx=result[3].x-result[2].x,by=result[3].y-result[2].y;
|
||||||
|
require(std::abs(ax*by-ay*bx)/(std::hypot(ax,ay)*std::hypot(bx,by))<.025,"recognized trapezoid bases are parallel");
|
||||||
|
}
|
||||||
|
// Approximate the reported skewed square, including its small closing gap.
|
||||||
|
auto unevenBox=path({{662,297},{663,213},{659,199},{659,181},{691,181},{718,189},
|
||||||
|
{753,195},{786,195},{775,217},{771,249},{771,289},{766,313},
|
||||||
|
{745,311},{716,304},{689,300},{671,300}});
|
||||||
|
for(double factor:{.5,1.0,2.5}) for(bool reverse:{false,true}) {
|
||||||
|
auto p=unevenBox;
|
||||||
|
for(auto &q:p) {q.x=LONG(std::lround((q.x-650)*factor+50));q.y=LONG(std::lround((q.y-170)*factor+50));}
|
||||||
|
if(reverse) std::reverse(p.begin(),p.end());
|
||||||
|
ks::ScreenshotShape kind{};
|
||||||
|
auto result=ks::improveScreenshotStroke(p,&kind);
|
||||||
|
require(kind==ks::ScreenshotShape::Rectangle && result.size()==5,"uneven square must not become a circle");
|
||||||
|
}
|
||||||
|
require(triangle.size()==4,"recognize triangle");
|
||||||
|
for(double ry:{50.0,90.0}) {
|
||||||
|
std::vector<POINT> p;
|
||||||
|
for(int i=0;i<=120;++i) {double a=i*2*3.141592653589793/120;
|
||||||
|
p.push_back({LONG(std::lround(150+90*std::cos(a))),LONG(std::lround(150+ry*std::sin(a)))});}
|
||||||
|
auto result=ks::improveScreenshotStroke(p);
|
||||||
|
require(result.size()==97 && result.front().x==result.back().x && result.front().y==result.back().y,
|
||||||
|
"recognize ellipse and circle as closed outlines");
|
||||||
|
}
|
||||||
|
auto open=path({{20,20},{200,20},{200,150},{60,150}});
|
||||||
|
// Hand-traced approximations of the uneven loops in the reported screenshot.
|
||||||
|
for(auto corners:std::vector<std::vector<POINT>>{
|
||||||
|
{{424,408},{449,407},{474,418},{487,439},{488,461},{475,485},{453,488},{432,479},{412,458},{410,435},{416,418},{431,409}},
|
||||||
|
{{352,474},{373,482},{389,501},{392,518},{378,529},{351,531},{331,519},{324,504},{326,486},{339,477},{352,474}},
|
||||||
|
{{246,236},{221,242},{192,256},{166,271},{151,289},{154,303},{172,316},{195,315},{226,305},{246,291},{250,272},{252,252},{246,236}}}) {
|
||||||
|
auto result=ks::improveScreenshotStroke(path(corners));
|
||||||
|
require(result.size()==97,"recognize uneven hand-drawn loops from screenshot");
|
||||||
|
}
|
||||||
|
for(double size:{35.0,100.0,300.0}) for(double aspect:{.5,1.0}) for(int direction:{-1,1}) {
|
||||||
|
std::vector<POINT> p;
|
||||||
|
for(int i=0;i<=120;++i) {
|
||||||
|
double a=direction*(.3+i*5.85/120),r=1+.07*std::sin(3*a)+.035*std::cos(7*a);
|
||||||
|
double x=size*r*std::cos(a),y=size*aspect*r*std::sin(a);
|
||||||
|
p.push_back({LONG(std::lround(500+x*.866-y*.5)),LONG(std::lround(500+x*.5+y*.866))});
|
||||||
|
}
|
||||||
|
require(ks::improveScreenshotStroke(p).size()==97,"recognize wobbly tilted ovals with closing gaps at different sizes");
|
||||||
|
}
|
||||||
|
for(double turns:{.72,1.8}) {
|
||||||
|
std::vector<POINT> p;
|
||||||
|
for(int i=0;i<=120;++i) {double a=i*turns*2*3.141592653589793/120;
|
||||||
|
p.push_back({LONG(std::lround(150+90*std::cos(a))),LONG(std::lround(150+70*std::sin(a)))});}
|
||||||
|
auto result=ks::improveScreenshotStroke(p);
|
||||||
|
require(result.size()!=97,"do not close broad arcs or repeated loops into ovals");
|
||||||
|
}
|
||||||
|
auto smoothed=ks::improveScreenshotStroke(open);
|
||||||
|
require(smoothed.size()>5 && smoothed.front().x==20 && smoothed.back().x==60,"open outline stays open");
|
||||||
|
auto scribble=path({{20,20},{200,150},{200,20},{20,150},{20,20}});
|
||||||
|
require(ks::improveScreenshotStroke(scribble).size()>97,"crossed scribble is not a shape");
|
||||||
|
require(ks::improveScreenshotStroke({{1,1},{1,1},{1,1},{1,1},{1,1}}).empty(),"stationary stroke unchanged");
|
||||||
|
std::vector<POINT> jitter;
|
||||||
|
for(int i=0;i<=100;++i) jitter.push_back({20+i*2,100+(i%2?2:-2)});
|
||||||
|
auto smooth=ks::improveScreenshotStroke(jitter);
|
||||||
|
double deviation=0; for(auto p:smooth) deviation+=std::abs(p.y-100);
|
||||||
|
require(deviation/smooth.size()<1.5 && smooth.front().y==jitter.front().y && smooth.back().y==jitter.back().y,
|
||||||
|
"smoothing reduces jitter and preserves endpoints");
|
||||||
|
}
|
||||||
+328
@@ -0,0 +1,328 @@
|
|||||||
|
#include "sync.h"
|
||||||
|
#include "json.hpp"
|
||||||
|
#include <iostream>
|
||||||
|
using namespace ks;
|
||||||
|
using Json = nlohmann::json;
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
int checks{};
|
||||||
|
void require(bool condition, const char *message)
|
||||||
|
{
|
||||||
|
if (!condition) throw std::runtime_error(message);
|
||||||
|
++checks;
|
||||||
|
}
|
||||||
|
template<class F> void rejects(F f, const char *message)
|
||||||
|
{
|
||||||
|
bool rejected = false;
|
||||||
|
try { f(); } catch (const std::exception &) { rejected = true; }
|
||||||
|
require(rejected, message);
|
||||||
|
}
|
||||||
|
Bytes bytes(const Json &j) { auto s = j.dump(); return {s.begin(), s.end()}; }
|
||||||
|
Json parse(const Bytes &b) { return Json::parse(b.begin(), b.end()); }
|
||||||
|
struct FakeProvider : SyncProvider
|
||||||
|
{
|
||||||
|
SyncDocument remote;
|
||||||
|
int writes{};
|
||||||
|
bool race{};
|
||||||
|
SyncDocument read() override { return remote; }
|
||||||
|
uint64_t write(const Bytes &b, uint64_t expected) override
|
||||||
|
{
|
||||||
|
if (race) ++remote.revision;
|
||||||
|
if (expected != remote.revision) throw std::runtime_error("CAS conflict");
|
||||||
|
++writes; remote = {remote.revision + 1, b}; return remote.revision;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
auto root = std::filesystem::temp_directory_path() / (L"ks-sync-test-" + std::to_wstring(GetCurrentProcessId()));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
std::filesystem::create_directories(root);
|
||||||
|
require(!syncVisible(root), "hidden without opt-in file");
|
||||||
|
require(!loadSyncConfig(root).enabled, "disabled by default");
|
||||||
|
SyncConfig config; config.server = L"https://bao.example.com"; config.username = L"person"; config.enabled = true;
|
||||||
|
validateSyncConfig(config);
|
||||||
|
for (const auto *server : {L"http://bao.example.com", L"https://user:pass@bao.example.com", L"https://bao.example.com/x", L"https://bao.example.com?x=1"})
|
||||||
|
{
|
||||||
|
auto invalid = config; invalid.server = server;
|
||||||
|
rejects([&] { validateSyncConfig(invalid); }, "reject unsafe endpoint");
|
||||||
|
}
|
||||||
|
auto invalid = config; invalid.path = L"../other";
|
||||||
|
rejects([&] { validateSyncConfig(invalid); }, "reject traversal");
|
||||||
|
invalid = config; invalid.provider = L"unknown";
|
||||||
|
rejects([&] { validateSyncConfig(invalid); }, "reject unsupported provider");
|
||||||
|
saveSyncConfig(root, config);
|
||||||
|
require(syncVisible(root) && loadSyncConfig(root) == config, "connection round trip");
|
||||||
|
auto partialConfig = config; partialConfig.keepLocalLayouts = true;
|
||||||
|
saveSyncConfig(root, partialConfig);
|
||||||
|
require(loadSyncConfig(root).keepLocalLayouts, "partial sync mode survives restart");
|
||||||
|
saveSyncConfig(root, config);
|
||||||
|
auto rawConfig = parse(readFile(root / L"sync.json", 16384));
|
||||||
|
require(!rawConfig.contains("password") && !rawConfig.contains("token"), "no secrets in config");
|
||||||
|
Settings local;
|
||||||
|
local.autostart = true; local.elevatedAutostart = true; local.tesseractPath = L"C:\\private\\tesseract.exe";
|
||||||
|
local.history = false;
|
||||||
|
local.snippets.push_back({L"test", L"private snippet"});
|
||||||
|
auto base = sharedSyncSettings(local);
|
||||||
|
Settings ordered = local, reordered = local;
|
||||||
|
ordered.directIds = {L"00000409", L"00000422"}; ordered.direct = {L"Ctrl+1", L"Ctrl+2"};
|
||||||
|
reordered.directIds = {L"00000422", L"00000409"}; reordered.direct = {L"Ctrl+2", L"Ctrl+1"};
|
||||||
|
require(sharedSyncSettings(ordered) == sharedSyncSettings(reordered), "layout enumeration does not cause sync churn");
|
||||||
|
auto j = parse(base);
|
||||||
|
require(!j.contains("startWithSystem") && !j.contains("tesseractPath") && !j.contains("historyEnabled"), "local-only fields excluded");
|
||||||
|
auto restored = restoreSyncSettings(base, local, {});
|
||||||
|
require(restored.autostart && restored.elevatedAutostart && !restored.history && restored.tesseractPath == local.tesseractPath, "preserve device settings");
|
||||||
|
auto hostile = j; hostile["startWithSystem"] = false;
|
||||||
|
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject injected device setting");
|
||||||
|
hostile = j; hostile["schemaVersion"] = 2;
|
||||||
|
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "reject future schema");
|
||||||
|
hostile = j; hostile["layouts"] = Json::array({"00000422"});
|
||||||
|
rejects([&] { restoreSyncSettings(bytes(hostile), local, {}); }, "missing layouts block destructive normalization");
|
||||||
|
auto left = j, right = j;
|
||||||
|
left["convertLastWord"] = false; right["switchSystemLayout"] = false;
|
||||||
|
auto merged = parse(mergeSyncSettings(base, bytes(left), bytes(right)));
|
||||||
|
require(merged["convertLastWord"] == false && merged["switchSystemLayout"] == false, "merge independent edits");
|
||||||
|
left = right = j; left["autoCorrectMinLength"] = 4; right["autoCorrectMinLength"] = 5;
|
||||||
|
rejects([&] { mergeSyncSettings(base, bytes(left), bytes(right)); }, "same field conflict");
|
||||||
|
left = right = j; left["snippets"].push_back({{"trigger", "aaa"}, {"text", "one"}});
|
||||||
|
right["snippets"].push_back({{"trigger", "bbb"}, {"text", "two"}});
|
||||||
|
require(parse(mergeSyncSettings(base, bytes(left), bytes(right)))["snippets"].size() == 3, "independent snippet additions merge");
|
||||||
|
require(parse(mergeSyncSettings({}, bytes(left), base))["snippets"].size() == 2,
|
||||||
|
"first sync merges independent additions without choosing a whole profile");
|
||||||
|
require(parse(mergeSyncSettings({}, base, base)) == j, "identical first sync safe");
|
||||||
|
{
|
||||||
|
auto l = j, r = j;
|
||||||
|
l["autoCorrectMinLength"] = 4; r["autoCorrectMinLength"] = 5;
|
||||||
|
l["autoCorrectNever"].push_back("localword");
|
||||||
|
r["autoCorrectNever"].push_back("remoteword");
|
||||||
|
auto plan = mergeSyncProfile({}, bytes(l), bytes(r));
|
||||||
|
require(plan.conflicts.size() == 1 && parse(plan.local)["autoCorrectNever"].size() == 2 &&
|
||||||
|
parse(plan.remote)["autoCorrectNever"].size() == 2,
|
||||||
|
"first sync merges lists while preserving scalar conflicts");
|
||||||
|
require(parse(plan.local)["autoCorrectMinLength"] == 4 && parse(plan.remote)["autoCorrectMinLength"] == 5,
|
||||||
|
"first sync never guesses between different scalar values");
|
||||||
|
auto again = mergeSyncProfile(plan.base, plan.local, plan.remote);
|
||||||
|
require(again.conflicts.size() == 1, "first-sync conflicts survive saving the ancestor");
|
||||||
|
auto resolved = mergeSyncProfile(plan.base, plan.local, plan.remote, {{plan.conflicts[0], false}});
|
||||||
|
require(resolved.conflicts.empty() && parse(resolved.local)["autoCorrectMinLength"] == 5,
|
||||||
|
"first-sync conflict can be resolved independently");
|
||||||
|
}
|
||||||
|
require(SyncIntervalMs == 600000, "background interval is ten minutes");
|
||||||
|
{
|
||||||
|
auto l = j, r = j;
|
||||||
|
l["autoCorrectReplacements"]["helo"] = "hello";
|
||||||
|
r["autoCorrectReplacements"]["wrold"] = "world";
|
||||||
|
auto paired = mergeSyncSettings(base, bytes(l), bytes(r));
|
||||||
|
require(parse(paired)["autoCorrectReplacements"].size() == 2, "independent replacement pairs merge");
|
||||||
|
l = r = parse(paired);
|
||||||
|
l["autoCorrectReplacements"].erase("helo");
|
||||||
|
r["autoCorrectReplacements"]["wrold"] = "worlds";
|
||||||
|
auto removed = mergeSyncSettings(paired, bytes(l), bytes(r));
|
||||||
|
auto restored = restoreSyncSettings(removed, local, {});
|
||||||
|
require(restored.autoReplacements.size() == 1 && restored.autoReplacements.at(L"wrold") == L"worlds",
|
||||||
|
"replacement deletion merges with an independent edit and remains loadable");
|
||||||
|
r["autoCorrectReplacements"]["helo"] = "help";
|
||||||
|
require(mergeSyncProfile(paired, bytes(l), bytes(r)).conflicts.size() == 1,
|
||||||
|
"replacement deletion versus edit is a conflict");
|
||||||
|
auto legacy = j;
|
||||||
|
for (auto key : {"autoCorrectReplace", "autoCorrectTypos", "autoCorrectReplacements"}) legacy.erase(key);
|
||||||
|
auto migrated = restoreSyncSettings(bytes(legacy), local, {});
|
||||||
|
require(!migrated.autoReplace && !migrated.autoTypos && migrated.autoReplacements.empty(),
|
||||||
|
"older synchronized profiles default new typo modes off");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
auto l = j, r = j;
|
||||||
|
l["autoCorrectMinLength"] = 4; r["autoCorrectMinLength"] = 5;
|
||||||
|
l["convertLastWord"] = false; r["switchSystemLayout"] = false;
|
||||||
|
auto plan = mergeSyncProfile(base, bytes(l), bytes(r));
|
||||||
|
require(plan.conflicts.size() == 1, "one conflict reported independently");
|
||||||
|
require(parse(plan.local)["switchSystemLayout"] == false && parse(plan.remote)["convertLastWord"] == false,
|
||||||
|
"nonconflicting changes reach both copies");
|
||||||
|
require(parse(plan.local)["autoCorrectMinLength"] == 4 && parse(plan.remote)["autoCorrectMinLength"] == 5,
|
||||||
|
"unresolved scalar retains both values");
|
||||||
|
require(parse(plan.base)["autoCorrectMinLength"] == j["autoCorrectMinLength"], "ancestor retained for unresolved setting");
|
||||||
|
saveSyncBase(root, config, plan.base);
|
||||||
|
auto again = mergeSyncProfile(loadSyncBase(root, config), plan.local, plan.remote);
|
||||||
|
require(again.conflicts.size() == 1, "conflict survives restart checkpoint");
|
||||||
|
auto choice = SyncChoice{plan.conflicts.front(), true};
|
||||||
|
auto resolved = mergeSyncProfile(plan.base, plan.local, plan.remote, {choice});
|
||||||
|
require(resolved.conflicts.empty() && parse(resolved.remote)["autoCorrectMinLength"] == 4,
|
||||||
|
"resolve only selected setting");
|
||||||
|
require(parse(resolved.remote)["switchSystemLayout"] == false, "choice preserves independent server change");
|
||||||
|
auto changed = parse(plan.remote); changed["autoCorrectMinLength"] = 6;
|
||||||
|
require(mergeSyncProfile(plan.base, plan.local, bytes(changed), {choice}).conflicts.size() == 1,
|
||||||
|
"stale conflict choice never overwrites new server value");
|
||||||
|
FakeProvider partial; partial.remote = {7, bytes(r)};
|
||||||
|
auto result = synchronize(partial, base, bytes(l), SyncAction::Automatic);
|
||||||
|
require(result.success && result.conflicts.size() == 1 && partial.writes == 1,
|
||||||
|
"partial synchronization commits independent fields with CAS");
|
||||||
|
auto encoded = encodeSyncChoices({choice});
|
||||||
|
require(decodeSyncChoices(encoded).front().conflict.key == choice.conflict.key, "choice IPC round trip");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
auto id = j["snippets"][0]["id"].get<std::string>();
|
||||||
|
auto l = j, r = j;
|
||||||
|
l["snippets"][0]["trigger"] = "renamed";
|
||||||
|
r["snippets"][0]["text"] = "edited remotely";
|
||||||
|
auto merged = parse(mergeSyncSettings(base, bytes(l), bytes(r)));
|
||||||
|
require(merged["snippets"][0]["id"] == id && merged["snippets"][0]["trigger"] == "renamed" &&
|
||||||
|
merged["snippets"][0]["text"] == "edited remotely", "stable ID merges rename and text edit");
|
||||||
|
l = r = j; l["snippets"] = Json::array(); l["snippetTombstones"] = Json::array({id});
|
||||||
|
auto deletion = mergeSyncProfile(base, bytes(l), bytes(r));
|
||||||
|
require(deletion.conflicts.empty() && parse(deletion.remote)["snippets"].empty(), "delete beats unchanged snippet");
|
||||||
|
require(parse(deletion.remote)["snippetTombstones"] == Json::array({id}), "deletion marker retained");
|
||||||
|
auto offline = mergeSyncProfile(base, bytes(r), deletion.remote);
|
||||||
|
require(offline.conflicts.empty() && parse(offline.local)["snippets"].empty(), "offline copy cannot resurrect deletion");
|
||||||
|
r["snippets"][0]["text"] = "offline edit";
|
||||||
|
auto conflict = mergeSyncProfile(base, bytes(l), bytes(r));
|
||||||
|
require(conflict.conflicts.size() == 1, "delete versus edit is one snippet conflict");
|
||||||
|
auto remove = mergeSyncProfile(conflict.base, conflict.local, conflict.remote, {{conflict.conflicts[0], true}});
|
||||||
|
require(remove.conflicts.empty() && parse(remove.remote)["snippets"].empty(), "choose deletion");
|
||||||
|
auto keep = mergeSyncProfile(conflict.base, conflict.local, conflict.remote, {{conflict.conflicts[0], false}});
|
||||||
|
require(keep.conflicts.empty() && parse(keep.local)["snippetTombstones"].empty() &&
|
||||||
|
parse(keep.local)["snippets"][0]["text"] == "offline edit", "choose edited snippet removes tombstone");
|
||||||
|
require(mergeSyncProfile(base, bytes(l), bytes(l)).conflicts.empty(), "both delete converges");
|
||||||
|
auto legacy = j; legacy.erase("snippetTombstones"); legacy["snippets"][0].erase("id");
|
||||||
|
auto migrated = parse(mergeSyncSettings(bytes(legacy), base, bytes(legacy)));
|
||||||
|
require(migrated["snippets"][0]["id"] == id, "legacy profiles migrate to identical IDs");
|
||||||
|
auto roundtrip = decodeSettings(encodeSettings(restored), {});
|
||||||
|
require(snippetId(roundtrip.snippets.front()) == wide(id), "ID survives settings serialization");
|
||||||
|
require(newSnippetId() != newSnippetId(), "new snippet IDs are unique");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
auto l = j, r = j;
|
||||||
|
l["snippets"].push_back({{"id", "new-a"}, {"trigger", "same"}, {"text", "A"}});
|
||||||
|
r["snippets"].push_back({{"id", "new-b"}, {"trigger", "same"}, {"text", "B"}});
|
||||||
|
auto collision = mergeSyncProfile(base, bytes(l), bytes(r));
|
||||||
|
require(collision.conflicts.size() == 2, "duplicate new triggers expose individual choices");
|
||||||
|
auto resolved = mergeSyncProfile(collision.base, collision.local, collision.remote,
|
||||||
|
{{collision.conflicts[0], true}, {collision.conflicts[1], true}});
|
||||||
|
require(resolved.conflicts.empty() && parse(resolved.remote)["snippets"].size() == 2, "choose one of colliding additions");
|
||||||
|
l = r = j; l["toggleHotkey"] = "Ctrl+Alt+F11"; r["caseHotkey"] = "Ctrl+Alt+F11";
|
||||||
|
require(mergeSyncProfile(base, bytes(l), bytes(r)).conflicts.size() == 2, "cross-field shortcut collisions exposed");
|
||||||
|
l = r = j; l["excludedApps"].push_back("local-app"); r["excludedApps"].push_back("remote-app");
|
||||||
|
require(parse(mergeSyncSettings(base, bytes(l), bytes(r)))["excludedApps"].size() == j["excludedApps"].size() + 2,
|
||||||
|
"independent exclusion edits merge");
|
||||||
|
}
|
||||||
|
FakeProvider provider;
|
||||||
|
rejects([&] { synchronize(provider, {}, base, SyncAction::Automatic); }, "empty server never auto-populated");
|
||||||
|
auto result = synchronize(provider, {}, base, SyncAction::Upload);
|
||||||
|
require(result.revision == 1 && provider.writes == 1, "initial upload CAS zero");
|
||||||
|
result = synchronize(provider, base, base, SyncAction::Automatic);
|
||||||
|
require(provider.writes == 1 && result.revision == 1, "no-op avoids versions");
|
||||||
|
result = synchronize(provider, base, bytes(left), SyncAction::Download);
|
||||||
|
require(parse(result.settings) == parse(base) && provider.writes == 1, "download never writes");
|
||||||
|
provider.race = true;
|
||||||
|
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload); }, "racing writer protected");
|
||||||
|
require(provider.writes == 1 && provider.remote.settings == base, "CAS failure preserves remote");
|
||||||
|
provider.race = false;
|
||||||
|
{
|
||||||
|
auto remoteProfile = j;
|
||||||
|
remoteProfile["layouts"] = Json::array({"win:00000422"});
|
||||||
|
remoteProfile["layoutHotkeys"] = Json::array({{{"layoutId", "win:00000422"}, {"hotkey", "Ctrl+Alt+9"}}});
|
||||||
|
remoteProfile["autoCorrectNever"].push_back("remoteword");
|
||||||
|
FakeProvider partial;
|
||||||
|
partial.remote = {3, bytes(remoteProfile)};
|
||||||
|
auto validate = [&](const Bytes &b) { restoreSyncSettings(b, local, {}, true); };
|
||||||
|
auto result = synchronize(partial, {}, base, SyncAction::Automatic, validate, {}, true);
|
||||||
|
auto restored = restoreSyncSettings(result.settings, local, {}, true);
|
||||||
|
require(restored.cycle == local.cycle && restored.direct == local.direct &&
|
||||||
|
restored.autoNever == std::vector<std::wstring>{L"remoteword"},
|
||||||
|
"partial sync restores other settings and keeps local layout configuration");
|
||||||
|
auto next = sharedSyncSettings(restored);
|
||||||
|
result = synchronize(partial, result.base, next, SyncAction::Automatic, validate, {}, true);
|
||||||
|
require(parse(partial.remote.settings)["layouts"] == remoteProfile["layouts"] &&
|
||||||
|
parse(partial.remote.settings)["layoutHotkeys"] == remoteProfile["layoutHotkeys"],
|
||||||
|
"repeated partial sync never deletes unavailable server layouts or shortcuts");
|
||||||
|
auto changed = parse(next); changed["autoCorrectMinLength"] = 7;
|
||||||
|
result = synchronize(partial, result.base, bytes(changed), SyncAction::Upload, validate, {}, true);
|
||||||
|
require(parse(partial.remote.settings)["autoCorrectMinLength"] == 7 &&
|
||||||
|
parse(partial.remote.settings)["layoutHotkeys"] == remoteProfile["layoutHotkeys"],
|
||||||
|
"explicit partial upload preserves server layouts too");
|
||||||
|
auto downloaded = synchronize(partial, result.base, base, SyncAction::Download, validate, {}, true);
|
||||||
|
auto downloadedSettings = restoreSyncSettings(downloaded.settings, local, {}, true);
|
||||||
|
require(downloadedSettings.autoMinLength == 7 && downloadedSettings.cycle == local.cycle &&
|
||||||
|
downloadedSettings.direct == local.direct, "partial Download applies other settings without replacing local layouts");
|
||||||
|
try { restoreSyncSettings(bytes(remoteProfile), local, {}); require(false, "full sync must identify missing layouts"); }
|
||||||
|
catch (const std::runtime_error &e) {
|
||||||
|
std::string message = e.what();
|
||||||
|
require(message.find("win:00000422") != message.npos && message.find("Keep this PC's layouts") != message.npos,
|
||||||
|
"missing-layout error identifies IDs and offers partial sync");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload,
|
||||||
|
[](const Bytes &) { throw std::runtime_error("invalid"); }); }, "validate before upload");
|
||||||
|
require(provider.writes == 1, "invalid data not uploaded");
|
||||||
|
saveSyncBase(root, config, base);
|
||||||
|
require(parse(loadSyncBase(root, config)) == j, "encrypted baseline round trip");
|
||||||
|
auto other = config; other.path = L"other/settings";
|
||||||
|
require(loadSyncBase(root, other).empty(), "baseline bound to connection");
|
||||||
|
auto encrypted = readFile(root / L"sync-base.bin", 1024 * 1024);
|
||||||
|
std::string disk(encrypted.begin(), encrypted.end());
|
||||||
|
require(disk.find("private snippet") == disk.npos, "baseline encrypted on disk");
|
||||||
|
// Exercise real Credential Manager with an isolated test target, never a user endpoint.
|
||||||
|
config.username = L"ks-sync-test-" + std::to_wstring(GetCurrentProcessId());
|
||||||
|
saveSyncPassword(config, L"test-only-password");
|
||||||
|
int logins{}, revokes{}, reads{}, writes{};
|
||||||
|
unsigned readStatus = 200, writeStatus = 200;
|
||||||
|
bool deleted = false;
|
||||||
|
auto transport = [&](const std::wstring &method, const std::wstring &path,
|
||||||
|
const std::wstring &token, const Bytes &body) -> SyncHttpResponse {
|
||||||
|
if (path == L"/v1/auth/userpass/login/" + config.username)
|
||||||
|
{
|
||||||
|
require(method == L"POST" && token.empty(), "login route and no bearer token");
|
||||||
|
require(parse(body)["password"] == "test-only-password", "password sourced from credential manager");
|
||||||
|
++logins;
|
||||||
|
return {200, bytes({{"auth", {{"client_token", "test-token"}}}})};
|
||||||
|
}
|
||||||
|
require(token == L"test-token", "authenticated requests use returned token");
|
||||||
|
if (path == L"/v1/auth/token/revoke-self")
|
||||||
|
{
|
||||||
|
require(method == L"POST", "revoke method"); ++revokes; return {204, {}};
|
||||||
|
}
|
||||||
|
require(path == L"/v1/kswitcher/data/personal/settings", "KV v2 data route");
|
||||||
|
if (method == L"GET")
|
||||||
|
{
|
||||||
|
++reads;
|
||||||
|
if (readStatus != 200 && !deleted) return {readStatus, bytes({{"errors", Json::array()}})};
|
||||||
|
return {readStatus, bytes({{"data", {
|
||||||
|
{"metadata", {{"version", 7}, {"deletion_time", deleted ? "2026-01-01" : ""}, {"destroyed", false}}},
|
||||||
|
{"data", {{"syncSchema", 1}, {"settings", j}}}}}})};
|
||||||
|
}
|
||||||
|
require(method == L"POST", "write method");
|
||||||
|
auto request = parse(body);
|
||||||
|
require(request["options"]["cas"] == 7, "write includes exact CAS revision");
|
||||||
|
require(request["data"]["syncSchema"] == 2 && request["data"]["settings"] == j, "versioned settings envelope");
|
||||||
|
++writes;
|
||||||
|
return {writeStatus, bytes({{"data", {{"version", 8}}}})};
|
||||||
|
};
|
||||||
|
{
|
||||||
|
auto bao = makeSyncProvider(config, transport);
|
||||||
|
auto doc = bao->read();
|
||||||
|
require(doc.revision == 7 && parse(doc.settings) == j, "read protocol response");
|
||||||
|
require(bao->write(base, 7) == 8, "write protocol response");
|
||||||
|
readStatus = 404;
|
||||||
|
require(bao->read().revision == 0, "missing profile");
|
||||||
|
deleted = true;
|
||||||
|
rejects([&] { bao->read(); }, "soft deleted is not treated as absent");
|
||||||
|
deleted = false; readStatus = 403;
|
||||||
|
rejects([&] { bao->read(); }, "permission denied is not absent");
|
||||||
|
readStatus = 302;
|
||||||
|
rejects([&] { bao->read(); }, "redirect response rejected");
|
||||||
|
writeStatus = 400;
|
||||||
|
rejects([&] { bao->write(base, 7); }, "server CAS refusal propagated");
|
||||||
|
}
|
||||||
|
require(logins == 1 && revokes == 1 && reads == 5 && writes == 2, "temporary login lifecycle");
|
||||||
|
forgetSyncPassword(config); forgetSyncPassword(config);
|
||||||
|
++checks;
|
||||||
|
std::filesystem::remove_all(root);
|
||||||
|
std::cout << checks << " sync checks passed\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (const std::exception &e)
|
||||||
|
{
|
||||||
|
std::cerr << e.what() << "\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
+302
-2
@@ -1,7 +1,9 @@
|
|||||||
#include "autocorrect.h"
|
#include "autocorrect.h"
|
||||||
|
#include "history_preview.h"
|
||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
#include "richtext.h"
|
#include "richtext.h"
|
||||||
#include "storage.h"
|
#include "storage.h"
|
||||||
|
#include "diagnostics.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <map>
|
#include <map>
|
||||||
@@ -21,6 +23,20 @@ void rejects(const std::function<void()> &f, const char *name)
|
|||||||
bool thrown = false;
|
bool thrown = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
{
|
||||||
|
std::map<std::wstring,int> previews;
|
||||||
|
std::set<std::wstring> visible;
|
||||||
|
for(int i=0;i<40;++i) previews[std::to_wstring(i)]=i;
|
||||||
|
for(int i=0;i<8;++i) visible.insert(std::to_wstring(i));
|
||||||
|
ks::trimHistoryPreviews(previews,visible);
|
||||||
|
require(previews.size()==32,"preview cache stays bounded");
|
||||||
|
for(const auto &id:visible) require(previews.contains(id),"visible preview survives eviction");
|
||||||
|
for(int i=0;i<40;++i) { auto id=std::to_wstring(i); previews[id]=i; visible.insert(id); }
|
||||||
|
ks::trimHistoryPreviews(previews,visible);
|
||||||
|
require(previews.size()==40,"tall viewport does not thrash visible previews");
|
||||||
|
visible.clear(); ks::trimHistoryPreviews(previews,visible);
|
||||||
|
require(previews.size()==32,"cache shrinks after leaving tall viewport");
|
||||||
|
}
|
||||||
f();
|
f();
|
||||||
}
|
}
|
||||||
catch (...)
|
catch (...)
|
||||||
@@ -44,6 +60,34 @@ int main(int argc, char **argv)
|
|||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
{
|
||||||
|
ks::DiagnosticJson record{{"attempt", "42"}, {"process", "editor.exe"}, {"command", "Convert / cycle"},
|
||||||
|
{"source", "English"}, {"target", "Ukrainian"}, {"outcome", "Original text remained"},
|
||||||
|
{"problem", true}, {"verification", "read-back-via-accessibility"},
|
||||||
|
{"original", "hello\n\"quoted\""}, {"expected", ks::utf8(L"привіт")}, {"actual", "hello"},
|
||||||
|
{"context", "copy\npaste"}};
|
||||||
|
auto encoded = L"2026-10-31 12:00:00 Operation " + ks::wide(record.dump());
|
||||||
|
auto rows = ks::parseOperationLog(encoded);
|
||||||
|
require(rows.size() == 1 && rows[0].process == L"editor.exe" && rows[0].problem,
|
||||||
|
"structured operation is one filterable row");
|
||||||
|
require(rows[0].details.find(L"привіт") != std::wstring::npos &&
|
||||||
|
rows[0].details.find(L"copy\r\npaste") != std::wstring::npos,
|
||||||
|
"operation details preserve Unicode and normalize multiline text");
|
||||||
|
record["problem"] = false;
|
||||||
|
record["outcome"] = "Paste verified";
|
||||||
|
rows = ks::parseOperationLog(encoded + L"\n2026-10-31 12:00:01 Operation " + ks::wide(record.dump()));
|
||||||
|
require(rows.size() == 2 && !rows[1].problem, "verified operations remain distinguishable from problems");
|
||||||
|
rows = ks::parseOperationLog(L"old incident\n first stage\n second stage\n2026-10-31 Operation {broken");
|
||||||
|
require(rows.size() == 2 && rows[0].details.find(L"second stage") != std::wstring::npos &&
|
||||||
|
rows[1].details.find(L"{broken") != std::wstring::npos,
|
||||||
|
"legacy context is grouped and malformed records remain inspectable");
|
||||||
|
require(ks::conversionReadbackOutcome(L"new", L"old", L"new") == L"Paste verified",
|
||||||
|
"expected readback verifies paste");
|
||||||
|
require(ks::conversionReadbackOutcome(L"old", L"old", L"new") == L"Original text remained",
|
||||||
|
"original readback identifies unchanged editor text");
|
||||||
|
require(ks::conversionReadbackOutcome(L"other", L"old", L"new") == L"Readback differs",
|
||||||
|
"unexpected readback is never reported as success");
|
||||||
|
}
|
||||||
require(ks::diagnosticText(L"word") == L"\"word\" units=4 truncated=0",
|
require(ks::diagnosticText(L"word") == L"\"word\" units=4 truncated=0",
|
||||||
"diagnostic word preserved");
|
"diagnostic word preserved");
|
||||||
require(ks::diagnosticText(L"\"\\\n\r\t") ==
|
require(ks::diagnosticText(L"\"\\\n\r\t") ==
|
||||||
@@ -67,8 +111,8 @@ int main(int argc, char **argv)
|
|||||||
require(diagnostics.record(L"later-failure", true, 30500).find(L"expired-context") == std::wstring::npos,
|
require(diagnostics.record(L"later-failure", true, 30500).find(L"expired-context") == std::wstring::npos,
|
||||||
"context expires after thirty seconds");
|
"context expires after thirty seconds");
|
||||||
diagnostics.record(L"oldest-context", false, 30600);
|
diagnostics.record(L"oldest-context", false, 30600);
|
||||||
for (int i = 0; i < 40; ++i)
|
for (int i = 0; i < 128; ++i)
|
||||||
diagnostics.record(L"bounded-context", false, 30600 + i);
|
diagnostics.record(L"bounded-context", false, 30600);
|
||||||
require(diagnostics.record(L"bounded-failure", true, 30700).find(L"oldest-context") == std::wstring::npos,
|
require(diagnostics.record(L"bounded-failure", true, 30700).find(L"oldest-context") == std::wstring::npos,
|
||||||
"context buffer has a fixed size");
|
"context buffer has a fixed size");
|
||||||
diagnostics.record(L"disabled-context", false, 30800);
|
diagnostics.record(L"disabled-context", false, 30800);
|
||||||
@@ -148,11 +192,38 @@ int main(int argc, char **argv)
|
|||||||
require(ks::convert(L"hello привет", en, ru) == L"руддщ привет", "mixed text preserves target words");
|
require(ks::convert(L"hello привет", en, ru) == L"руддщ привет", "mixed text preserves target words");
|
||||||
require(ks::convert(L"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words");
|
require(ks::convert(L"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words");
|
||||||
std::vector<ks::Layout> layouts{en, ru, uk};
|
std::vector<ks::Layout> layouts{en, ru, uk};
|
||||||
|
ks::Run suffixRun;
|
||||||
|
suffixRun.keys = L"snipping ";
|
||||||
|
require(suffixRun.matchSuffix(L"зштп ", layouts, en) >= 0,
|
||||||
|
"recent Cyrillic suffix matches physical keys across layouts");
|
||||||
|
require(suffixRun.matchSuffix(L"ping ", layouts, en) == 0,
|
||||||
|
"recent English suffix matches physical keys");
|
||||||
|
require(suffixRun.matchSuffix(L"other", layouts, en) < 0 &&
|
||||||
|
suffixRun.matchSuffix(L"", layouts, en) < 0 &&
|
||||||
|
suffixRun.matchSuffix(L"snipping ", layouts, en) < 0,
|
||||||
|
"suffix recovery rejects unrelated, empty and complete ranges");
|
||||||
|
suffixRun.overflow = true;
|
||||||
|
require(suffixRun.matchSuffix(L"зштп ", layouts, en) < 0,
|
||||||
|
"suffix recovery rejects overflowed tracking");
|
||||||
|
suffixRun.overflow = false;
|
||||||
|
suffixRun.keys = L"LinkedIn ";
|
||||||
|
require(suffixRun.matchSuffix(L"лувШт ", layouts, en) >= 0,
|
||||||
|
"ChatGPT partial CamelCase selection matches tracked physical suffix");
|
||||||
|
require(suffixRun.match(L"ДштлувШт ", layouts, en) >= 0 &&
|
||||||
|
suffixRun.match(L"лувШт ", layouts, en) < 0,
|
||||||
|
"CamelCase conversion still requires the complete tracked text after recovery");
|
||||||
ks::AutoCorrector corrector;
|
ks::AutoCorrector corrector;
|
||||||
ks::Settings autoSettings;
|
ks::Settings autoSettings;
|
||||||
require(!autoSettings.autoCorrect, "auto-correction defaults off");
|
require(!autoSettings.autoCorrect, "auto-correction defaults off");
|
||||||
autoSettings.cycle = {L"en", L"uk"};
|
autoSettings.cycle = {L"en", L"uk"};
|
||||||
corrector.configure(L"dictionaries", autoSettings.autoLanguages);
|
corrector.configure(L"dictionaries", autoSettings.autoLanguages);
|
||||||
|
{
|
||||||
|
auto options = autoSettings;
|
||||||
|
options.autoTypos = true;
|
||||||
|
auto typo = corrector.suggest(L"првиіт ", 2, layouts, options);
|
||||||
|
require(typo && typo->text == L"привіт " && typo->target == 2,
|
||||||
|
"bundled Ukrainian dictionary corrects a common transposition");
|
||||||
|
}
|
||||||
auto suggested = corrector.suggest(L"ghbdsn ", 0, layouts, autoSettings);
|
auto suggested = corrector.suggest(L"ghbdsn ", 0, layouts, autoSettings);
|
||||||
require(suggested && suggested->text == L"привіт " && suggested->target == 2,
|
require(suggested && suggested->text == L"привіт " && suggested->target == 2,
|
||||||
"dictionary corrects Ukrainian and retains trailing space");
|
"dictionary corrects Ukrainian and retains trailing space");
|
||||||
@@ -241,10 +312,84 @@ int main(int argc, char **argv)
|
|||||||
require(corrector.memoryBytes() < 3 * 1024 * 1024, "two dictionaries fit within 3 MiB");
|
require(corrector.memoryBytes() < 3 * 1024 * 1024, "two dictionaries fit within 3 MiB");
|
||||||
require(!ks::autoCorrectionAllowed(nullptr, autoSettings), "missing focused editor fails closed");
|
require(!ks::autoCorrectionAllowed(nullptr, autoSettings), "missing focused editor fails closed");
|
||||||
ks::AutoCorrector missing;
|
ks::AutoCorrector missing;
|
||||||
|
{
|
||||||
|
ks::AutoCorrector supplemental;
|
||||||
|
auto options = autoSettings;
|
||||||
|
options.autoLanguages = {L"en", L"uk", L"ru"};
|
||||||
|
options.cycle = {L"en", L"uk", L"ru"};
|
||||||
|
supplemental.configure(L"dictionaries", options.autoLanguages);
|
||||||
|
auto current = supplemental.suggest(L"Gjnjxys ", 0, layouts, options);
|
||||||
|
require(current && current->text == L"Поточні " && current->target == 2,
|
||||||
|
"reported title-case Ukrainian word corrects with all three dictionaries enabled");
|
||||||
|
current = supplemental.suggest(L"Поточны ", 1, layouts, options);
|
||||||
|
require(current && current->text == L"Поточні " && current->target == 2,
|
||||||
|
"reported Ukrainian word corrects from Russian layout");
|
||||||
|
require(!supplemental.suggest(L"Поточні ", 2, layouts, options),
|
||||||
|
"correct Ukrainian word remains unchanged");
|
||||||
|
for (auto [typed, expected] : {std::pair{L"ntcn ", L"тест "}, std::pair{L"vfkj ", L"мало "}})
|
||||||
|
{
|
||||||
|
auto shared = supplemental.suggest(typed, 0, layouts, options);
|
||||||
|
require(shared && shared->text == expected && shared->target == 2,
|
||||||
|
"identical Russian and Ukrainian spelling uses preferred Ukrainian layout");
|
||||||
|
}
|
||||||
|
require(!supplemental.suggest(L"lsv ", 0, layouts, options),
|
||||||
|
"different dictionary results remain ambiguous");
|
||||||
|
auto release = supplemental.suggest(L"релыз ", 1, layouts, options);
|
||||||
|
require(release && release->text == L"реліз " && release->target == 2,
|
||||||
|
"supplemental Ukrainian release word corrects from Russian layout");
|
||||||
|
require(!supplemental.suggest(L"реліз ", 2, layouts, options),
|
||||||
|
"correct Ukrainian release word stays unchanged");
|
||||||
|
require(!supplemental.suggest(L"релиз ", 1, layouts, options),
|
||||||
|
"valid Russian release word stays unchanged");
|
||||||
|
options.autoMaxRank = 50000;
|
||||||
|
require(!supplemental.suggest(L"Gjnjxys ", 0, layouts, options),
|
||||||
|
"supplemental current word respects explicit frequency limit");
|
||||||
|
require(!supplemental.suggest(L"релыз ", 1, layouts, options),
|
||||||
|
"supplemental vocabulary respects explicit frequency limit");
|
||||||
|
options.autoWords[L"uk"] = {L"релізик"};
|
||||||
|
options.autoMaxRank = 1;
|
||||||
|
auto custom = supplemental.suggest(L"релызик ", 1, layouts, options);
|
||||||
|
require(custom && custom->text == L"релізик " && custom->target == 2,
|
||||||
|
"personal word enables correction without editing dictionary files");
|
||||||
|
require(!supplemental.suggest(L"релізик ", 2, layouts, options),
|
||||||
|
"personal word is protected from correction");
|
||||||
|
options.autoNever = {L"релызик"};
|
||||||
|
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
||||||
|
"never-correct list takes precedence over personal word");
|
||||||
|
options.autoNever.clear();
|
||||||
|
options.autoWords[L"uk"].push_back(L"робить");
|
||||||
|
auto commaWord = supplemental.suggest(L"hj,bnm ", 0, layouts, options);
|
||||||
|
require(commaWord && commaWord->text == L"робить " && commaWord->target == 2,
|
||||||
|
"personal Ukrainian word accepts a punctuation key that maps to a letter");
|
||||||
|
require(!supplemental.suggest(L"hj,bnm123 ", 0, layouts, options),
|
||||||
|
"punctuation-key correction still rejects digits");
|
||||||
|
options.autoMinLength = 1;
|
||||||
|
require(!supplemental.suggest(L"ye ", 0, layouts, options),
|
||||||
|
"known English ye remains protected at minimum length one");
|
||||||
|
options.cycle = {L"en", L"ru"};
|
||||||
|
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
|
||||||
|
"personal dictionary respects disabled target layouts");
|
||||||
|
}
|
||||||
rejects([&] { missing.configure(L"missing-dictionaries", {L"en"}); }, "missing dictionary rejected");
|
rejects([&] { missing.configure(L"missing-dictionaries", {L"en"}); }, "missing dictionary rejected");
|
||||||
require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion");
|
require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion");
|
||||||
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
|
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
|
||||||
require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion");
|
require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion");
|
||||||
|
require(ks::nextConversionLayout(L"рф", layouts, {0, 1, 2}, 1) == 0 &&
|
||||||
|
ks::convertTo(L"рф", layouts, 0, 1) == L"ha",
|
||||||
|
"cycle skips unchanged Russian to Ukrainian and wraps to English");
|
||||||
|
require(ks::nextConversionLayout(L"шььш", layouts, {0, 2, 1}, 2) == 0 &&
|
||||||
|
ks::convertTo(L"шььш", layouts, 0, 2) == L"immi",
|
||||||
|
"cycle skips unchanged Ukrainian to Russian");
|
||||||
|
require(ks::nextConversionLayout(L"і", layouts, {0, 2, 1}, 2) == 1,
|
||||||
|
"cycle keeps the next language when text changes");
|
||||||
|
require(ks::nextConversionLayout(L"ha", layouts, {0, 1, 2}, 0) == 1,
|
||||||
|
"cycle preserves configured order for changed text");
|
||||||
|
require(ks::nextConversionLayout(L"рф", layouts, {1, 2}, 1) == 2,
|
||||||
|
"cycle does not use disabled English when all candidates are unchanged");
|
||||||
|
require(ks::nextConversionLayout(L"123 ", layouts, {0, 1, 2}, 0) == 1,
|
||||||
|
"unchanged digits finish after a bounded cycle");
|
||||||
|
require(ks::nextConversionLayout(L"рф", layouts, {2, 0}, 1) == 0,
|
||||||
|
"source outside cycle still skips unchanged candidates");
|
||||||
require(ks::cycleCase(L"hello world") == L"Hello World", "title case");
|
require(ks::cycleCase(L"hello world") == L"Hello World", "title case");
|
||||||
require(ks::cycleCase(L"Hello World") == L"HELLO WORLD", "upper case");
|
require(ks::cycleCase(L"Hello World") == L"HELLO WORLD", "upper case");
|
||||||
require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case");
|
require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case");
|
||||||
@@ -253,6 +398,12 @@ int main(int argc, char **argv)
|
|||||||
require(ks::transliterate(L"Shchuka") == L"Щука", "reverse transliteration");
|
require(ks::transliterate(L"Shchuka") == L"Щука", "reverse transliteration");
|
||||||
require(ks::parseTemplate(L"a{cursor}b").suffix == L"b", "snippet suffix");
|
require(ks::parseTemplate(L"a{cursor}b").suffix == L"b", "snippet suffix");
|
||||||
require(ks::parseTemplate(L"{{cursor}}").text == L"{cursor}", "escaped cursor");
|
require(ks::parseTemplate(L"{{cursor}}").text == L"{cursor}", "escaped cursor");
|
||||||
|
require(ks::parseTemplate(L"rider .{enter}").text == L"rider ." &&
|
||||||
|
ks::parseTemplate(L"rider .{enter}").enters == 1, "Enter is an action, not inserted text");
|
||||||
|
require(ks::parseTemplate(L"a{cursor}b{enter}{enter}").suffix == L"b" &&
|
||||||
|
ks::parseTemplate(L"a{cursor}b{enter}{enter}").enters == 2, "Enter actions preserve cursor suffix");
|
||||||
|
require(ks::parseTemplate(L"{{enter}}").text == L"{enter}" &&
|
||||||
|
ks::parseTemplate(L"{{enter}}").enters == 0, "escaped Enter is literal");
|
||||||
require(ks::parseTemplate(L"{cursor}{cursor}").cursors == 2, "duplicate cursor detection");
|
require(ks::parseTemplate(L"{cursor}{cursor}").cursors == 2, "duplicate cursor detection");
|
||||||
require(ks::Hotkey::parse(L"Ctrl+Alt+Shift+Space")->key == VK_SPACE, "hotkey parse");
|
require(ks::Hotkey::parse(L"Ctrl+Alt+Shift+Space")->key == VK_SPACE, "hotkey parse");
|
||||||
require(!ks::Hotkey::parse(L"Ctrl+F25"), "invalid function key");
|
require(!ks::Hotkey::parse(L"Ctrl+F25"), "invalid function key");
|
||||||
@@ -335,6 +486,64 @@ int main(int argc, char **argv)
|
|||||||
auto temp = std::filesystem::temp_directory_path() /
|
auto temp = std::filesystem::temp_directory_path() /
|
||||||
(L"KSwitcher-tests-" + std::to_wstring(GetCurrentProcessId()));
|
(L"KSwitcher-tests-" + std::to_wstring(GetCurrentProcessId()));
|
||||||
std::filesystem::create_directories(temp);
|
std::filesystem::create_directories(temp);
|
||||||
|
{
|
||||||
|
auto folder = temp / L"dictionaries";
|
||||||
|
std::filesystem::create_directories(folder);
|
||||||
|
auto put = [&](const wchar_t *name, const std::wstring &text)
|
||||||
|
{
|
||||||
|
auto data = ks::utf8(text);
|
||||||
|
ks::atomicWrite(folder / name, ks::Bytes(data.begin(), data.end()));
|
||||||
|
};
|
||||||
|
put(L"en.txt", L"zoo\t3\nhello\t99\nhello\t1\n");
|
||||||
|
put(L"uk.txt", L"дім\t3\n");
|
||||||
|
ks::AutoCorrector edited;
|
||||||
|
edited.configure(folder, {L"en", L"uk"});
|
||||||
|
auto options = ks::Settings{};
|
||||||
|
options.cycle = {L"en", L"uk"};
|
||||||
|
options.autoMaxRank = 1;
|
||||||
|
auto correction = edited.suggest(L"руддщ ", 2, layouts, options);
|
||||||
|
require(correction && correction->text == L"hello ",
|
||||||
|
"unsorted dictionaries and duplicates load using lowest duplicate rank");
|
||||||
|
require(!edited.suggest(L"hlelo ", 0, layouts, options), "spelling correction defaults off");
|
||||||
|
options.autoTypos = true;
|
||||||
|
for (auto typo : {L"hlelo ", L"helo ", L"helllo ", L"hezlo "})
|
||||||
|
{
|
||||||
|
correction = edited.suggest(typo, 0, layouts, options);
|
||||||
|
require(correction && correction->text == L"hello " && correction->target == 0,
|
||||||
|
"single transposition, deletion, insertion and substitution retain the source layout");
|
||||||
|
}
|
||||||
|
correction = edited.suggest(L"(Hlelo), ", 0, layouts, options);
|
||||||
|
require(correction && correction->text == L"(Hello), ", "typos retain case and punctuation");
|
||||||
|
for (auto text : {L"hello ", L"HLELO ", L"hLelo ", L"h3llo ", L"h.llo ", L"hlo ", L"hello@example.com "})
|
||||||
|
require(!edited.suggest(text, 0, layouts, options), "known or unsafe typo input is unchanged");
|
||||||
|
options.autoWords[L"en"] = {L"hella"};
|
||||||
|
require(!edited.suggest(L"hell ", 0, layouts, options), "personal dictionary alternatives create ambiguity");
|
||||||
|
options.autoWords.clear();
|
||||||
|
options.autoReplacements = {{L"hlelo", L"greeting"}, {L"hello", L"greetings"}, {L"првиіт", L"привіт"}};
|
||||||
|
options.autoReplace = true;
|
||||||
|
correction = edited.suggest(L"Hlelo! ", 0, layouts, options);
|
||||||
|
require(correction && correction->text == L"Greeting! ", "explicit replacements override spelling guesses");
|
||||||
|
correction = edited.suggest(L"hello ", 0, layouts, options);
|
||||||
|
require(correction && correction->text == L"greetings ", "explicit replacements can override known words");
|
||||||
|
correction = edited.suggest(L"Првиіт ", 2, layouts, options);
|
||||||
|
require(correction && correction->text == L"Привіт " && correction->target == 2,
|
||||||
|
"Ukrainian replacements work without dictionary membership");
|
||||||
|
options.autoNever = {L"hlelo"};
|
||||||
|
require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs");
|
||||||
|
options.autoNever.clear();
|
||||||
|
edited.reject(L"(Hlelo)! ", options);
|
||||||
|
require(!edited.suggest(L"hlelo ", 0, layouts, options), "undo rejection suppresses custom replacements");
|
||||||
|
options.autoReplace = false;
|
||||||
|
options.autoWords[L"uk"] = {L"привіт"};
|
||||||
|
correction = edited.suggest(L"првиіт ", 2, layouts, options);
|
||||||
|
require(correction && correction->text == L"привіт ", "Ukrainian personal words are typo targets");
|
||||||
|
options.cycle = {L"en"};
|
||||||
|
require(!edited.suggest(L"првиіт ", 2, layouts, options), "disabled source layouts do not correct typos");
|
||||||
|
put(L"en.txt", L"hello\t1\nhella\t99999\n");
|
||||||
|
ks::AutoCorrector ambiguous;
|
||||||
|
ambiguous.configure(folder, {L"en", L"uk"});
|
||||||
|
require(!ambiguous.suggest(L"hell ", 0, layouts, options), "frequency filter does not conceal ambiguous typo targets");
|
||||||
|
}
|
||||||
ks::Pixels pixels{2, 1, {10, 20, 30, 255, 40, 50, 60, 255}};
|
ks::Pixels pixels{2, 1, {10, 20, 30, 255, 40, 50, 60, 255}};
|
||||||
ks::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}};
|
ks::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}};
|
||||||
auto thumbnail = ks::decodeImage(firstImage, 1, 1);
|
auto thumbnail = ks::decodeImage(firstImage, 1, 1);
|
||||||
@@ -379,16 +588,31 @@ int main(int argc, char **argv)
|
|||||||
settings.writeLog = false;
|
settings.writeLog = false;
|
||||||
settings.elevatedAutostart = true;
|
settings.elevatedAutostart = true;
|
||||||
settings.autoCorrect = true;
|
settings.autoCorrect = true;
|
||||||
|
settings.autoReplace = settings.autoTypos = true;
|
||||||
|
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}};
|
||||||
settings.autoMaxRank = 10000;
|
settings.autoMaxRank = 10000;
|
||||||
settings.autoMinLength = 5;
|
settings.autoMinLength = 5;
|
||||||
settings.autoPrefixChars = L"(#";
|
settings.autoPrefixChars = L"(#";
|
||||||
settings.autoSuffixChars = L");%";
|
settings.autoSuffixChars = L");%";
|
||||||
settings.autoNever = {L"myWord"};
|
settings.autoNever = {L"myWord"};
|
||||||
|
settings.autoWords = {{L"uk", {L"релізик"}}, {L"en", {L"kswitcher"}}};
|
||||||
settings.autoExcluded = {L"Code.exe"};
|
settings.autoExcluded = {L"Code.exe"};
|
||||||
settings.cycle = {L"en", L"uk"};
|
settings.cycle = {L"en", L"uk"};
|
||||||
settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}};
|
settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}};
|
||||||
ks::saveSettings(temp / L"settings.json", settings);
|
ks::saveSettings(temp / L"settings.json", settings);
|
||||||
auto restored = ks::loadSettings(temp / L"settings.json", {});
|
auto restored = ks::loadSettings(temp / L"settings.json", {});
|
||||||
|
require(restored.autoWords == settings.autoWords, "personal dictionaries survive settings roundtrip");
|
||||||
|
require(restored.autoReplace && restored.autoTypos && restored.autoReplacements == settings.autoReplacements,
|
||||||
|
"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"}})
|
||||||
|
{
|
||||||
|
auto invalid = settings;
|
||||||
|
invalid.autoReplacements = {{pair.first, pair.second}};
|
||||||
|
rejects([&] { ks::validateSettings(invalid); }, "invalid replacement pairs rejected");
|
||||||
|
}
|
||||||
|
auto invalidWords = settings;
|
||||||
|
invalidWords.autoWords[L"uk"] = {L"hello"};
|
||||||
|
rejects([&] { ks::validateSettings(invalidWords); }, "personal dictionary rejects wrong language letters");
|
||||||
require(ks::encodeSettings(ks::decodeSettings(ks::encodeSettings(settings), {})) == ks::encodeSettings(settings),
|
require(ks::encodeSettings(ks::decodeSettings(ks::encodeSettings(settings), {})) == ks::encodeSettings(settings),
|
||||||
"configurator snapshot survives serialization without changing the concurrency token");
|
"configurator snapshot survives serialization without changing the concurrency token");
|
||||||
rejects([&] { ks::decodeSettings(ks::Bytes{'{', '}'}, {}); },
|
rejects([&] { ks::decodeSettings(ks::Bytes{'{', '}'}, {}); },
|
||||||
@@ -430,6 +654,13 @@ int main(int argc, char **argv)
|
|||||||
auto invalid = settings;
|
auto invalid = settings;
|
||||||
auto captureSettings = settings;
|
auto captureSettings = settings;
|
||||||
captureSettings.screenshot = L"Ctrl+Shift+F9";
|
captureSettings.screenshot = L"Ctrl+Shift+F9";
|
||||||
|
require(!captureSettings.screenshotAutoOpenEditor,"notification-first capture is the default");
|
||||||
|
captureSettings.screenshotAutoOpenEditor=true;
|
||||||
|
require(ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshotAutoOpenEditor,
|
||||||
|
"explicit automatic editor preference survives settings roundtrip");
|
||||||
|
captureSettings.screenshotAutoOpenEditor=false;
|
||||||
|
require(!ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshotAutoOpenEditor,
|
||||||
|
"notification-first preference survives settings roundtrip");
|
||||||
require(ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshot == captureSettings.screenshot,
|
require(ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshot == captureSettings.screenshot,
|
||||||
"screen capture shortcut roundtrip");
|
"screen capture shortcut roundtrip");
|
||||||
captureSettings.screenshot = captureSettings.clipboard;
|
captureSettings.screenshot = captureSettings.clipboard;
|
||||||
@@ -461,6 +692,75 @@ int main(int argc, char **argv)
|
|||||||
history.clear();
|
history.clear();
|
||||||
require(history.entries().empty(), "clear history");
|
require(history.entries().empty(), "clear history");
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
ks::History history(temp / L"search");
|
||||||
|
history.add(ks::textClip(std::wstring(260, L'x') + L" hidden needle"), L"Test", settings);
|
||||||
|
history.add(ks::textClip(L"first\nsecond\tthird"), L"Test", settings);
|
||||||
|
require(history.search(L"hidden needle").size() == 1, "search beyond truncated preview");
|
||||||
|
require(history.search(L"first\nsecond").size() == 1, "search retains original newlines");
|
||||||
|
require(history.search(L"second\tthird").size() == 1, "search retains original tabs");
|
||||||
|
require(history.search(L"absent").empty(), "short previews reject unmatched queries");
|
||||||
|
require(history.search(L"FIRST").size() == 1, "short preview search ignores case");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
auto folder = temp / L"ocr-history";
|
||||||
|
ks::History history(folder);
|
||||||
|
ks::Clip image{{ks::imageDib(ks::Pixels{2, 2, ks::Bytes(16, 255)})}};
|
||||||
|
history.add(image, L"OCR test", settings);
|
||||||
|
auto id = history.entries().front().id;
|
||||||
|
require(history.entries().front().ocrState == 1, "new images await background OCR");
|
||||||
|
require(ks::History(folder).entries().front().ocrState == 1, "pending OCR survives restart");
|
||||||
|
auto original = ks::encodeClip(history.load(id));
|
||||||
|
auto beforeBytes = history.entries().front().bytes;
|
||||||
|
require(history.saveOcr(id, L"Розпізнаний текст HELLO", settings), "save image OCR");
|
||||||
|
require(history.entries().front().bytes > beforeBytes, "OCR storage counts against history size");
|
||||||
|
require(ks::encodeClip(history.load(id)) == original, "OCR preserves original image formats");
|
||||||
|
require(!history.saveOcr(id, L"replacement", settings), "completed OCR is not overwritten");
|
||||||
|
ks::History restored(folder);
|
||||||
|
require(restored.search(L"розпізнаний").size() == 1, "persisted Ukrainian OCR is searchable");
|
||||||
|
require(restored.ocrText(id) == L"Розпізнаний текст HELLO", "OCR text survives restart");
|
||||||
|
auto raw = ks::readFile(folder / (id + L".ocr"), 2 * 1024 * 1024);
|
||||||
|
require(std::string(raw.begin(), raw.end()).find("HELLO") == std::string::npos, "OCR sidecar is encrypted");
|
||||||
|
restored.remove(id);
|
||||||
|
require(!std::filesystem::exists(folder / (id + L".ocr")), "deleting image removes OCR sidecar");
|
||||||
|
require(!restored.saveOcr(id, L"late result", settings), "late OCR cannot resurrect deleted image");
|
||||||
|
restored.add(image, L"OCR test", settings);
|
||||||
|
id = restored.entries().front().id;
|
||||||
|
restored.saveOcr(id, L"", settings);
|
||||||
|
require(restored.ocrText(id).has_value() && restored.ocrText(id)->empty(), "empty OCR is a completed result");
|
||||||
|
restored.clear();
|
||||||
|
require(!std::filesystem::exists(folder / (id + L".ocr")), "clear removes OCR sidecars");
|
||||||
|
restored.add(image, L"OCR test", settings);
|
||||||
|
id = restored.entries().front().id;
|
||||||
|
restored.saveOcr(id, std::nullopt, settings);
|
||||||
|
require(ks::History(folder).entries().front().ocrState == 3, "failed OCR is not retried on every search or restart");
|
||||||
|
restored.saveOcr(id, L"manual retry", settings);
|
||||||
|
require(restored.search(L"manual retry").size() == 1, "explicit retry can recover failed OCR");
|
||||||
|
restored.add(ks::textClip(L"newer"), L"test", settings);
|
||||||
|
auto limited = settings; limited.maxItems = 1;
|
||||||
|
restored.add(ks::textClip(L"newest"), L"test", limited);
|
||||||
|
require(!std::filesystem::exists(folder / (id + L".ocr")), "pruning image removes OCR sidecar");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
ks::Settings options;
|
||||||
|
require(!options.useTesseract, "Tesseract is opt-in by default");
|
||||||
|
options.useTesseract = true;
|
||||||
|
options.tesseractPath = L"C:\\Program Files\\Tesseract-OCR\\tesseract.exe";
|
||||||
|
options.tesseractLanguages = L"ukr+eng+rus";
|
||||||
|
auto restored = ks::decodeSettings(ks::encodeSettings(options), {});
|
||||||
|
require(restored.useTesseract && restored.tesseractPath == options.tesseractPath &&
|
||||||
|
restored.tesseractLanguages == options.tesseractLanguages, "OCR settings roundtrip");
|
||||||
|
options.tesseractLanguages = L"ukr+../eng";
|
||||||
|
rejects([&] { ks::validateSettings(options); }, "OCR language paths rejected");
|
||||||
|
options.tesseractLanguages = L"ukr++eng";
|
||||||
|
rejects([&] { ks::validateSettings(options); }, "empty OCR language rejected");
|
||||||
|
options.tesseractLanguages = L"ukr+eng";
|
||||||
|
options.tesseractPath = L"C:\\missing-tesseract\\tesseract.exe";
|
||||||
|
rejects([&] { ks::validateTesseract(options); }, "missing optional Tesseract reports clear failure");
|
||||||
|
options.useTesseract = false;
|
||||||
|
ks::validateTesseract(options);
|
||||||
|
require(true, "Windows OCR works without optional installation");
|
||||||
|
}
|
||||||
// Test-owned directory only; never touch the user's clipboard or stored history.
|
// Test-owned directory only; never touch the user's clipboard or stored history.
|
||||||
std::filesystem::remove_all(temp);
|
std::filesystem::remove_all(temp);
|
||||||
CoUninitialize();
|
CoUninitialize();
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ try {
|
|||||||
$binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path)
|
$binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path)
|
||||||
$binaryVersion = Get-StableVersion $binaryInfo.FileVersion
|
$binaryVersion = Get-StableVersion $binaryInfo.FileVersion
|
||||||
Assert ($null -ne $binaryVersion) 'executable has a stable version'
|
Assert ($null -ne $binaryVersion) 'executable has a stable version'
|
||||||
|
$header = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../src/update.h') -Raw
|
||||||
|
$match = [regex]::Match($header, 'AppVersion\[\]\s*=\s*L"([0-9.]+)"')
|
||||||
|
Assert ($match.Success -and (Get-StableVersion $match.Groups[1].Value) -eq $binaryVersion) 'updater runtime version matches packaged executable'
|
||||||
|
$cmake = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../CMakeLists.txt') -Raw
|
||||||
|
$projectVersion = [regex]::Match($cmake, 'project\(KSwitcher VERSION ([0-9.]+)')
|
||||||
|
Assert ($projectVersion.Success -and (Get-StableVersion $projectVersion.Groups[1].Value) -eq $binaryVersion) 'CMake version matches packaged executable'
|
||||||
Assert ($binaryInfo.FileDescription -ceq ('KSwitcher ' + [char]0x2014 + ' native keyboard and clipboard utility')) 'executable description preserves Unicode em dash'
|
Assert ($binaryInfo.FileDescription -ceq ('KSwitcher ' + [char]0x2014 + ' native keyboard and clipboard utility')) 'executable description preserves Unicode em dash'
|
||||||
Assert ((Get-StableVersion 'v0.10.0') -gt (Get-StableVersion 'v0.2.9')) 'numeric versions'
|
Assert ((Get-StableVersion 'v0.10.0') -gt (Get-StableVersion 'v0.2.9')) 'numeric versions'
|
||||||
Assert ($null -eq (Get-StableVersion 'v1.0.0-beta')) 'prerelease rejected'
|
Assert ($null -eq (Get-StableVersion 'v1.0.0-beta')) 'prerelease rejected'
|
||||||
@@ -22,6 +28,11 @@ try {
|
|||||||
Assert ((Select-Release @($release) ([version]'0.2.0')).version -eq 'v0.2.1') 'newer release'
|
Assert ((Select-Release @($release) ([version]'0.2.0')).version -eq 'v0.2.1') 'newer release'
|
||||||
Assert ($null -eq (Select-Release @($release) ([version]'0.2.1'))) 'same version'
|
Assert ($null -eq (Select-Release @($release) ([version]'0.2.1'))) 'same version'
|
||||||
Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade'
|
Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade'
|
||||||
|
Assert ((Get-StableVersion 'release-v0.5.0') -eq [version]'0.5.0') 'modern stable release tag'
|
||||||
|
$modern=@{draft=$false;prerelease=$false;tag_name='release-v0.5.0';assets=$assets}
|
||||||
|
$installedRelease=@{draft=$false;prerelease=$false;tag_name=('release-v'+$binaryInfo.FileVersion);assets=$assets}
|
||||||
|
Assert ($null -eq (Select-Release @($installedRelease) (Get-StableVersion $match.Groups[1].Value))) 'runtime updater does not offer the installed modern release again'
|
||||||
|
Assert ((Select-Release @($release,$modern) ([version]'0.4.2')).version -eq 'release-v0.5.0') 'bridge discovers modern channel'
|
||||||
$draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets}
|
$draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets}
|
||||||
Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored'
|
Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored'
|
||||||
Reject { Select-Release @(@{draft=$false;prerelease=$false;tag_name='v2.0.0';assets=@()}) ([version]'0.2.0') } 'incomplete release rejected'
|
Reject { Select-Release @(@{draft=$false;prerelease=$false;tag_name='v2.0.0';assets=@()}) ([version]'0.2.0') } 'incomplete release rejected'
|
||||||
@@ -34,15 +45,30 @@ try {
|
|||||||
if (Test-Path -LiteralPath $configurator) {
|
if (Test-Path -LiteralPath $configurator) {
|
||||||
Copy-Item -LiteralPath $configurator -Destination (Join-Path $source 'KSwitcher.Config.exe')
|
Copy-Item -LiteralPath $configurator -Destination (Join-Path $source 'KSwitcher.Config.exe')
|
||||||
}
|
}
|
||||||
|
$screenshot = Join-Path (Split-Path $Executable) 'KSwitcher.Screenshot.exe'
|
||||||
|
if (Test-Path -LiteralPath $screenshot) { Copy-Item -LiteralPath $screenshot -Destination (Join-Path $source 'KSwitcher.Screenshot.exe') }
|
||||||
foreach ($name in @('LICENSE','json-LICENSE.txt','README.md')) { [IO.File]::WriteAllText((Join-Path $source $name),'fixture') }
|
foreach ($name in @('LICENSE','json-LICENSE.txt','README.md')) { [IO.File]::WriteAllText((Join-Path $source $name),'fixture') }
|
||||||
[IO.Directory]::CreateDirectory((Join-Path $source 'dictionaries')) | Out-Null
|
[IO.Directory]::CreateDirectory((Join-Path $source 'dictionaries')) | Out-Null
|
||||||
[IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary')
|
[IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary')
|
||||||
|
function Write-TestManifest($Folder) {
|
||||||
|
$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}
|
||||||
|
})
|
||||||
|
@{schema=1;version=$binaryVersion.ToString();files=$entries} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $Folder 'release-files.json') -Encoding utf8
|
||||||
|
}
|
||||||
|
[IO.File]::WriteAllText((Join-Path $source 'future-tool.exe'),'future companion fixture')
|
||||||
|
Write-TestManifest $source
|
||||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
$archive=Join-Path $root 'release.zip'
|
$archive=Join-Path $root 'release.zip'
|
||||||
[IO.Compression.ZipFile]::CreateFromDirectory($source,$archive)
|
[IO.Compression.ZipFile]::CreateFromDirectory($source,$archive)
|
||||||
$sum=Join-Path $root 'SHA256SUMS.txt'
|
$sum=Join-Path $root 'SHA256SUMS.txt'
|
||||||
((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash+' KSwitcher-win-x64.zip') | Set-Content -LiteralPath $sum
|
((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash+' KSwitcher-win-x64.zip') | Set-Content -LiteralPath $sum
|
||||||
Assert-Checksum $archive $sum; ++$script:checks
|
Assert-Checksum $archive $sum; ++$script:checks
|
||||||
|
& {
|
||||||
|
function Get-FileHash { throw 'Get-FileHash unavailable in inherited module environment' }
|
||||||
|
Assert-Checksum $archive $sum
|
||||||
|
}
|
||||||
|
++$script:checks
|
||||||
$stage=Join-Path $root 'stage'
|
$stage=Join-Path $root 'stage'
|
||||||
Expand-Release $archive $stage $binaryVersion; ++$script:checks
|
Expand-Release $archive $stage $binaryVersion; ++$script:checks
|
||||||
Reject { Expand-Release $archive (Join-Path $root 'wrong-version') ([version]'9.0.0') } 'version mismatch rejected'
|
Reject { Expand-Release $archive (Join-Path $root 'wrong-version') ([version]'9.0.0') } 'version mismatch rejected'
|
||||||
@@ -52,6 +78,19 @@ try {
|
|||||||
$z=[IO.Compression.ZipFile]::Open($bad,[IO.Compression.ZipArchiveMode]::Create)
|
$z=[IO.Compression.ZipFile]::Open($bad,[IO.Compression.ZipArchiveMode]::Create)
|
||||||
$null=$z.CreateEntry('../escape.exe'); $z.Dispose()
|
$null=$z.CreateEntry('../escape.exe'); $z.Dispose()
|
||||||
Reject { Expand-Release $bad (Join-Path $root 'badstage') ([version]'0.2.1') } 'zip traversal rejected'
|
Reject { Expand-Release $bad (Join-Path $root 'badstage') ([version]'0.2.1') } 'zip traversal rejected'
|
||||||
|
foreach ($unsafe in @('../escape','C:/escape','data/settings.json','DATA/history.bin','folder/../escape','folder/file:stream','CON.txt','folder/file.','/absolute','folder\file')) {
|
||||||
|
Reject { Assert-PackagePath $unsafe } 'unsafe or protected manifest path rejected'
|
||||||
|
}
|
||||||
|
$corrupt=Join-Path $root 'corrupt.zip'
|
||||||
|
Copy-Item -LiteralPath $archive -Destination $corrupt
|
||||||
|
$z=[IO.Compression.ZipFile]::Open($corrupt,[IO.Compression.ZipArchiveMode]::Update)
|
||||||
|
$z.GetEntry('future-tool.exe').Delete()
|
||||||
|
$writer=[IO.StreamWriter]::new($z.CreateEntry('future-tool.exe').Open()); $writer.Write('tampered'); $writer.Dispose(); $z.Dispose()
|
||||||
|
Reject { Expand-Release $corrupt (Join-Path $root 'corruptstage') $binaryVersion } 'manifest detects tampered companion'
|
||||||
|
$extra=Join-Path $root 'extra.zip'; Copy-Item -LiteralPath $archive -Destination $extra
|
||||||
|
$z=[IO.Compression.ZipFile]::Open($extra,[IO.Compression.ZipArchiveMode]::Update)
|
||||||
|
$null=$z.CreateEntry('undeclared.dll'); $z.Dispose()
|
||||||
|
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'
|
||||||
[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
|
||||||
@@ -64,12 +103,14 @@ try {
|
|||||||
if (Test-Path -LiteralPath $configurator) {
|
if (Test-Path -LiteralPath $configurator) {
|
||||||
Assert ((Get-FileHash -LiteralPath (Join-Path $target 'KSwitcher.Config.exe')).Hash -eq (Get-FileHash -LiteralPath $configurator).Hash) 'configurator installed'
|
Assert ((Get-FileHash -LiteralPath (Join-Path $target 'KSwitcher.Config.exe')).Hash -eq (Get-FileHash -LiteralPath $configurator).Hash) 'configurator installed'
|
||||||
}
|
}
|
||||||
|
Assert ([IO.File]::ReadAllText((Join-Path $target '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 'dictionaries/en.txt')) -eq 'personal dictionary') 'dictionary preserved'
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'dictionaries/en.txt')) -eq 'personal dictionary') 'dictionary preserved'
|
||||||
Undo-Files $target $backup $changed
|
Undo-Files $target $backup $changed
|
||||||
Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'rollback restores original'
|
Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'rollback restores original'
|
||||||
Assert (!(Test-Path -LiteralPath (Join-Path $target 'README.md'))) 'rollback removes newly introduced files'
|
Assert (!(Test-Path -LiteralPath (Join-Path $target 'README.md'))) 'rollback removes newly introduced files'
|
||||||
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Config.exe'))) 'rollback removes newly introduced configurator'
|
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'
|
||||||
# Fail after the original exe has moved to backup, as if the staged file vanished.
|
# Fail after the original exe has moved to backup, as if the staged file vanished.
|
||||||
$stage2=Join-Path $root 'stage2'; [IO.Directory]::CreateDirectory($stage2) | Out-Null
|
$stage2=Join-Path $root 'stage2'; [IO.Directory]::CreateDirectory($stage2) | Out-Null
|
||||||
[IO.File]::WriteAllText((Join-Path $stage2 'KSwitcher.exe'),'new binary fixture')
|
[IO.File]::WriteAllText((Join-Path $stage2 'KSwitcher.exe'),'new binary fixture')
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
param([string]$PreviewDirectory = (Join-Path $PSScriptRoot '../build/update-preview'), [switch]$Network)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
. (Join-Path $PSScriptRoot '../resources/update.ps1') -Library
|
||||||
|
[IO.Directory]::CreateDirectory($PreviewDirectory) | Out-Null
|
||||||
|
Start-UpdateProgress
|
||||||
|
try {
|
||||||
|
if ($script:progressWindow.Status -ne 'Checking for updates') { throw 'Initial progress stage missing' }
|
||||||
|
Show-UpdateProgress 'Downloading update' '42% - 1.6 / 3.9 MB' 42
|
||||||
|
if ($script:progressWindow.Percent -ne 42) { throw 'Determinate download progress missing' }
|
||||||
|
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'download.png'))
|
||||||
|
if ($Network) {
|
||||||
|
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' (Join-Path $PreviewDirectory 'releases.json') 2MB 'Downloading update'
|
||||||
|
if ($script:progressWindow.Percent -ne 100) { throw 'Real download did not finish at 100 percent' }
|
||||||
|
}
|
||||||
|
foreach ($stage in @('Verifying download','Preparing installation','Closing KSwitcher','Installing update','Restarting KSwitcher','Restoring previous version')) {
|
||||||
|
Show-UpdateProgress $stage 'Update stage under test'
|
||||||
|
if ($script:progressWindow.Status -ne $stage -or $script:progressWindow.Percent -ne -1) { throw "Invalid progress stage: $stage" }
|
||||||
|
}
|
||||||
|
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'rollback.png'))
|
||||||
|
$script:progressWindow.Finish('Update completed','KSwitcher 0.6.11 is now running.',$true)
|
||||||
|
if ($script:progressWindow.Status -ne 'Update completed' -or $script:progressWindow.Percent -ne 100) { throw 'Completion state missing' }
|
||||||
|
Start-Sleep -Milliseconds 800 # Let the native progress-bar animation finish before capturing it.
|
||||||
|
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'complete.png'))
|
||||||
|
$script:progressWindow.Finish('Update failed',"The updated application did not confirm startup.`r`nThe previous version has been restored and restarted.",$false)
|
||||||
|
if ($script:progressWindow.Status -ne 'Update failed' -or $script:progressWindow.Percent -ne 0) { throw 'Failure state missing' }
|
||||||
|
$script:progressWindow.SavePreview((Join-Path $PreviewDirectory 'failed.png'))
|
||||||
|
'Passed updater progress UI checks'
|
||||||
|
} finally {
|
||||||
|
$script:progressWindow.Dispose()
|
||||||
|
$script:progressWindow = $null
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
param([Parameter(Mandatory)][string]$BridgeArchive,[Parameter(Mandatory)][string]$FullArchive)
|
||||||
|
$ErrorActionPreference='Stop'
|
||||||
|
Add-Type @'
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public static class UpdateResource {
|
||||||
|
[DllImport("kernel32.dll", CharSet=CharSet.Unicode)] static extern IntPtr LoadLibraryEx(string path, IntPtr file, uint flags);
|
||||||
|
[DllImport("kernel32.dll")] static extern IntPtr FindResource(IntPtr module, IntPtr name, IntPtr type);
|
||||||
|
[DllImport("kernel32.dll")] static extern IntPtr LoadResource(IntPtr module, IntPtr resource);
|
||||||
|
[DllImport("kernel32.dll")] static extern IntPtr LockResource(IntPtr resource);
|
||||||
|
[DllImport("kernel32.dll")] static extern uint SizeofResource(IntPtr module, IntPtr resource);
|
||||||
|
[DllImport("kernel32.dll")] static extern bool FreeLibrary(IntPtr module);
|
||||||
|
public static byte[] Read(string path) {
|
||||||
|
var module=LoadLibraryEx(path,IntPtr.Zero,2|32);
|
||||||
|
if(module==IntPtr.Zero) throw new Exception("Cannot load bridge resources");
|
||||||
|
try {
|
||||||
|
var resource=FindResource(module,(IntPtr)201,(IntPtr)10);
|
||||||
|
var size=SizeofResource(module,resource);
|
||||||
|
if(size==0 || size>1024*1024) throw new Exception("Invalid bridge updater resource");
|
||||||
|
var bytes=new byte[size];
|
||||||
|
Marshal.Copy(LockResource(LoadResource(module,resource)),bytes,0,(int)size);
|
||||||
|
return bytes;
|
||||||
|
} finally { FreeLibrary(module); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
$root=Join-Path ([IO.Path]::GetTempPath()) ('KSwitcher-transition-'+[guid]::NewGuid().ToString('N'))
|
||||||
|
[IO.Directory]::CreateDirectory($root) | Out-Null
|
||||||
|
try {
|
||||||
|
$legacy=Join-Path $root 'legacy.ps1'
|
||||||
|
$oldSource=git -C (Join-Path $PSScriptRoot '..') show v0.4.1:resources/update.ps1
|
||||||
|
if ($LASTEXITCODE) { throw 'Cannot read the released v0.4.1 updater.' }
|
||||||
|
$oldSource | Set-Content -LiteralPath $legacy -Encoding utf8
|
||||||
|
$releases=@('v0.4.2','release-v0.5.0') | ForEach-Object {
|
||||||
|
@{tag_name=$_;draft=$false;prerelease=$false;assets=@(
|
||||||
|
@{name='KSwitcher-win-x64.zip';browser_download_url="https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/$_/KSwitcher-win-x64.zip"},
|
||||||
|
@{name='SHA256SUMS.txt';browser_download_url="https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/$_/SHA256SUMS.txt"})}
|
||||||
|
}
|
||||||
|
$install=Join-Path $root 'install'
|
||||||
|
[IO.Directory]::CreateDirectory((Join-Path $install 'data')) | Out-Null
|
||||||
|
[IO.Directory]::CreateDirectory((Join-Path $install 'dictionaries')) | Out-Null
|
||||||
|
[IO.File]::WriteAllText((Join-Path $install 'data/settings.json'),'personal settings')
|
||||||
|
[IO.File]::WriteAllText((Join-Path $install 'dictionaries/en.txt'),'personal dictionary')
|
||||||
|
& {
|
||||||
|
. $legacy -Library
|
||||||
|
$selected=Select-Release $releases ([version]'0.4.1')
|
||||||
|
if ($selected.version -ne 'v0.4.2') { throw 'Legacy client skipped the bridge.' }
|
||||||
|
$stage=Join-Path $root 'bridge-stage'
|
||||||
|
Expand-Release $BridgeArchive $stage ([version]'0.4.2')
|
||||||
|
$changed=[Collections.Generic.List[string]]::new()
|
||||||
|
Install-Files $stage $install (Join-Path $root 'bridge-backup') $changed
|
||||||
|
}
|
||||||
|
$bridgeHelper=Join-Path $root 'bridge-helper.ps1'
|
||||||
|
[IO.File]::WriteAllBytes($bridgeHelper,[UpdateResource]::Read((Join-Path $install 'KSwitcher.exe')))
|
||||||
|
. $bridgeHelper -Library
|
||||||
|
$selected=Select-Release $releases ([version]'0.4.2')
|
||||||
|
if ($selected.version -ne 'release-v0.5.0') { throw 'Bridge did not find the full release.' }
|
||||||
|
$stage=Join-Path $root 'full-stage'
|
||||||
|
Expand-Release $FullArchive $stage ([version]'0.5.0')
|
||||||
|
$changed=[Collections.Generic.List[string]]::new()
|
||||||
|
Install-Files $stage $install (Join-Path $root 'full-backup') $changed
|
||||||
|
foreach ($name in @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe')) {
|
||||||
|
$version=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $install $name)).FileVersion
|
||||||
|
if ((Get-StableVersion $version) -ne [version]'0.5.0') { throw "Incorrect installed version: $name" }
|
||||||
|
}
|
||||||
|
if ([IO.File]::ReadAllText((Join-Path $install 'data/settings.json')) -ne 'personal settings' -or
|
||||||
|
[IO.File]::ReadAllText((Join-Path $install 'dictionaries/en.txt')) -ne 'personal dictionary') { throw 'User data changed during transition.' }
|
||||||
|
if (Select-Release $releases ([version]'0.5.0')) { throw 'Current version offers another update.' }
|
||||||
|
Undo-Files $install (Join-Path $root 'full-backup') $changed
|
||||||
|
if ((Get-StableVersion ([Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $install 'KSwitcher.exe')).FileVersion)) -ne [version]'0.4.2' -or
|
||||||
|
(Test-Path -LiteralPath (Join-Path $install 'KSwitcher.Screenshot.exe'))) { throw 'Full release rollback did not restore the bridge.' }
|
||||||
|
'Passed real-package transition: v0.4.1 updater -> v0.4.2 bridge -> release-v0.5.0; user data preserved; rollback verified.'
|
||||||
|
} finally {
|
||||||
|
if ([IO.Path]::GetDirectoryName($root) -eq [IO.Path]::GetTempPath().TrimEnd('\') -and (Split-Path -Leaf $root) -like 'KSwitcher-transition-*') {
|
||||||
|
Remove-Item -LiteralPath $root -Recurse -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ from pathlib import Path
|
|||||||
import urllib.request, hashlib, json
|
import urllib.request, hashlib, json
|
||||||
ROOT=Path(__file__).resolve().parents[1]/'dictionaries'
|
ROOT=Path(__file__).resolve().parents[1]/'dictionaries'
|
||||||
REV='525f9b560de45753a5ea01069454e72e9aa541c6'
|
REV='525f9b560de45753a5ea01069454e72e9aa541c6'
|
||||||
|
# Supplemental vocabulary has no corpus frequency: rank after the 50k source list.
|
||||||
|
SUPPLEMENTAL={'uk': {'реліз': 50001, 'поточні': 50001}}
|
||||||
ROOT.mkdir(exist_ok=True)
|
ROOT.mkdir(exist_ok=True)
|
||||||
manifest={}
|
manifest={}
|
||||||
for lang in ('en','uk','ru'):
|
for lang in ('en','uk','ru'):
|
||||||
@@ -15,8 +17,12 @@ for lang in ('en','uk','ru'):
|
|||||||
if not (2<=len(word)<=40 and all(c in alphabet for c in word)):continue
|
if not (2<=len(word)<=40 and all(c in alphabet for c in word)):continue
|
||||||
if int(count)<5:continue
|
if int(count)<5:continue
|
||||||
rows.setdefault(word,rank)
|
rows.setdefault(word,rank)
|
||||||
|
for word,rank in SUPPLEMENTAL.get(lang,{}).items():
|
||||||
|
rows.setdefault(word,rank)
|
||||||
out=''.join(f'{word}\t{rows[word]}\n' for word in sorted(rows))
|
out=''.join(f'{word}\t{rows[word]}\n' for word in sorted(rows))
|
||||||
(ROOT/f'{lang}.txt').write_bytes(out.encode('utf-8'))
|
(ROOT/f'{lang}.txt').write_bytes(out.encode('utf-8'))
|
||||||
manifest[lang]={'source':url,'sourceSha256':hashlib.sha256(data).hexdigest(),'words':len(rows),'bytes':len(out.encode()),'sha256':hashlib.sha256(out.encode()).hexdigest()}
|
manifest[lang]={'source':url,'sourceSha256':hashlib.sha256(data).hexdigest(),'words':len(rows),'bytes':len(out.encode()),'sha256':hashlib.sha256(out.encode()).hexdigest()}
|
||||||
|
if lang in SUPPLEMENTAL:
|
||||||
|
manifest[lang]['supplementalWords']=SUPPLEMENTAL[lang]
|
||||||
(ROOT/'sources.json').write_text(json.dumps(manifest,indent=2)+'\n',encoding='utf-8')
|
(ROOT/'sources.json').write_text(json.dumps(manifest,indent=2)+'\n',encoding='utf-8')
|
||||||
print(json.dumps(manifest,indent=2))
|
print(json.dumps(manifest,indent=2))
|
||||||
|
|||||||
@@ -40,3 +40,9 @@ for index in range(struct.unpack_from('<H', cursor, 4)[0]):
|
|||||||
# Drawing point is the tip of the pencil, not the center of its image.
|
# Drawing point is the tip of the pencil, not the center of its image.
|
||||||
struct.pack_into('<HH', cursor, entry+4, round(size*4/28), round(size*24/28))
|
struct.pack_into('<HH', cursor, entry+4, round(size*4/28), round(size*24/28))
|
||||||
(root / 'pencil.cur').write_bytes(cursor)
|
(root / 'pencil.cur').write_bytes(cursor)
|
||||||
|
|
||||||
|
# Original confirmation artwork, with native-resolution variants for display scaling.
|
||||||
|
question = root.parent / 'question.svg'
|
||||||
|
question_png = resvg_py.svg_to_bytes(svg_string=question.read_text(encoding='utf-8'), width=256, height=256)
|
||||||
|
Image.open(BytesIO(question_png)).convert('RGBA').save(
|
||||||
|
question.with_suffix('.ico'), sizes=[(s,s) for s in (16,20,24,32,40,48,64,96,128,256)])
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$root = Split-Path $PSScriptRoot
|
$root = Split-Path $PSScriptRoot
|
||||||
try {
|
try {
|
||||||
|
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq (Join-Path $root 'build\KSwitcher.Screenshot.exe') }) { throw 'Close screenshot editors before updating the development build.' }
|
||||||
$exe = Join-Path $root 'build\KSwitcher.exe'
|
$exe = Join-Path $root 'build\KSwitcher.exe'
|
||||||
$running = @(Get-Process KSwitcher -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $exe })
|
$running = @(Get-Process KSwitcher -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $exe })
|
||||||
$stop = Start-Process -FilePath $exe -ArgumentList '--exit' -WindowStyle Hidden -PassThru
|
$stop = Start-Process -FilePath $exe -ArgumentList '--exit' -WindowStyle Hidden -PassThru
|
||||||
@@ -10,6 +11,7 @@ try {
|
|||||||
foreach ($process in $config) { if (-not $process.WaitForExit(10000)) { throw 'Close the configurator before updating.' } }
|
foreach ($process in $config) { if (-not $process.WaitForExit(10000)) { throw 'Close the configurator before updating.' } }
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.Config.exe') -Destination (Join-Path $root 'build\KSwitcher.Config.exe') -Force
|
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.Config.exe') -Destination (Join-Path $root 'build\KSwitcher.Config.exe') -Force
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.exe') -Destination $exe -Force
|
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.exe') -Destination $exe -Force
|
||||||
|
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.Screenshot.exe') -Destination (Join-Path $root 'build\KSwitcher.Screenshot.exe') -Force
|
||||||
$eventName = 'Local\KSwitcher.Update.Started.' + [guid]::NewGuid().ToString('N')
|
$eventName = 'Local\KSwitcher.Update.Started.' + [guid]::NewGuid().ToString('N')
|
||||||
$started = [System.Threading.EventWaitHandle]::new($false, [System.Threading.EventResetMode]::ManualReset, $eventName)
|
$started = [System.Threading.EventWaitHandle]::new($false, [System.Threading.EventResetMode]::ManualReset, $eventName)
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user