Compare commits
29 Commits
release-v0.6.4
...
main
| 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 |
@@ -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.
|
||||||
+22
-8
@@ -1,15 +1,15 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.6.1 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 shlwapi 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_launcher.cpp src/screenshot_activation.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,10 +17,20 @@ 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/screenshot_launcher.cpp src/screenshot_activation.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/screenshot_launcher.cpp src/screenshot_activation.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)
|
||||||
target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES)
|
target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES)
|
||||||
add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp resources/app.rc)
|
add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp resources/app.rc)
|
||||||
@@ -36,10 +46,10 @@ if(MSVC)
|
|||||||
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 KSwitcherScreenshot 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)
|
||||||
@@ -49,14 +59,18 @@ else()
|
|||||||
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(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 KSwitcherScreenshot RUNTIME DESTINATION .)
|
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .)
|
||||||
|
|||||||
@@ -16,10 +16,15 @@ 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. Screen capture and all screenshot editors run on demand in
|
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
|
**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
|
process. Closing the last editor (or cancelling the only selection) exits that process. It does not
|
||||||
@@ -90,8 +95,18 @@ 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
|
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.
|
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.
|
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
|
To annotate: **N** places sequential numbered red markers with a click, and **A** draws
|
||||||
red arrows by dragging from the tail to the tip. Click an existing marker or arrow to select it;
|
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
|
||||||
@@ -101,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
|
||||||
@@ -129,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
|
||||||
@@ -142,13 +167,47 @@ 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 keeps the editor open 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**.
|
||||||
@@ -162,8 +221,21 @@ 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 background check runs shortly after startup and every 6 hours while KSwitcher is running.
|
A background check runs shortly after startup and every 6 hours while KSwitcher is running.
|
||||||
It reports each available version once per session and never installs automatically.
|
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.
|
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. The updater uses the existing
|
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;
|
||||||
@@ -187,15 +259,31 @@ 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
|
||||||
@@ -207,8 +295,24 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
|||||||
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.
|
||||||
@@ -221,6 +325,16 @@ existing saved shortcuts are preserved. While KSwitcher runs, this binding opens
|
|||||||
|
|
||||||
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
|
||||||
@@ -230,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.
|
||||||
|
|
||||||
@@ -269,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:
|
||||||
@@ -288,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
|
||||||
@@ -306,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
|
||||||
@@ -388,6 +676,13 @@ against a staged build. It briefly opens real selection/editor windows without c
|
|||||||
|
|
||||||
### Release channel rule
|
### 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
|
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`,
|
`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
|
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt`. Never publish a manifest package under a
|
||||||
|
|||||||
Binary file not shown.
+13
-13
@@ -1,22 +1,23 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.4 — 2026-10-29
|
## v0.6.30 — 2026-11-02
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- Fix accidental arrow/line detachment when dragging two linked numbered markers in turn. Clicking a visible marker now selects the marker instead of a connector endpoint underneath it, including when the connector is selected.
|
- 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.
|
||||||
- Preserve intentional endpoint dragging outside the marker.
|
- A previous-result verification mismatch clears stale repeat-conversion state.
|
||||||
|
- 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.
|
||||||
|
- The guarded retry and failed-paste readback handling require **Write log**, which enables manual-conversion readback. Clipboard restoration remains conditional on ownership.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
176 native checks, 403 input/layout checks at 240 DPI, screenshot editor UI integration, isolated notification registration, and 45 updater checks. The new UI regression test fails before the fix and passes afterward, covering alternating marker drags with arrows, lines and freehand connectors, both endpoint links, and endpoint selection outside markers.
|
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:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the
|
## Limitations
|
||||||
v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
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.
|
||||||
For manual installation, 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 the resident,
|
**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.
|
||||||
configurator and screenshot executables, a release manifest, offline dictionaries, documentation
|
For manual installation, exit KSwitcher, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||||
and license notices; no personal settings or clipboard history.
|
|
||||||
|
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:
|
||||||
|
|
||||||
@@ -25,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
|
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
||||||
`release-vX.Y.Z` tag matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
|
||||||
@@ -1 +1 @@
|
|||||||
da86748a476deec9323c39e119c89dc008e2fe62691b207eb3b0f3bb0da32cf6 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','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
$libs = @('-lwinhttp','-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
foreach ($name in @('core','platform','autostart','autocorrect','storage','sync','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
||||||
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
||||||
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
||||||
}
|
}
|
||||||
@@ -27,11 +29,11 @@ try {
|
|||||||
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
|
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
|
||||||
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
|
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_launcher.o build/screenshot_activation.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'
|
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
|
||||||
@@ -39,11 +41,19 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||||
if ($Test) {
|
if ($Test) {
|
||||||
& $compiler @common 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/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-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' }
|
||||||
@@ -51,6 +61,8 @@ try {
|
|||||||
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'
|
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
|
||||||
try {
|
try {
|
||||||
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
|
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
|
||||||
@@ -61,15 +73,17 @@ try {
|
|||||||
|
|
||||||
& "$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/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-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
|
||||||
|
|||||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
|||||||
1 RT_MANIFEST "app.manifest"
|
1 RT_MANIFEST "app.manifest"
|
||||||
201 RCDATA "update.ps1"
|
201 RCDATA "update.ps1"
|
||||||
1 VERSIONINFO
|
1 VERSIONINFO
|
||||||
FILEVERSION 0,6,4,0
|
FILEVERSION 0,6,30,0
|
||||||
PRODUCTVERSION 0,6,4,0
|
PRODUCTVERSION 0,6,30,0
|
||||||
FILEOS VOS_NT_WINDOWS32
|
FILEOS VOS_NT_WINDOWS32
|
||||||
FILETYPE VFT_APP
|
FILETYPE VFT_APP
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -20,7 +20,7 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||||
VALUE "ProductName", "KSwitcher"
|
VALUE "ProductName", "KSwitcher"
|
||||||
VALUE "FileVersion", "0.6.4"
|
VALUE "FileVersion", "0.6.30"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
+215
-5
@@ -3,10 +3,192 @@
|
|||||||
[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|release-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 }
|
||||||
@@ -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() }
|
||||||
@@ -220,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') {
|
||||||
@@ -242,25 +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'))
|
$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.' }
|
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
|
||||||
@@ -273,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 {
|
||||||
@@ -291,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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+579
-89
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, notifiedUpdate;
|
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 &, bool visible = true);
|
void showHistory(App &, bool visible = true);
|
||||||
void launchScreenshot(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)
|
||||||
|
|||||||
+15
@@ -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,14 +54,20 @@ 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 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"};
|
||||||
@@ -65,6 +78,7 @@ struct Settings
|
|||||||
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
|
||||||
@@ -86,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
|
||||||
|
|||||||
+643
-83
File diff suppressed because it is too large
Load Diff
+11
-4
@@ -15,8 +15,12 @@ void showScreenshot(ScreenshotHost &);
|
|||||||
void showScreenshotEditor(ScreenshotHost &,const Clip &);
|
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;
|
||||||
@@ -33,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 {
|
||||||
@@ -48,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> &);
|
||||||
@@ -59,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);
|
||||||
|
|||||||
@@ -83,8 +83,12 @@ public:
|
|||||||
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
|
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
|
||||||
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
|
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
|
||||||
HRESULT STDMETHODCALLTYPE Activate(LPCWSTR app,LPCWSTR args,const NotificationInput *,ULONG) override {
|
HRESULT STDMETHODCALLTYPE Activate(LPCWSTR app,LPCWSTR args,const NotificationInput *,ULONG) override {
|
||||||
if(!app || std::wstring(app)!=AppId || !args || !validScreenshotToken(args)) return E_INVALIDARG;
|
if(!app || std::wstring(app)!=AppId || !args) return E_INVALIDARG;
|
||||||
auto path=target; auto command=L"\""+path+L"\" --open \"kswitcher-screenshot:"+args+L"\"";
|
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{};
|
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 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());
|
auto result=ok?S_OK:HRESULT_FROM_WIN32(GetLastError());
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
#include "screenshot_notifications.h"
|
||||||
|
|
||||||
namespace ks {
|
namespace ks {
|
||||||
void launchScreenshot(App &app)
|
static void launch(App &app, const Clip *clip)
|
||||||
{
|
{
|
||||||
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
||||||
if (!std::filesystem::exists(path))
|
if (!std::filesystem::exists(path))
|
||||||
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
|
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
|
||||||
auto command=L"\""+path.wstring()+L"\" --auto-open-editor="+
|
auto command=L"\""+path.wstring()+L"\" ";
|
||||||
(app.settings.screenshotAutoOpenEditor?L"1":L"0");
|
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";
|
if(app.settings.writeLog) command+=L" --write-log";
|
||||||
STARTUPINFOW startup{sizeof(startup)};
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
PROCESS_INFORMATION process{};
|
PROCESS_INFORMATION process{};
|
||||||
@@ -20,4 +22,6 @@ void launchScreenshot(App &app)
|
|||||||
CloseHandle(process.hThread);
|
CloseHandle(process.hThread);
|
||||||
CloseHandle(process.hProcess);
|
CloseHandle(process.hProcess);
|
||||||
}
|
}
|
||||||
|
void launchScreenshot(App &app) { launch(app,nullptr); }
|
||||||
|
void launchScreenshotEditor(App &app,const Clip &clip) { launch(app,&clip); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,19 @@ std::wstring escapeXml(const std::wstring &s)
|
|||||||
return result;
|
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)
|
Clip loadNotifiedScreenshot(const std::wstring &id)
|
||||||
{
|
{
|
||||||
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
|
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
|
||||||
@@ -181,5 +194,44 @@ bool notifyScreenshot(const Clip &clip)
|
|||||||
}
|
}
|
||||||
std::string screenshotNotificationError() { return notificationError; }
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ int runScreenshotToastActivator();
|
|||||||
DWORD startScreenshotActivationListener();
|
DWORD startScreenshotActivationListener();
|
||||||
bool validScreenshotToken(const std::wstring &);
|
bool validScreenshotToken(const std::wstring &);
|
||||||
Clip loadNotifiedScreenshot(const std::wstring &);
|
Clip loadNotifiedScreenshot(const std::wstring &);
|
||||||
|
std::wstring cacheScreenshotForEditor(const Clip &);
|
||||||
}
|
}
|
||||||
|
|||||||
+157
-5
@@ -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}})
|
||||||
@@ -178,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;
|
||||||
}
|
}
|
||||||
@@ -196,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},
|
||||||
@@ -212,10 +290,22 @@ Bytes encodeSettings(const Settings &s)
|
|||||||
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}})
|
||||||
@@ -229,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));
|
||||||
@@ -297,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));
|
||||||
}
|
}
|
||||||
@@ -313,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
|
||||||
@@ -342,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -429,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();
|
||||||
}
|
}
|
||||||
@@ -445,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)
|
||||||
{
|
{
|
||||||
@@ -464,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();
|
||||||
@@ -477,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;
|
||||||
|
}
|
||||||
+397
-338
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -2,14 +2,17 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.4";
|
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 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)
|
||||||
|
|||||||
+335
@@ -1,11 +1,52 @@
|
|||||||
#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) {
|
static bool updateTimer(App &a) {
|
||||||
a.updateRunning=true; // Exercise timer scheduling without launching a network request.
|
a.updateRunning=true; // Exercise timer scheduling without launching a network request.
|
||||||
@@ -42,15 +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::UpdateCheckIntervalMs==21600000,"automatic update interval is six hours");
|
||||||
require(ks::InputRegressionAccess::updateTimer(app),"update timer rearms and avoids overlapping checks");
|
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","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::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"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");
|
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);
|
||||||
@@ -179,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);
|
||||||
@@ -478,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)})
|
||||||
@@ -505,10 +629,197 @@ 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");
|
if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
|
||||||
ks::showHistory(app,false);
|
ks::showHistory(app,false);
|
||||||
auto history=app.historyWindow;
|
auto history=app.historyWindow;
|
||||||
require(history&&!IsWindowVisible(history),"history layout tested without changing focus");
|
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; };
|
auto childRect=[&](int id) { RECT r{}; GetWindowRect(GetDlgItem(history,id),&r); MapWindowPoints(nullptr,history,(POINT*)&r,2); return r; };
|
||||||
RECT historyFrame{}; GetWindowRect(history,&historyFrame);
|
RECT historyFrame{}; GetWindowRect(history,&historyFrame);
|
||||||
auto listBefore=childRect(102),searchBefore=childRect(101),buttonBefore=childRect(110);
|
auto listBefore=childRect(102),searchBefore=childRect(101),buttonBefore=childRect(110);
|
||||||
@@ -551,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
#include <shlobj.h>
|
#include <shlobj.h>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
static std::wstring programs;
|
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)
|
static HRESULT testKnownFolder(REFKNOWNFOLDERID, DWORD, HANDLE, PWSTR *out)
|
||||||
{
|
{
|
||||||
*out=(PWSTR)CoTaskMemAlloc((programs.size()+1)*sizeof(wchar_t));
|
*out=(PWSTR)CoTaskMemAlloc((programs.size()+1)*sizeof(wchar_t));
|
||||||
@@ -10,8 +18,10 @@ static HRESULT testKnownFolder(REFKNOWNFOLDERID, DWORD, HANDLE, PWSTR *out)
|
|||||||
wcscpy(*out,programs.c_str()); return S_OK;
|
wcscpy(*out,programs.c_str()); return S_OK;
|
||||||
}
|
}
|
||||||
#define SHGetKnownFolderPath testKnownFolder
|
#define SHGetKnownFolderPath testKnownFolder
|
||||||
|
#define CreateProcessW testCreateProcess
|
||||||
#include "../src/screenshot_activation.cpp"
|
#include "../src/screenshot_activation.cpp"
|
||||||
#undef SHGetKnownFolderPath
|
#undef SHGetKnownFolderPath
|
||||||
|
#undef CreateProcessW
|
||||||
int main()
|
int main()
|
||||||
{
|
{
|
||||||
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||||
@@ -45,6 +55,18 @@ int main()
|
|||||||
valid=id.vt==VT_CLSID && *id.puuid==ks::ActivatorId; PropVariantClear(&id);
|
valid=id.vt==VT_CLSID && *id.puuid==ks::ActivatorId; PropVariantClear(&id);
|
||||||
if(!valid) throw std::runtime_error("shortcut activator missing");
|
if(!valid) throw std::runtime_error("shortcut activator missing");
|
||||||
ks::registerScreenshotNotifications(); // Registration is safe to repeat.
|
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";
|
std::cout<<"Passed isolated screenshot registration checks\n";
|
||||||
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; result=1; }
|
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; result=1; }
|
||||||
RegOverridePredefKey(HKEY_CURRENT_USER,nullptr);
|
RegOverridePredefKey(HKEY_CURRENT_USER,nullptr);
|
||||||
|
|||||||
+507
-4
@@ -2,10 +2,69 @@
|
|||||||
// 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)
|
||||||
{
|
{
|
||||||
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
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::ScreenshotHost app{GetModuleHandleW(nullptr)};
|
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);
|
||||||
@@ -22,6 +81,321 @@ int main()
|
|||||||
"editor is an independent Alt+Tab application window");
|
"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{};
|
||||||
@@ -31,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 #1","title includes editor index without 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;
|
||||||
@@ -120,11 +536,98 @@ int main()
|
|||||||
std::abs(std::hypot(double(c.marks[2].to.x-end.x),double(c.marks[2].to.y-end.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");
|
"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;
|
c.selected=2;
|
||||||
require(ks::markAt(c,c.marks[1].from).first==1,"selected connector cannot steal a badge click");
|
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;
|
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");
|
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});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+295
-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{'{', '}'}, {}); },
|
||||||
@@ -468,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'
|
||||||
@@ -24,6 +30,8 @@ try {
|
|||||||
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'
|
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}
|
$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'
|
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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user