Compare commits

...

5 Commits

97 changed files with 3489 additions and 95 deletions
+22 -6
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.2.2 LANGUAGES CXX RC) project(KSwitcher VERSION 0.4.2 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()
@@ -9,22 +9,37 @@ add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorre
target_include_directories(kscore PUBLIC src PRIVATE vendor) target_include_directories(kscore PUBLIC src PRIVATE vendor)
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00) target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore) target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/ui.cpp resources/app.rc) add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
target_link_libraries(KSwitcher PRIVATE kscore) target_link_libraries(KSwitcher PRIVATE kscore)
add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
set_target_properties(KSwitcherConfig PROPERTIES OUTPUT_NAME "KSwitcher.Config")
target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
target_link_libraries(KSwitcherConfig PRIVATE kscore)
add_executable(KSwitcherTests tests/tests.cpp) add_executable(KSwitcherTests tests/tests.cpp)
target_link_libraries(KSwitcherTests PRIVATE kscore) target_link_libraries(KSwitcherTests PRIVATE kscore)
add_executable(KSwitcherIntegration tests/integration.cpp src/app.cpp src/ui.cpp resources/app.rc) add_executable(KSwitcherIntegration tests/integration.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherIntegration PRIVATE kscore) target_link_libraries(KSwitcherIntegration PRIVATE kscore)
add_executable(KSwitcherInputTests tests/input.cpp src/app.cpp src/ui.cpp resources/app.rc) add_executable(KSwitcherInputTests tests/input.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherInputTests PRIVATE kscore) target_link_libraries(KSwitcherInputTests PRIVATE kscore)
add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp src/app.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherScreenshotUITests PRIVATE kscore)
if(MSVC)
target_compile_options(KSwitcherScreenshotUITests PRIVATE /utf-8)
set_property(TARGET KSwitcherScreenshotUITests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
else()
target_link_options(KSwitcherScreenshotUITests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
add_executable(KSwitcherOcrTests tests/ocr.cpp) add_executable(KSwitcherOcrTests tests/ocr.cpp)
target_link_libraries(KSwitcherOcrTests PRIVATE kscore) target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
if(MSVC) if(MSVC)
set_property(TARGET kscore KSwitcher KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>") set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
target_compile_options(kscore PRIVATE /W4 /utf-8) target_compile_options(kscore PRIVATE /W4 /utf-8)
target_compile_options(KSwitcher PRIVATE /W4 /utf-8) target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
else() else()
target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++) target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++) target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
@@ -33,9 +48,10 @@ endif()
enable_testing() enable_testing()
add_test(NAME native-regressions COMMAND KSwitcherTests) add_test(NAME native-regressions COMMAND KSwitcherTests)
add_test(NAME input-regressions COMMAND KSwitcherInputTests) add_test(NAME input-regressions COMMAND KSwitcherInputTests)
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
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 RUNTIME DESTINATION .) install(TARGETS KSwitcher KSwitcherConfig RUNTIME DESTINATION .)
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .) install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries") add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
+47
View File
@@ -19,3 +19,50 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
Third-party toolbar icons: Lucide (https://lucide.dev)
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+100 -5
View File
@@ -12,6 +12,16 @@ Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the e
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding. The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
The application lives in the notification area. Right-click its icon for Settings, Snippets and history. The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
Double-click the tray icon to open Settings, the bold default menu item. Double-click the tray icon to open Settings, the bold default menu item.
Settings and the snippet editor run on demand in **KSwitcher.Config.exe**, beside the main executable.
Repeated launches activate the existing configurator; closing its last window exits that process.
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
load clipboard history, dictionaries or global input hooks. Clipboard history and the log viewer remain
in the resident app. Keep both executables together when copying or updating the application.
If settings change in the tray while the configurator is open, a conflicting Save is rejected; reopen
the configurator to load the latest settings.
The first upgrade from v0.2.3 or earlier to this two-executable build requires manual extraction:
the older updater rejects additional executable files. Exit KSwitcher, replace both executables,
and retain `data/` and customized dictionaries. Subsequent updates support both executables.
In Settings, enable **Start with Windows** and **Start with Windows as administrator** In Settings, enable **Start with Windows** and **Start with Windows as administrator**
to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing
the elevated task requests UAC approval if the app is not already elevated. No password is stored; the elevated task requests UAC approval if the app is not already elevated. No password is stored;
@@ -43,6 +53,78 @@ keys cannot trigger recursive commands or overwrite the recorded typing run. Oth
is accepted, including key-up events and VK-only keyboard events. Unicode packets do not provide is accepted, including key-up events and VK-only keyboard events. Unicode packets do not provide
physical key positions and therefore reset recent-word tracking; selection conversion still works. physical key positions and therefore reset recent-word tracking; selection conversion still works.
### Screen capture and annotations
Press **Win+Shift+S**, choose **Capture screen region…** in the tray menu, or run
`KSwitcher.exe --capture`. Drag a rectangle on the dimmed, frozen desktop; the selected area stays
at normal brightness. Escape cancels. Selection and annotation frames are buffered to avoid flicker.
The editor opens with the selected area. Press **Ctrl+C** or **Enter** to copy it immediately,
or annotate first: **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;
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.
Select an arrow and drag the extra handle beside its midpoint perpendicular to the line to change
its thickness from 1 to 30 pixels. The arrowhead scales with the line. Minus/plus keys also work.
Alternatively, scroll the mouse wheel over an arrow to change its thickness, or over a number to
change its diameter. Wheel up increases the size; wheel down decreases it. These edits support Undo
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
are filled first, without renumbering existing objects. A cloned arrow keeps its style but starts without endpoint links.
The compact toolbar uses [Lucide](https://lucide.dev) icons with hover tooltips showing each action
and its keyboard shortcut.
Drop either arrow endpoint onto a number to link it to the circle's edge. Linked endpoints follow
the number when it moves or changes diameter. Drag an endpoint away to unlink it; moving the whole
arrow unlinks both ends. In Arrow mode, hold **Ctrl** while dragging to start a new arrow directly
from a number. Deleting a number leaves its arrows in place and removes their links; Undo restores them.
**Delete** removes the selected object. **Ctrl+Z** (Undo) reverses additions, moves, resizes and deletions
(up to 100 actions). **Ctrl+Y** or **Ctrl+Shift+Z** (Redo) restores undone edits; a new edit clears the
redo history. Undo and Redo buttons are disabled when unavailable. Enabled toolbar buttons use a
hand cursor. Selection handles are visible only in the editor, never in the copied image.
The **palette** changes the selected object's color: arrow, numbered marker, or highlight.
It also sets the color for newly drawn objects of every type. With nothing selected, only the
color for new objects changes. Number text switches between dark and white for readability.
**Highlight (H)** paints a translucent freehand marker stroke. Dark text remains readable beneath
the highlight. Drag a selected stroke
to move it, scroll over it to change width (4–100 pixels), or use Delete / Ctrl+D / Undo / Redo.
Hold Ctrl while drawing to paint a new stroke over an existing object. Highlights render beneath
arrows and numbered markers. The palette's color indicator reflects the selected object, or the
color for new objects when nothing is selected.
The editor status bar shows contextual tool, selection, rotation and text-editing hints.
Hover a toolbar button to see its action and shortcut; the title stays **KSwitcher — Screenshot**.
**Text (T)** adds text directly on the image. Enter inserts a line break; Ctrl+Enter or a click
outside commits the edit, and Escape cancels it. Double-click text or press F2 to edit it again.
The **Aa** icon opens the standard font dialog for family, size (6–200 pt), bold and italic.
The mouse wheel over text adjusts its font size. Text supports color, dragging, Ctrl+D,
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
automatic angle; only dragging the rotation handle switches to a fixed angle.
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
the arrow when moved or resized; drag the caption to adjust its offset. By default, the caption
angle follows the line (or middle curve segment) and stays upright. Rotating its handle manually
fixes its angle; Shift snaps to 15-degree steps. Undo restores automatic mode. The Aa icon changes
the selected caption font, and the mouse wheel over it changes its size. Captions and rotation
are included in clipboard export, cloning and Undo/Redo.
**Straight line (L)** creates a line without arrowheads. **Rectangle (R)**, **Circle (O)** and
**Ellipse (E)** create transparent shapes with colored outlines. Drag the two corner handles to
resize; circles keep equal width and height. Move shapes by their outlines, adjust thickness with
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
for shapes.
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
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
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
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.
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy closes the editor and publishes the image
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**.
Each capture opens an independent editor; use the shortcut again to take another screenshot while
keeping earlier editors and their annotations open.
Captures use ordinary clipboard history settings and limits. Protected video and secure desktops cannot be captured.
### Updates ### Updates
Select **Update** in the tray menu to check the stable Releases of Select **Update** in the tray menu to check the stable Releases of
@@ -62,8 +144,9 @@ intact and is reported when Update is selected. Startup network failures remain
The helper uses Windows' built-in Windows PowerShell 5.1; no additional runtime is installed. The helper uses Windows' built-in Windows PowerShell 5.1; no additional runtime is installed.
Settings and the other utility windows use the destination monitor's DPI for their initial frame, Settings and the other utility windows use the destination monitor's DPI for their initial frame,
controls and fonts. They can be resized, stay within a remaining monitor's work area after display controls and fonts. Settings has a fixed form size, without manual resizing or maximization; other
changes, and provide scrollbars when their content does not fit. Tab navigation scrolls the focused utility windows remain resizable. Windows stay within a remaining monitor's work area after display
changes and provide scrollbars when their content does not fit. Tab navigation scrolls the focused
field into view; Shift+wheel scrolls horizontally. field into view; Shift+wheel scrolls horizontally.
## Implemented ## Implemented
@@ -112,7 +195,7 @@ only bounded previews/metadata stay in memory. Image data is loaded on demand an
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. Successful operations stay in a memory buffer of at most 40 diagnostic events for 30 seconds.
Only failures or suspected repeated commands flush this context to `kswitcher.log`; flushed Only failures flush this context to `kswitcher.log`; flushed
context is discarded. Ordinary application errors are also logged when logging is enabled. context is discarded. Ordinary application errors are also logged when logging is enabled.
Incident context contains tracked physical input, the expected word, copied text, conversion result, 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,
@@ -121,8 +204,8 @@ original length and a truncation flag; each field is limited to 512 UTF-16 code
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. A second repeated manual command triggers a suspicion, not proof of failure; intentional input. Successful repeated commands do not flush the log; intentional layout cycling is normal.
layout cycling can also trigger it. An unchanged result containing no letters, or a direct command An unchanged result containing no letters, or a direct command
to the detected current layout, is treated as expected. `paste-sent-unverified` means Windows to the detected current layout, is treated as expected. `paste-sent-unverified` means Windows
accepted the paste shortcut; it does not prove that the editor applied the replacement. accepted the paste shortcut; it does not prove that the editor applied the replacement.
The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates. The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates.
@@ -227,6 +310,7 @@ Run `./package.ps1 -PublishRelease` after building and testing to refresh the tr
.\build.ps1 -Test # Pure/native regression checks; does not touch the user's clipboard .\build.ps1 -Test # Pure/native regression checks; does not touch the user's clipboard
.\build.ps1 -Ocr # Render a synthetic image, decode it with WIC and recognize it with Windows OCR .\build.ps1 -Ocr # Render a synthetic image, decode it with WIC and recognize it with Windows OCR
.\build.ps1 -Integration # Interactive, isolated test editor; avoid input for about 10 seconds .\build.ps1 -Integration # Interactive, isolated test editor; avoid input for about 10 seconds
.\tests\configuration.ps1 # Separate-process settings tests; close KSwitcher first
``` ```
`-Test` also checks remote/own input separation, hidden Settings layout and scrolling, and updater `-Test` also checks remote/own input separation, hidden Settings layout and scrolling, and updater
@@ -255,9 +339,20 @@ It still uses the normal instance lock; close a running KSwitcher first if measu
- `platform.*`: Win32 layouts, clipboard, input, ownership, DPAPI and file primitives. - `platform.*`: Win32 layouts, clipboard, input, ownership, DPAPI and file primitives.
- `storage.*`: settings and encrypted, disk-backed history. - `storage.*`: settings and encrypted, disk-backed history.
- `app.*`: message loop, hooks, background work and guarded text transactions. - `app.*`: message loop, hooks, background work and guarded text transactions.
- `config_main.cpp`, `configuration.*`: single-instance configurator and validated settings IPC.
- `ui.cpp`: native windows and shortcut recording. - `ui.cpp`: native windows and shortcut recording.
- `ocr.*`: Windows WIC and OCR via the native Windows Runtime ABI. - `ocr.*`: Windows WIC and OCR via the native Windows Runtime ABI.
- `tests/`: regression checks, real editor integration and OCR fixture. - `tests/`: regression checks, real editor integration and OCR fixture.
Application code is MIT licensed; preserves the original [Typlo](https://github.com/bitcodepro/Typlo) attribution in `LICENSE`. Application code is MIT licensed; preserves the original [Typlo](https://github.com/bitcodepro/Typlo) attribution in `LICENSE`.
Dictionary data has its own CC BY-SA 4.0 license as described above. Dictionary data has its own CC BY-SA 4.0 license as described above.
## Compatibility update bridge (v0.4.2)
This two-executable release can be installed by the v0.3.x–v0.4.1 updater.
It upgrades the updater to discover stable `release-vX.Y.Z` tags and validate
manifest-based packages. Use Update again after this version starts to install
the full current release, including the standalone screenshot editor.
The screenshot editor remains embedded only in this transitional release.
Future full releases must use the `release-v` prefix so unmigrated clients
continue to discover v0.4.2 first rather than skip this bridge.
Binary file not shown.
+22 -18
View File
@@ -1,31 +1,35 @@
# Latest Windows release # Latest Windows release
## v0.2.2 — 2026-10-28 ## v0.4.1 — 2026-10-28
- Fix the garbled dash in the executable description shown by Task Manager and Windows file properties. - Fix Settings spacing around Screen capture, automatic layout correction and layout controls.
- Explicitly compile the UTF-8 Windows resource source as UTF-8, both in the resource file and the portable build command. - Verify update checksums through .NET SHA-256 without requiring the PowerShell Get-FileHash command. This fixes updates from processes launched with a conflicting PowerShell module environment.
- Add regression checks against the compiled executable's version and exact Unicode description. - Capture a rectangular screen region with Win+Shift+S (configurable), with dimmed selection and independent editor windows.
- Annotate screenshots with numbered markers, arrows, straight/freehand lines, highlights, rectangles, circles, ellipses and inline text.
- Edit color, line style, arrowheads, thickness, font and size. Link arrow endpoints to markers and attach captions.
- Captions follow line angle and keep their relative offset until manually rotated. Text rotation supports Shift snapping in 15-degree steps.
- Move objects one pixel with arrow keys; clone with Ctrl+D; undo/redo edits. Numbered markers reuse gaps.
- Compact Lucide toolbar, contextual status bar, pencil cursor and confirmation before discarding an uncopied screenshot. Escape no longer closes the editor.
- Preserve the separate on-demand configurator and configurable capture shortcut.
Verified: 168 native checks, 51 input/layout checks, and 26 updater/metadata checks. Verified: 173 native checks, 379 input/rendering checks, screenshot editor UI integration checks,
The executable description is `KSwitcher — native keyboard and clipboard utility`. 29 updater checks, including missing Get-FileHash coverage, and package validation.
Download **KSwitcher-win-x64.zip**, extract the entire archive to a writable folder, **Upgrade from v0.3.0 or v0.4.0:** use Update in the tray menu. Both executables are supported.
and launch `KSwitcher.exe`. Keep `dictionaries/` next to the executable. If an older updater reports missing `Get-FileHash`, restart KSwitcher normally from Explorer
The archive contains the application, offline dictionaries, documentation and license notices. (outside a PowerShell 7 session) or install this ZIP manually once. v0.4.1 removes that dependency.
It does not contain personal settings or clipboard history. **Upgrade from v0.2.3 or earlier:** install manually once because those updaters reject the additional configurator EXE.
`SHA256SUMS.txt` contains the archive checksum. Exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
From version 0.2.1 onward, use **Update** in the tray for future releases. Startup checks notify; Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains both executables,
installation starts only after confirming Update. Older versions need this one manual upgrade: offline dictionaries, documentation and license notices; no personal settings or clipboard history.
exit KSwitcher and replace the application files, retaining `data/` and any customized dictionaries.
This folder holds the latest compiled version; older versions remain in Git history and Releases. Build and package:
To refresh the tracked package from source:
```powershell ```powershell
.\build.ps1 -Stage -Test .\build.ps1 -Stage -Test
.\package.ps1 -PublishRelease .\package.ps1 -PublishRelease
``` ```
Commit the ZIP and checksum together with their source changes. Publish both files as assets of Commit the archive, checksum and sources together. Publish both assets on a stable release whose
one stable release tagged with the executable version, so the updater can verify it. tag matches the executable version.
+1 -1
View File
@@ -1 +1 @@
9705cdb0c0a732eeae10b88e42101f9ff6714399ea8779e154fecc79269059a0 KSwitcher-win-x64.zip 7f0ee55eb5b56a6227431c1951ac2751208d3d0c7c34e4d1d616e180c37b1d9f KSwitcher-win-x64.zip
+16 -4
View File
@@ -14,30 +14,42 @@ Push-Location $root
try { try {
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections') $common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
$libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore') $libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','app','ui','main')) { foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','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" }
} }
& $compiler @common -DKS_RESIDENT_APP -c src/ui.cpp -o build/ui-resident.o
if ($LASTEXITCODE) { throw 'Resident UI compilation failed' }
& $compiler @common -DKS_CONFIG_APP -c src/ui.cpp -o build/ui-config.o
if ($LASTEXITCODE) { throw 'Configurator UI compilation failed' }
& windres --codepage=65001 resources/app.rc -O coff -o build/app-res.o & windres --codepage=65001 resources/app.rc -O coff -o build/app-res.o
if ($LASTEXITCODE) { throw 'Resource compilation failed' } if ($LASTEXITCODE) { throw 'Resource compilation failed' }
New-Item -ItemType Directory -Force build/release | Out-Null New-Item -ItemType Directory -Force build/release | Out-Null
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/app.o build/ui.o build/main.o build/app-res.o @libs -o $executablePath & $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
if ($LASTEXITCODE) { throw 'Link failed' } if ($LASTEXITCODE) { throw 'Link failed' }
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
if ($LASTEXITCODE) { throw 'Configurator link failed' }
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
if ($Test) { if ($Test) {
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe & $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
if ($LASTEXITCODE) { throw 'Test build failed' } if ($LASTEXITCODE) { throw 'Test build failed' }
& .\build\KSwitcherTests.exe & .\build\KSwitcherTests.exe
if ($LASTEXITCODE) { throw 'Tests failed' } if ($LASTEXITCODE) { throw 'Tests failed' }
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/app.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherInputTests.exe & $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input test build failed' } if ($LASTEXITCODE) { throw 'Input test build failed' }
& .\build\KSwitcherInputTests.exe & .\build\KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input tests failed' } if ($LASTEXITCODE) { throw 'Input tests failed' }
& $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
& .\build\KSwitcherScreenshotUITests.exe
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
& "$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' }
} }
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/app.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherIntegration.exe & $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration test build failed' } if ($LASTEXITCODE) { throw 'Integration test build failed' }
& .\build\KSwitcherIntegration.exe & .\build\KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration tests failed' } if ($LASTEXITCODE) { throw 'Integration tests failed' }
+2 -1
View File
@@ -4,12 +4,13 @@ $root = $PSScriptRoot
$destination = Join-Path $root 'dist\KSwitcher' $destination = Join-Path $root 'dist\KSwitcher'
New-Item -ItemType Directory -Force $destination | Out-Null New-Item -ItemType Directory -Force $destination | Out-Null
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
# Enumerate the distributable files explicitly; do not include a user's data directory. # Enumerate the distributable files explicitly; do not include a user's data directory.
$files = @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ } $files = @('KSwitcher.exe','KSwitcher.Config.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
$archive = Join-Path $root 'dist\KSwitcher-win-x64.zip' $archive = Join-Path $root 'dist\KSwitcher-win-x64.zip'
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
Get-FileHash -LiteralPath $archive -Algorithm SHA256 Get-FileHash -LiteralPath $archive -Algorithm SHA256
+52 -3
View File
@@ -2,11 +2,12 @@
#include <windows.h> #include <windows.h>
#include "resource.h" #include "resource.h"
IDI_KSWITCHER ICON "kswitcher.ico" IDI_KSWITCHER ICON "kswitcher.ico"
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,2,2,0 FILEVERSION 0,4,2,0
PRODUCTVERSION 0,2,2,0 PRODUCTVERSION 0,4,2,0
FILEOS VOS_NT_WINDOWS32 FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP FILETYPE VFT_APP
BEGIN BEGIN
@@ -16,7 +17,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.2.2" VALUE "FileVersion", "0.4.2"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
@@ -24,3 +25,51 @@ BEGIN
VALUE "Translation", 0x409, 1200 VALUE "Translation", 0x409, 1200
END END
END END
300 ICON "icons/lucide/circle-dot-normal.ico"
310 ICON "icons/lucide/circle-dot-active.ico"
320 ICON "icons/lucide/circle-dot-disabled.ico"
301 ICON "icons/lucide/arrow-up-right-normal.ico"
311 ICON "icons/lucide/arrow-up-right-active.ico"
321 ICON "icons/lucide/arrow-up-right-disabled.ico"
302 ICON "icons/lucide/undo-2-normal.ico"
312 ICON "icons/lucide/undo-2-active.ico"
322 ICON "icons/lucide/undo-2-disabled.ico"
303 ICON "icons/lucide/copy-normal.ico"
313 ICON "icons/lucide/copy-active.ico"
323 ICON "icons/lucide/copy-disabled.ico"
304 ICON "icons/lucide/x-normal.ico"
314 ICON "icons/lucide/x-active.ico"
324 ICON "icons/lucide/x-disabled.ico"
305 ICON "icons/lucide/redo-2-normal.ico"
315 ICON "icons/lucide/redo-2-active.ico"
325 ICON "icons/lucide/redo-2-disabled.ico"
306 ICON "icons/lucide/highlighter-normal.ico"
316 ICON "icons/lucide/highlighter-active.ico"
326 ICON "icons/lucide/highlighter-disabled.ico"
307 ICON "icons/lucide/palette-normal.ico"
317 ICON "icons/lucide/palette-active.ico"
327 ICON "icons/lucide/palette-disabled.ico"
308 ICON "icons/lucide/pencil-normal.ico"
318 ICON "icons/lucide/pencil-active.ico"
328 ICON "icons/lucide/pencil-disabled.ico"
400 ICON "icons/lucide/slash-normal.ico"
410 ICON "icons/lucide/slash-active.ico"
420 ICON "icons/lucide/slash-disabled.ico"
401 ICON "icons/lucide/square-normal.ico"
411 ICON "icons/lucide/square-active.ico"
421 ICON "icons/lucide/square-disabled.ico"
402 ICON "icons/lucide/circle-normal.ico"
412 ICON "icons/lucide/circle-active.ico"
422 ICON "icons/lucide/circle-disabled.ico"
403 ICON "icons/lucide/ellipse-normal.ico"
413 ICON "icons/lucide/ellipse-active.ico"
423 ICON "icons/lucide/ellipse-disabled.ico"
404 ICON "icons/lucide/type-normal.ico"
414 ICON "icons/lucide/type-active.ico"
424 ICON "icons/lucide/type-disabled.ico"
405 ICON "icons/lucide/a-large-small-normal.ico"
415 ICON "icons/lucide/a-large-small-active.ico"
425 ICON "icons/lucide/a-large-small-disabled.ico"
+43
View File
@@ -0,0 +1,43 @@
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5
View File
@@ -0,0 +1,5 @@
Lucide https://github.com/lucide-icons/lucide
Commit: be09619c1c1124ad09c19fc3e87ee3b939409bbd
SVG originals retained. ICO variants generated with tools/generate-toolbar-icons.py.
Ellipse is adapted from the pinned Lucide circle SVG (r replaced with rx/ry).
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m15 16 2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16" />
<path d="M15.697 14h5.606" />
<path d="m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16" />
<path d="M3.304 13h6.392" />
</svg>

After

Width:  |  Height:  |  Size: 391 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M7 7h10v10" />
<path d="M7 17 17 7" />
</svg>

After

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="12" r="10" />
</svg>

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
</svg>

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>

After

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<ellipse cx="12" cy="12" rx="10" ry="7" />
</svg>

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m9 11-6 6v3h9l3-3" />
<path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4" />
</svg>

After

Width:  |  Height:  |  Size: 317 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14" />
</svg>

After

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z" />
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 571 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
<path d="m15 5 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 377 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m15 14 5-5-5-5" />
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13" />
</svg>

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 2 2 22" />
</svg>

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 4v16" />
<path d="M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2" />
<path d="M9 20h6" />
</svg>

After

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 14 4 9l5-5" />
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11" />
</svg>

After

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>

After

Width:  |  Height:  |  Size: 260 B

+1
View File
@@ -1,2 +1,3 @@
#pragma once #pragma once
#define IDI_KSWITCHER 101 #define IDI_KSWITCHER 101
#define IDC_SCREENSHOT_PENCIL 500
+90 -7
View File
@@ -9,8 +9,8 @@ $ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
function Get-StableVersion([string]$Tag) { function Get-StableVersion([string]$Tag) {
if ($Tag -notmatch '^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null } if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
try { return [version]($Tag -replace '^v','') } catch { return $null } try { return [version]($Tag -replace '^(?:release-)?v','') } catch { return $null }
} }
function Assert-ReleaseUrl([string]$Url) { function Assert-ReleaseUrl([string]$Url) {
$uri = [uri]$Url $uri = [uri]$Url
@@ -63,9 +63,15 @@ function Assert-Checksum([string]$Archive,[string]$Checksum) {
$matchesFound = @([IO.File]::ReadAllLines($Checksum) | Where-Object { $_ -cmatch '^([a-fA-F0-9]{64})\s+\*?KSwitcher-win-x64\.zip$' }) $matchesFound = @([IO.File]::ReadAllLines($Checksum) | Where-Object { $_ -cmatch '^([a-fA-F0-9]{64})\s+\*?KSwitcher-win-x64\.zip$' })
if ($matchesFound.Count -ne 1) { throw 'Missing or ambiguous archive checksum.' } if ($matchesFound.Count -ne 1) { throw 'Missing or ambiguous archive checksum.' }
$expected = ($matchesFound[0] -split '\s+')[0] $expected = ($matchesFound[0] -split '\s+')[0]
if ((Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash -ne $expected) { throw 'Release checksum verification failed.' } # Do not depend on PowerShell module autoloading: hosts can inherit a
# PowerShell 7 PSModulePath while this helper runs in Windows PowerShell 5.1.
$stream = [IO.File]::OpenRead($Archive)
$sha = [Security.Cryptography.SHA256]::Create()
try { $actual = [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','') }
finally { $sha.Dispose(); $stream.Dispose() }
if ($actual -ne $expected) { throw 'Release checksum verification failed.' }
} }
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) { function Expand-LegacyRelease([string]$Archive,[string]$Destination,[version]$Expected) {
Add-Type -AssemblyName System.IO.Compression.FileSystem Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::OpenRead($Archive) $zip = [IO.Compression.ZipFile]::OpenRead($Archive)
try { try {
@@ -74,7 +80,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
foreach ($entry in $zip.Entries) { foreach ($entry in $zip.Entries) {
$name = $entry.FullName.Replace('\','/') $name = $entry.FullName.Replace('\','/')
if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue } if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue }
if ($name -cnotmatch '^(KSwitcher\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or if ($name -cnotmatch '^(KSwitcher(?:\.(?:Config|Screenshot))?\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or
$name.EndsWith('/.') -or $name.EndsWith('/..') -or $name.EndsWith('.') -or $name.EndsWith('/.') -or $name.EndsWith('/..') -or $name.EndsWith('.') -or
(($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($name)) { (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($name)) {
throw "Unexpected or unsafe archive entry: $name" throw "Unexpected or unsafe archive entry: $name"
@@ -88,8 +94,13 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
foreach ($required in @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md')) { foreach ($required in @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md')) {
if (!$seen.Contains($required)) { throw "Incomplete release: $required" } if (!$seen.Contains($required)) { throw "Incomplete release: $required" }
} }
if ($Expected -gt [version]'0.2.3' -and !$seen.Contains('KSwitcher.Config.exe')) {
throw 'Incomplete release: KSwitcher.Config.exe'
}
} finally { $zip.Dispose() } } finally { $zip.Dispose() }
$exe = Join-Path $Destination 'KSwitcher.exe' foreach ($binary in @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe')) {
$exe = Join-Path $Destination $binary
if (!(Test-Path -LiteralPath $exe)) { continue }
$version = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe) $version = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe)
if ($version.ProductName -ne 'KSwitcher' -or (Get-StableVersion $version.FileVersion) -ne $Expected) { if ($version.ProductName -ne 'KSwitcher' -or (Get-StableVersion $version.FileVersion) -ne $Expected) {
throw 'Executable identity/version does not match the release.' throw 'Executable identity/version does not match the release.'
@@ -99,6 +110,76 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
$pe = [BitConverter]::ToInt32($bytes,60) $pe = [BitConverter]::ToInt32($bytes,60)
if ($pe -lt 64 -or $pe -gt $bytes.Length-6 -or [BitConverter]::ToUInt32($bytes,$pe) -ne 0x4550 -or if ($pe -lt 64 -or $pe -gt $bytes.Length-6 -or [BitConverter]::ToUInt32($bytes,$pe) -ne 0x4550 -or
[BitConverter]::ToUInt16($bytes,$pe+4) -ne 0x8664) { throw 'Release is not a Windows x64 executable.' } [BitConverter]::ToUInt16($bytes,$pe+4) -ne 0x8664) { throw 'Release is not a Windows x64 executable.' }
}
}
function Get-Sha256([IO.Stream]$Stream) {
$sha=[Security.Cryptography.SHA256]::Create()
try { return [BitConverter]::ToString($sha.ComputeHash($Stream)).Replace('-','').ToLowerInvariant() }
finally { $sha.Dispose() }
}
function Assert-PackagePath([string]$Name) {
if (!$Name -or $Name.Length -gt 220 -or $Name -match '[\\:<>"|?*]' -or $Name.StartsWith('/')) { throw "Unsafe package path: $Name" }
$parts=$Name.Split('/')
foreach ($part in $parts) {
if (!$part -or $part -in @('.','..') -or $part -match '[. ]$|[\x00-\x1f]' -or
$part -match '^(?i:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\.|$)') { throw "Unsafe package path: $Name" }
}
if ($parts[0] -ieq 'data' -or $parts[0] -like '.kswitcher-*') { throw "Protected user path: $Name" }
}
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip=[IO.Compression.ZipFile]::OpenRead($Archive)
try {
$manifests=@($zip.Entries | Where-Object FullName -CEQ 'release-files.json')
if (!$manifests.Count) {
if ($Expected -gt [version]'0.4.2') { throw 'Release manifest is missing.' }
$zip.Dispose(); $zip=$null
Expand-LegacyRelease $Archive $Destination $Expected
return
}
if ($manifests.Count -ne 1 -or $manifests[0].Length -gt 256KB) { throw 'Invalid release manifest.' }
$reader=[IO.StreamReader]::new($manifests[0].Open())
try { $manifest=$reader.ReadToEnd() | ConvertFrom-Json } finally { $reader.Dispose() }
if ($manifest.schema -ne 1 -or (Get-StableVersion $manifest.version) -ne $Expected -or
@($manifest.files).Count -lt 1 -or @($manifest.files).Count -gt 1000) { throw 'Unsupported or mismatched release manifest.' }
$declared=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($file in $manifest.files) {
Assert-PackagePath $file.path
if ($file.path -ieq 'release-files.json' -or $declared.ContainsKey($file.path) -or
$file.sha256 -notmatch '^[a-fA-F0-9]{64}$' -or $file.size -lt 0 -or $file.size -gt 100MB) { throw 'Invalid manifest file entry.' }
$declared.Add($file.path,$file)
}
if (!$declared.ContainsKey('KSwitcher.exe')) { throw 'Main executable is missing from the manifest.' }
$seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$total=0L
foreach ($entry in $zip.Entries) {
$name=$entry.FullName.Replace('\','/')
if ($name.EndsWith('/')) { Assert-PackagePath $name.TrimEnd('/'); continue }
Assert-PackagePath $name
if (!$seen.Add($name) -or (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000) { throw 'Duplicate or linked archive entry.' }
if ($name -ceq 'release-files.json') { continue }
if (!$declared.ContainsKey($name)) { throw "Undeclared package file: $name" }
$file=$declared[$name]
if ($entry.Length -ne $file.size) { throw "Package file size mismatch: $name" }
$total+=$entry.Length
if ($total -gt 100MB -or $zip.Entries.Count -gt 1100) { throw 'Release expands beyond allowed limits.' }
$stream=$entry.Open()
try { $hash=Get-Sha256 $stream } finally { $stream.Dispose() }
if ($hash -ne $file.sha256) { throw "Package file checksum mismatch: $name" }
}
if ($seen.Count -ne $declared.Count+1) { throw 'Archive does not contain every manifest file.' }
[IO.Directory]::CreateDirectory($Destination) | Out-Null
Assert-PlainDirectory $Destination
foreach ($entry in $zip.Entries) {
if ($entry.FullName.EndsWith('/')) { continue }
$target=Join-Path $Destination $entry.FullName
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($target)) | Out-Null
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($target))
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry,$target,$false)
}
$info=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $Destination 'KSwitcher.exe'))
if ($info.ProductName -ne 'KSwitcher' -or (Get-StableVersion $info.FileVersion) -ne $Expected) { throw 'Main executable identity/version mismatch.' }
} finally { if ($zip) { $zip.Dispose() } }
} }
function Assert-PlainDirectory([string]$Directory) { function Assert-PlainDirectory([string]$Directory) {
$item = Get-Item -LiteralPath $Directory -Force $item = Get-Item -LiteralPath $Directory -Force
@@ -112,7 +193,7 @@ function Install-Files([string]$Stage,[string]$Target,[string]$Backup,[Collectio
foreach ($file in @(Get-ChildItem -LiteralPath $Stage -File -Recurse)) { foreach ($file in @(Get-ChildItem -LiteralPath $Stage -File -Recurse)) {
$relative = $file.FullName.Substring($Stage.Length+1) $relative = $file.FullName.Substring($Stage.Length+1)
$destination = Join-Path $Target $relative $destination = Join-Path $Target $relative
if ($relative.StartsWith('dictionaries\') -and (Test-Path -LiteralPath $destination)) { continue } if ($relative.StartsWith('dictionaries\',[StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $destination)) { continue }
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($destination)) Assert-PlainDirectory ([IO.Path]::GetDirectoryName($destination))
if (Test-Path -LiteralPath $destination) { if (Test-Path -LiteralPath $destination) {
@@ -165,6 +246,8 @@ try {
Receive-File $release.checksum $checksum 64KB Receive-File $release.checksum $checksum 64KB
Assert-Checksum $archive $checksum Assert-Checksum $archive $checksum
Expand-Release $archive $stage (Get-StableVersion $release.version) Expand-Release $archive $stage (Get-StableVersion $release.version)
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
Write-Result @{state='prepared';version=$release.version} Write-Result @{state='prepared';version=$release.version}
$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.' }
+52 -12
View File
@@ -1,6 +1,7 @@
#include "app.h" #include "app.h"
#include "../resources/resource.h" #include "../resources/resource.h"
#include "ocr.h" #include "ocr.h"
#include "configuration.h"
#include <algorithm> #include <algorithm>
#include <commctrl.h> #include <commctrl.h>
#include <set> #include <set>
@@ -16,7 +17,8 @@ std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
{Case, s.casing}, {Case, s.casing},
{Translit, s.translit}, {Translit, s.translit},
{Undo, s.undo}, {Undo, s.undo},
{ShowHistory, s.clipboard}}; {ShowHistory, s.clipboard},
{Screenshot, s.screenshot}};
for (size_t i = 0; i < s.direct.size(); ++i) for (size_t i = 0; i < s.direct.size(); ++i)
out.emplace_back(Direct + (int)i, s.direct[i]); out.emplace_back(Direct + (int)i, s.direct[i]);
return out; return out;
@@ -398,6 +400,18 @@ void App::reloadLayouts()
} }
registerHotkeys(settings); registerHotkeys(settings);
} }
void App::clearHistory()
{
if (busy) throw std::runtime_error("Wait for the current operation to finish.");
if (history) history->clear();
if (historyWindow) refreshHistory(*this);
}
void App::setHotkeyCapture(HWND field)
{
hotkeyCapture = field;
hotkeyCaptureProcess = 0;
if (field) GetWindowThreadProcessId(field, &hotkeyCaptureProcess);
}
bool App::isHotkey(DWORD key, UINT mods) bool App::isHotkey(DWORD key, UINT mods)
{ {
for (auto &[id, text] : shortcuts(settings)) for (auto &[id, text] : shortcuts(settings))
@@ -496,6 +510,10 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
// down AND up events use exactly the same path as physical keyboard input. // down AND up events use exactly the same path as physical keyboard input.
if (ownKeyboardInput(key) || installingUpdate) if (ownKeyboardInput(key) || installingUpdate)
return false; return false;
DWORD captureProcess{};
if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess);
if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess))
setHotkeyCapture(nullptr);
if (hotkeyCapture && GetForegroundWindow() == GetAncestor(hotkeyCapture, GA_ROOT)) if (hotkeyCapture && GetForegroundWindow() == GetAncestor(hotkeyCapture, GA_ROOT))
{ {
bool modifier = key.vkCode == VK_CONTROL || key.vkCode == VK_SHIFT || key.vkCode == VK_MENU || bool modifier = key.vkCode == VK_CONTROL || key.vkCode == VK_SHIFT || key.vkCode == VK_MENU ||
@@ -569,6 +587,11 @@ void App::dispatch(int command)
return; return;
try try
{ {
if (command == Screenshot)
{
if (!busy) showScreenshot(*this);
return;
}
if (command == ShowHistory) if (command == ShowHistory)
{ {
showHistory(*this); showHistory(*this);
@@ -579,6 +602,11 @@ void App::dispatch(int command)
showSettings(*this); showSettings(*this);
return; return;
} }
if (command == 22)
{
showLogs(*this);
return;
}
if (command == 21) if (command == 21)
{ {
showSnippets(*this); showSnippets(*this);
@@ -619,9 +647,6 @@ void App::transform(int command, int snippet)
L" window=" + std::to_wstring(reinterpret_cast<uintptr_t>(target)) + L" window=" + std::to_wstring(reinterpret_cast<uintptr_t>(target)) +
L" version=" + std::to_wstring(currentVersion) + L" version=" + std::to_wstring(currentVersion) +
L" repeat=" + std::to_wstring(repeatAttempts)); L" repeat=" + std::to_wstring(repeatAttempts));
if (repeatAttempts >= 2)
diagnosticEvent(L"Transform suspicion id=" + std::to_wstring(attempt) +
L" reason=repeated-command-without-input (may be intentional cycling)");
} }
if (busy.exchange(true)) if (busy.exchange(true))
{ {
@@ -643,8 +668,7 @@ void App::transform(int command, int snippet)
version = inputVersion; version = inputVersion;
} }
enqueue( enqueue(
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt, [this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt]
suspicious = manual && repeatAttempts >= 2]
{ {
struct BusyReset struct BusyReset
{ {
@@ -679,7 +703,6 @@ void App::transform(int command, int snippet)
uint64_t version, attempt; uint64_t version, attempt;
ULONGLONG start; ULONGLONG start;
bool enabled; bool enabled;
bool suspicious;
std::wstring &reason; std::wstring &reason;
std::wstring &text, &result, &expected; std::wstring &text, &result, &expected;
int &hint, &to; int &hint, &to;
@@ -693,10 +716,10 @@ void App::transform(int command, int snippet)
L" source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) + L" source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
L" expected=" + diagnosticText(expected) + L" copied=" + diagnosticText(text) + L" expected=" + diagnosticText(expected) + L" copied=" + diagnosticText(text) +
L" result=" + diagnosticText(result), L" result=" + diagnosticText(result),
suspicious || (reason != L"paste-sent-unverified" && reason != L"layout-only" && reason != L"paste-sent-unverified" && reason != L"layout-only" &&
reason != L"unchanged-expected")); reason != L"unchanged-expected");
} }
} audit{*this, target, version, attempt, requestedAt, manual, suspicious, reason, text, result, expected, hint, to}; } audit{*this, target, version, attempt, requestedAt, manual, reason, text, result, expected, hint, to};
try try
{ {
auto valid = [&] auto valid = [&]
@@ -1079,6 +1102,7 @@ void App::trayMenu()
{ {
HMENU menu = CreatePopupMenu(); HMENU menu = CreatePopupMenu();
AppendMenuW(menu, MF_STRING, 10, L"Clipboard history"); AppendMenuW(menu, MF_STRING, 10, L"Clipboard history");
AppendMenuW(menu, MF_STRING, 24, L"Capture screen region…");
AppendMenuW(menu, MF_STRING, 11, L"Settings…"); AppendMenuW(menu, MF_STRING, 11, L"Settings…");
SetMenuDefaultItem(menu, 11, FALSE); SetMenuDefaultItem(menu, 11, FALSE);
AppendMenuW(menu, MF_STRING, 12, L"Snippets…"); AppendMenuW(menu, MF_STRING, 12, L"Snippets…");
@@ -1110,6 +1134,9 @@ void App::trayMenu()
{ {
switch (cmd) switch (cmd)
{ {
case 24:
dispatch(Screenshot);
break;
case 10: case 10:
showHistory(*this); showHistory(*this);
break; break;
@@ -1134,9 +1161,9 @@ void App::trayMenu()
break; break;
case 16: case 16:
MessageBoxW(window, MessageBoxW(window,
L"KSwitcher 0.2.2\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on " (std::wstring(L"KSwitcher ") + AppVersion + L"\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on "
L"Typlo by bitcodepro. MIT license.\nHistory is encrypted for your Windows " L"Typlo by bitcodepro. MIT license.\nHistory is encrypted for your Windows "
L"account.\nNo .NET, WebView or graphics framework.", L"account.\nNo .NET, WebView or graphics framework.").c_str(),
L"About KSwitcher", MB_OK); L"About KSwitcher", MB_OK);
break; break;
case 17: case 17:
@@ -1170,6 +1197,11 @@ void App::trayMenu()
void App::checkUpdate(bool interactive, bool install, const std::wstring &version) void App::checkUpdate(bool interactive, bool install, const std::wstring &version)
{ {
if (updateRunning) return; if (updateRunning) return;
if (install && FindWindowW(L"KSwitcher.Config", nullptr))
{
report(L"Close the configurator before installing an update.");
return;
}
if (install && busy) if (install && busy)
{ {
report(L"Wait for the current text operation to finish, then select Update again."); report(L"Wait for the current text operation to finish, then select Update again.");
@@ -1289,6 +1321,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
case WM_COPYDATA: case WM_COPYDATA:
{ {
auto *data = (COPYDATASTRUCT *)lp; auto *data = (COPYDATASTRUCT *)lp;
if (data && data->dwData >= ConfigRead && data->dwData < ConfigReply)
{
if (app->installingUpdate) return FALSE;
return configurationRequest(*app, (HWND)wp, *data);
}
if (data && data->dwData == 1 && data->cbData == sizeof(int)) if (data && data->dwData == 1 && data->cbData == sizeof(int))
{ {
int cmd; int cmd;
@@ -1345,6 +1382,9 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
DestroyWindow(hwnd); DestroyWindow(hwnd);
return 0; return 0;
case WM_DESTROY: case WM_DESTROY:
if (auto config = FindWindowW(L"KSwitcher.Config", nullptr);
config && configurationPeer(config, L"KSwitcher.Config.exe"))
PostMessageW(config, WM_CLOSE, 0, 0);
PostQuitMessage(0); PostQuitMessage(0);
return 0; return 0;
default: default:
+5 -1
View File
@@ -14,7 +14,7 @@ namespace ks
inline constexpr UINT TrayMessage = WM_APP + 1, ErrorMessage = WM_APP + 2, HistoryMessage = WM_APP + 3, inline constexpr UINT TrayMessage = WM_APP + 1, ErrorMessage = WM_APP + 2, HistoryMessage = WM_APP + 3,
SnippetMessage = WM_APP + 4; SnippetMessage = WM_APP + 4;
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,
Direct = 100; Screenshot = 7, Direct = 100;
inline constexpr UINT AutoCorrectMessage = WM_APP + 6; inline constexpr UINT AutoCorrectMessage = WM_APP + 6;
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5; inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
struct LastTransform struct LastTransform
@@ -43,6 +43,7 @@ class App
std::atomic<ULONGLONG> pauseUntil{0}; std::atomic<ULONGLONG> pauseUntil{0};
HWND pasteTarget{}; HWND pasteTarget{};
HWND hotkeyCapture{}; HWND hotkeyCapture{};
DWORD hotkeyCaptureProcess{};
HWND logWindow{}; HWND logWindow{};
std::mutex stateMutex; std::mutex stateMutex;
Run run; Run run;
@@ -66,6 +67,8 @@ class App
bool registerHotkeys(const Settings &, bool rollback = false); bool registerHotkeys(const Settings &, bool rollback = false);
void applySettings(const Settings &); void applySettings(const Settings &);
void reloadLayouts(); void reloadLayouts();
void clearHistory();
void setHotkeyCapture(HWND);
bool isHotkey(DWORD, UINT); bool isHotkey(DWORD, UINT);
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers); void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers); bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
@@ -97,6 +100,7 @@ class App
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM); static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
}; };
void showHistory(App &); void showHistory(App &);
void showScreenshot(App &);
void refreshHistory(App &); void refreshHistory(App &);
void showSettings(App &, bool visible = true); void showSettings(App &, bool visible = true);
void showLogs(App &); void showLogs(App &);
+205
View File
@@ -0,0 +1,205 @@
#include "configuration.h"
#include <commctrl.h>
#include <cstring>
namespace ks
{
App *App::current = nullptr;
namespace
{
HWND service{};
Bytes response;
bool replied{};
Bytes request(App &app, ULONG_PTR command, const Bytes &payload = {})
{
if (!service || !configurationPeer(service, L"KSwitcher.exe"))
throw std::runtime_error("KSwitcher has stopped. Close and reopen the configurator.");
if (payload.size() > ConfigLimit) throw std::runtime_error("Settings are too large.");
response.clear(); replied = false;
COPYDATASTRUCT message{command, (DWORD)payload.size(), const_cast<unsigned char *>(payload.data())};
DWORD_PTR result{};
SetLastError(ERROR_SUCCESS);
if (!SendMessageTimeoutW(service, WM_COPYDATA, (WPARAM)app.window, (LPARAM)&message,
SMTO_ABORTIFHUNG | SMTO_ERRORONEXIT, 15000, &result) || !replied)
{
if (GetLastError() == ERROR_ACCESS_DENIED)
throw std::runtime_error("Open Settings from the KSwitcher tray menu so both processes use the same privileges.");
throw std::runtime_error("KSwitcher did not respond. The operation may still finish; reopen Settings to check.");
}
if (result != 1)
throw std::runtime_error(response.empty() ? "KSwitcher rejected the request." :
std::string(response.begin(), response.end()));
return response;
}
LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
{
auto *app = App::current;
if (!app) return DefWindowProcW(window, message, wp, lp);
if (message == WM_COPYDATA)
{
auto *data = reinterpret_cast<COPYDATASTRUCT *>(lp);
if ((HWND)wp == service && data && data->dwData == ConfigReply && data->cbData <= ConfigLimit &&
(!data->cbData || data->lpData))
{
response.clear();
if (data->cbData)
{
auto first = static_cast<const unsigned char *>(data->lpData);
response.assign(first, first + data->cbData);
}
replied = true;
return TRUE;
}
return FALSE;
}
if (message == WM_APP + 20)
{
try
{
if (wp == 21) showSnippets(*app);
else showSettings(*app);
}
catch (const std::exception &e)
{
MessageBoxW(app->settingsWindow, wide(e.what()).c_str(), L"KSwitcher configurator", MB_OK | MB_ICONERROR);
}
return 0;
}
if (message == WM_CLOSE)
{
if (app->settingsWindow) DestroyWindow(app->settingsWindow);
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
PostQuitMessage(0);
return 0;
}
return DefWindowProcW(window, message, wp, lp);
}
}
// The configurator owns no history store, dictionaries, worker or input hooks.
// All mutations are validated and committed by the resident process.
App::App(HINSTANCE h, bool, const std::filesystem::path &) : instance(h)
{
current = this;
layouts = readLayouts();
}
App::~App()
{
if (settingsWindow) DestroyWindow(settingsWindow);
if (snippetsWindow) DestroyWindow(snippetsWindow);
if (window) DestroyWindow(window);
current = nullptr;
}
void App::applySettings(const Settings &next)
{
validateSettings(next);
auto before = encodeSettings(settings), after = encodeSettings(next);
DWORD length = (DWORD)before.size();
Bytes payload(sizeof(length));
memcpy(payload.data(), &length, sizeof(length));
payload.insert(payload.end(), before.begin(), before.end());
payload.insert(payload.end(), after.begin(), after.end());
settings = decodeSettings(request(*this, ConfigSave, payload), layouts);
}
void App::reloadLayouts()
{
auto bytes = request(*this, ConfigLayouts);
layouts = readLayouts();
settings = decodeSettings(bytes, layouts);
}
void App::clearHistory() { request(*this, ConfigClearHistory); }
void App::setHotkeyCapture(HWND field)
{
hotkeyCapture = field;
Bytes payload(sizeof(field));
memcpy(payload.data(), &field, sizeof(field));
// Focus notifications must not let an exception escape a window callback.
try { request(*this, ConfigCapture, payload); }
catch (const std::exception &e)
{
hotkeyCapture = nullptr;
if (field) MessageBoxW(settingsWindow, wide(e.what()).c_str(), L"Shortcut capture unavailable", MB_OK | MB_ICONWARNING);
}
}
void showLogs(App &app) { request(app, ConfigLogs); }
int runConfigurator(HINSTANCE instance, bool snippets)
{
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock.");
const bool existing = GetLastError() == ERROR_ALREADY_EXISTS;
struct Close { HANDLE handle; ~Close() { CloseHandle(handle); } } close{singleton};
if (existing)
{
HWND window{};
for (int i = 0; i < 100 && !window; ++i)
{
window = FindWindowW(L"KSwitcher.Config", nullptr);
if (!window) Sleep(50);
}
if (!window || !configurationPeer(window, L"KSwitcher.Config.exe"))
throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly.");
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
AllowSetForegroundWindow(pid);
PostMessageW(window, WM_APP + 20, snippets ? 21 : 20, 0);
return 0;
}
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
if (!service)
{
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.exe";
auto command = L"\"" + path.wstring() + L"\"";
STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{};
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
path.parent_path().c_str(), &startup, &process))
throw std::runtime_error("Cannot start KSwitcher beside the configurator.");
CloseHandle(process.hThread); CloseHandle(process.hProcess);
for (int i = 0; i < 100 && !service; ++i)
{
Sleep(50); service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
}
}
if (!configurationPeer(service, L"KSwitcher.exe"))
throw std::runtime_error("Run the configurator from the active KSwitcher installation.");
INITCOMMONCONTROLSEX controls{sizeof(controls), ICC_STANDARD_CLASSES};
InitCommonControlsEx(&controls);
App app(instance);
WNDCLASSW cls{}; cls.hInstance = instance; cls.lpfnWndProc = controller; cls.lpszClassName = L"KSwitcher.Config";
RegisterClassW(&cls);
app.window = CreateWindowExW(WS_EX_TOOLWINDOW, cls.lpszClassName, L"KSwitcher configurator", WS_POPUP,
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
app.settings = decodeSettings(request(app, ConfigRead), app.layouts);
if (snippets) showSnippets(app); else showSettings(app);
MSG message;
while ((app.settingsWindow || app.snippetsWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
{
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
{
HWND popup = GetLastActivePopup(app.settingsWindow);
if (popup != app.settingsWindow && IsDialogMessageW(popup, &message)) continue;
}
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
TranslateMessage(&message); DispatchMessageW(&message);
}
return 0;
}
}
int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
{
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
int result = 1;
try
{
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
LocalFree(args);
result = ks::runConfigurator(instance, snippets);
}
catch (const std::exception &e)
{
MessageBoxW(nullptr, ks::wide(e.what()).c_str(), L"KSwitcher configurator", MB_OK | MB_ICONERROR);
}
CoUninitialize();
return result;
}
+98
View File
@@ -0,0 +1,98 @@
#include "configuration.h"
#include <cstring>
namespace ks
{
bool configurationPeer(HWND window, const wchar_t *filename)
{
DWORD pid{};
GetWindowThreadProcessId(window, &pid);
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
if (!process) return false;
wchar_t path[32768]; DWORD count = std::size(path);
bool ok = QueryFullProcessImageNameW(process, 0, path, &count) != 0;
CloseHandle(process);
auto expected = std::filesystem::path(executable()).parent_path() / filename;
return ok && _wcsicmp(path, expected.c_str()) == 0;
}
void launchConfiguration(App &app, bool snippets)
{
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
if (!std::filesystem::exists(path))
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package.");
auto command = L"\"" + path.wstring() + L"\"" + (snippets ? L" --snippets" : L"");
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
path.parent_path().c_str(), &startup, &process))
throw std::runtime_error(utf8(lastError(L"Cannot start configurator")));
AllowSetForegroundWindow(process.dwProcessId);
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
(void)app;
}
LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &message)
{
if (!configurationPeer(sender, L"KSwitcher.Config.exe") || message.cbData > ConfigLimit ||
(message.cbData && !message.lpData)) return FALSE;
Bytes reply;
bool ok = false;
try
{
switch (message.dwData)
{
case ConfigSave:
{
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
if (message.cbData < sizeof(DWORD)) throw std::runtime_error("Invalid settings request.");
DWORD length{};
memcpy(&length, message.lpData, sizeof(length));
if (length > message.cbData - sizeof(length)) throw std::runtime_error("Invalid settings snapshot.");
auto begin = static_cast<const unsigned char *>(message.lpData) + sizeof(length);
if (Bytes(begin, begin + length) != encodeSettings(app.settings))
throw std::runtime_error("Settings changed in KSwitcher. Close and reopen the configurator before saving.");
Bytes next(begin + length, static_cast<const unsigned char *>(message.lpData) + message.cbData);
if (next.empty()) throw std::runtime_error("Empty settings request.");
app.applySettings(decodeSettings(next, app.layouts));
reply = encodeSettings(app.settings);
break;
}
case ConfigLayouts:
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
app.reloadLayouts();
[[fallthrough]];
case ConfigRead:
reply = encodeSettings(app.settings);
break;
case ConfigClearHistory:
app.clearHistory();
break;
case ConfigCapture:
{
if (message.cbData != sizeof(HWND)) throw std::runtime_error("Invalid shortcut field.");
HWND field{}; memcpy(&field, message.lpData, sizeof(field));
DWORD fieldPid{}, senderPid{};
GetWindowThreadProcessId(field, &fieldPid);
GetWindowThreadProcessId(sender, &senderPid);
if (field && fieldPid != senderPid) throw std::runtime_error("Invalid shortcut field owner.");
app.setHotkeyCapture(field);
break;
}
case ConfigLogs:
PostMessageW(app.window, WM_HOTKEY, 22, 0);
break;
default: return FALSE;
}
ok = true;
}
catch (const std::exception &error)
{
std::string text = error.what();
reply.assign(text.begin(), text.end());
}
COPYDATASTRUCT response{ConfigReply, (DWORD)reply.size(), reply.data()};
DWORD_PTR received{};
if (!SendMessageTimeoutW(sender, WM_COPYDATA, (WPARAM)app.window, (LPARAM)&response,
SMTO_ABORTIFHUNG, 2000, &received)) return FALSE;
return ok ? 1 : 2;
}
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include "app.h"
namespace ks
{
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
ConfigReply = ConfigRead + 6;
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
bool configurationPeer(HWND, const wchar_t *filename);
void launchConfiguration(App &, bool snippets = false);
LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
}
+2 -1
View File
@@ -59,7 +59,8 @@ struct Settings
L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"}; L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
int maxItems = 500, maxMiB = 200, maxImageMiB = 20; int maxItems = 500, maxMiB = 200, maxImageMiB = 20;
std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C", std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C",
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Ctrl+Shift+V"; translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Ctrl+Shift+V",
screenshot = L"Win+Shift+S";
std::vector<std::wstring> cycle, direct, directIds; std::vector<std::wstring> cycle, direct, directIds;
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"}; std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
std::vector<Snippet> snippets; std::vector<Snippet> snippets;
+3 -1
View File
@@ -31,7 +31,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
HANDLE mutex = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Native.Singleton"); HANDLE mutex = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Native.Singleton");
bool existing = GetLastError() == ERROR_ALREADY_EXISTS; bool existing = GetLastError() == ERROR_ALREADY_EXISTS;
int cmd = ks::ShowHistory; int cmd = ks::ShowHistory;
if (arg == L"--convert") if (arg == L"--capture")
cmd = ks::Screenshot;
else if (arg == L"--convert")
cmd = ks::Toggle; cmd = ks::Toggle;
else if (arg == L"--case") else if (arg == L"--case")
cmd = ks::Case; cmd = ks::Case;
+1534
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include "platform.h"
namespace ks
{
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
enum class LineEnds { End, Start, Both, None };
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse };
struct ScreenshotOffset { double x, y; };
struct ScreenshotMark {
POINT from, to; int number; int radius = 15; int thickness = 3;
int fromNumber = 0, toNumber = 0;
std::vector<POINT> points;
COLORREF color = RGB(225,42,50);
bool freehand = false;
LineStyle style = LineStyle::Solid;
LineEnds ends = LineEnds::End;
ScreenshotShape shape = ScreenshotShape::None;
std::wstring text, fontName=L"Segoe UI";
int fontSize=24, fontWeight=FW_NORMAL;
bool fontItalic=false;
double rotation=0;
std::wstring label;
ScreenshotOffset labelOffset{0,-24}; // Along the line and perpendicular to it, in image pixels.
bool labelRotationManual=false;
bool isText() const { return !text.empty(); }
bool isHighlight() const { return !points.empty() && !freehand; }
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
};
class ScreenshotHistory {
std::deque<std::vector<ScreenshotMark>> past, future;
public:
void remember(const std::vector<ScreenshotMark> &);
bool undo(std::vector<ScreenshotMark> &);
bool redo(std::vector<ScreenshotMark> &);
bool canUndo() const { return !past.empty(); }
bool canRedo() const { return !future.empty(); }
};
void resolveScreenshotLinks(std::vector<ScreenshotMark> &);
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
const std::vector<ScreenshotMark> &, double tolerance);
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label };
POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16);
ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height);
int nextScreenshotNumber(const std::vector<ScreenshotMark> &);
MarkPart hitScreenshotMark(const ScreenshotMark &, POINT, double tolerance);
ScreenshotMark dragScreenshotMark(const ScreenshotMark &, MarkPart, POINT origin, POINT position,
int width, int height, bool snapRotation=false);
POINT constrainScreenshotShape(ScreenshotShape, POINT anchor, POINT position, int width, int height);
RECT screenshotTextBounds(const ScreenshotMark &);
POINT screenshotTextRotationHandle(const ScreenshotMark &, double clearance=24);
ScreenshotMark screenshotArrowLabel(const ScreenshotMark &);
// Render at source resolution; never reads or writes the system clipboard.
Clip screenshotClip(HDC source, int width, int height, const std::vector<ScreenshotMark> &marks);
}
+15 -5
View File
@@ -45,7 +45,7 @@ bool validId(const std::wstring &id)
void validateSettings(const Settings &s) void validateSettings(const Settings &s)
{ {
std::vector<Hotkey> used; std::vector<Hotkey> used;
std::vector<std::wstring> keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard}; 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());
for (auto &text : keys) for (auto &text : keys)
{ {
@@ -102,6 +102,10 @@ void validateSettings(const Settings &s)
} }
} }
Settings loadSettings(const std::filesystem::path &path, const std::vector<Layout> &layouts) Settings loadSettings(const std::filesystem::path &path, const std::vector<Layout> &layouts)
{
return decodeSettings(std::filesystem::exists(path) ? readFile(path, 4 * 1024 * 1024) : Bytes{}, layouts);
}
Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
{ {
Settings s; Settings s;
for (size_t i = 0; i < layouts.size(); ++i) for (size_t i = 0; i < layouts.size(); ++i)
@@ -111,9 +115,9 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
s.direct.push_back(i < 3 ? L"Ctrl+Alt+Shift+" + std::to_wstring(i + 1) : L""); s.direct.push_back(i < 3 ? L"Ctrl+Alt+Shift+" + std::to_wstring(i + 1) : L"");
s.directIds.push_back(layouts[i].id); s.directIds.push_back(layouts[i].id);
} }
if (!std::filesystem::exists(path)) if (bytes.empty())
return s; return s;
auto j = parse(readFile(path, 4 * 1024 * 1024)); auto j = parse(bytes);
auto format = j.value("format", std::string()); auto format = j.value("format", std::string());
if (format != "kswitcher-settings" && format != "typlo-settings") if (format != "kswitcher-settings" && format != "typlo-settings")
throw std::runtime_error("Not a KSwitcher or Typlo settings file"); throw std::runtime_error("Not a KSwitcher or Typlo settings file");
@@ -146,6 +150,7 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
s.translit = str(j, "translitHotkey", s.translit); s.translit = str(j, "translitHotkey", s.translit);
s.undo = str(j, "undoHotkey", s.undo); s.undo = str(j, "undoHotkey", s.undo);
s.clipboard = str(j, "clipboardHotkey", s.clipboard); s.clipboard = str(j, "clipboardHotkey", s.clipboard);
s.screenshot = str(j, "screenshotHotkey", s.screenshot);
if (j.contains("layouts")) if (j.contains("layouts"))
{ {
s.cycle.clear(); s.cycle.clear();
@@ -177,6 +182,10 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
return s; return s;
} }
void saveSettings(const std::filesystem::path &path, const Settings &s) void saveSettings(const std::filesystem::path &path, const Settings &s)
{
atomicWrite(path, encodeSettings(s));
}
Bytes encodeSettings(const Settings &s)
{ {
validateSettings(s); validateSettings(s);
Json j = {{"format", "kswitcher-settings"}, Json j = {{"format", "kswitcher-settings"},
@@ -195,7 +204,8 @@ void saveSettings(const std::filesystem::path &path, const Settings &s)
{"caseHotkey", utf8(s.casing)}, {"caseHotkey", utf8(s.casing)},
{"translitHotkey", utf8(s.translit)}, {"translitHotkey", utf8(s.translit)},
{"undoHotkey", utf8(s.undo)}, {"undoHotkey", utf8(s.undo)},
{"clipboardHotkey", utf8(s.clipboard)}}; {"clipboardHotkey", utf8(s.clipboard)},
{"screenshotHotkey", utf8(s.screenshot)}};
j["layouts"] = Json::array(); j["layouts"] = Json::array();
for (auto &id : s.cycle) for (auto &id : s.cycle)
j["layouts"].push_back(utf8(id)); j["layouts"].push_back(utf8(id));
@@ -221,7 +231,7 @@ void saveSettings(const std::filesystem::path &path, const Settings &s)
j["excludedApps"] = Json::array(); j["excludedApps"] = Json::array();
for (auto &name : s.excluded) for (auto &name : s.excluded)
j["excludedApps"].push_back(utf8(name)); j["excludedApps"].push_back(utf8(name));
atomicWrite(path, jsonBytes(j)); return jsonBytes(j);
} }
Bytes encodeClip(const Clip &clip) Bytes encodeClip(const Clip &clip)
{ {
+2
View File
@@ -4,6 +4,8 @@
namespace ks namespace ks
{ {
Settings loadSettings(const std::filesystem::path &, const std::vector<Layout> &); Settings loadSettings(const std::filesystem::path &, const std::vector<Layout> &);
Settings decodeSettings(const Bytes &, const std::vector<Layout> &);
Bytes encodeSettings(const Settings &);
void saveSettings(const std::filesystem::path &, const Settings &); void saveSettings(const std::filesystem::path &, const Settings &);
void validateSettings(const Settings &); void validateSettings(const Settings &);
struct Entry struct Entry
+48 -28
View File
@@ -1,5 +1,6 @@
#include "../resources/resource.h" #include "../resources/resource.h"
#include "app.h" #include "app.h"
#include "configuration.h"
#include "ocr.h" #include "ocr.h"
#include "richtext.h" #include "richtext.h"
#include "ui_geometry.h" #include "ui_geometry.h"
@@ -185,13 +186,13 @@ LRESULT CALLBACK hotkeyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
auto *app = (App *)data; auto *app = (App *)data;
if (msg == WM_SETFOCUS) if (msg == WM_SETFOCUS)
{ {
app->hotkeyCapture = w; app->setHotkeyCapture(w);
SendMessageW(w, EM_SETSEL, 0, -1); SendMessageW(w, EM_SETSEL, 0, -1);
} }
if (msg == WM_KILLFOCUS || msg == WM_NCDESTROY) if (msg == WM_KILLFOCUS || msg == WM_NCDESTROY)
{ {
if (app->hotkeyCapture == w) if (app->hotkeyCapture == w)
app->hotkeyCapture = nullptr; app->setHotkeyCapture(nullptr);
} }
if (msg == CaptureHotkeyMessage) if (msg == CaptureHotkeyMessage)
{ {
@@ -218,13 +219,13 @@ LRESULT CALLBACK hotkeyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
return 0; return 0;
return DefSubclassProc(w, msg, wp, lp); return DefSubclassProc(w, msg, wp, lp);
} }
void hotkeyEdit(App &app, HWND w, int id, int x, int y, int width) [[maybe_unused]] void hotkeyEdit(App &app, HWND w, int id, int x, int y, int width)
{ {
HWND field = edit(w, id, x, y, width, 25, ES_AUTOHSCROLL | ES_READONLY); HWND field = edit(w, id, x, y, width, 25, ES_AUTOHSCROLL | ES_READONLY);
SendMessageW(field, EM_SETCUEBANNER, FALSE, (LPARAM)L"Click, then press a shortcut"); SendMessageW(field, EM_SETCUEBANNER, FALSE, (LPARAM)L"Click, then press a shortcut");
SetWindowSubclass(field, hotkeyProc, 1, (DWORD_PTR)&app); SetWindowSubclass(field, hotkeyProc, 1, (DWORD_PTR)&app);
} }
void checkbox(HWND w, const wchar_t *text, int id, int x, int y, int width = 300) [[maybe_unused]] void checkbox(HWND w, const wchar_t *text, int id, int x, int y, int width = 300)
{ {
control(w, L"BUTTON", text, WS_TABSTOP | BS_AUTOCHECKBOX, id, x, y, width, 24); control(w, L"BUTTON", text, WS_TABSTOP | BS_AUTOCHECKBOX, id, x, y, width, 24);
} }
@@ -241,11 +242,11 @@ void value(HWND w, int id, const std::wstring &text)
{ {
SetDlgItemTextW(w, id, text.c_str()); SetDlgItemTextW(w, id, text.c_str());
} }
bool checked(HWND w, int id) [[maybe_unused]] bool checked(HWND w, int id)
{ {
return IsDlgButtonChecked(w, id) == BST_CHECKED; return IsDlgButtonChecked(w, id) == BST_CHECKED;
} }
void check(HWND w, int id, bool yes) [[maybe_unused]] void check(HWND w, int id, bool yes)
{ {
CheckDlgButton(w, id, yes ? BST_CHECKED : BST_UNCHECKED); CheckDlgButton(w, id, yes ? BST_CHECKED : BST_UNCHECKED);
} }
@@ -254,7 +255,7 @@ void error(HWND w, const std::exception &e)
MessageBoxW(w, wide(e.what()).c_str(), L"KSwitcher", MB_OK | MB_ICONWARNING); MessageBoxW(w, wide(e.what()).c_str(), L"KSwitcher", MB_OK | MB_ICONWARNING);
} }
HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc, int width, int height, HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc, int width, int height,
DWORD extra = 0) DWORD extra = 0, bool resizable = true)
{ {
WNDCLASSW wc{}; WNDCLASSW wc{};
wc.hInstance = app.instance; wc.hInstance = app.instance;
@@ -266,7 +267,8 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
RegisterClassW(&wc); RegisterClassW(&wc);
UINT dpi = GetDpiForSystem(); UINT dpi = GetDpiForSystem();
RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)}; RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_THICKFRAME | WS_MAXIMIZEBOX | extra; DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | extra;
if (resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX;
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi); AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
POINT cursor{}; POINT cursor{};
GetCursorPos(&cursor); GetCursorPos(&cursor);
@@ -352,7 +354,7 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
fitToMonitor(w, *(RECT *)lp); fitToMonitor(w, *(RECT *)lp);
arrangeWindow(w); arrangeWindow(w);
} }
std::filesystem::path pickFile(HWND owner, bool save) [[maybe_unused]] std::filesystem::path pickFile(HWND owner, bool save)
{ {
wchar_t path[32768]{}; wchar_t path[32768]{};
OPENFILENAMEW ofn{sizeof(ofn)}; OPENFILENAMEW ofn{sizeof(ofn)};
@@ -372,6 +374,7 @@ void disposeFont(HWND w)
DeleteObject(h); DeleteObject(h);
} }
#ifndef KS_CONFIG_APP
struct SearchResult struct SearchResult
{ {
uint64_t generation; uint64_t generation;
@@ -755,6 +758,8 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
return DefWindowProcW(w, msg, wp, lp); return DefWindowProcW(w, msg, wp, lp);
} }
#endif
#ifndef KS_RESIDENT_APP
struct SettingsView struct SettingsView
{ {
App *app{}; App *app{};
@@ -818,6 +823,7 @@ void fillSettings(HWND w, SettingsView *v)
value(w, 212, s.translit); value(w, 212, s.translit);
value(w, 213, s.undo); value(w, 213, s.undo);
value(w, 214, s.clipboard); value(w, 214, s.clipboard);
value(w, 215, s.screenshot);
value(w, 240, std::to_wstring(s.maxItems)); value(w, 240, std::to_wstring(s.maxItems));
value(w, 241, std::to_wstring(s.maxMiB)); value(w, 241, std::to_wstring(s.maxMiB));
value(w, 242, std::to_wstring(s.maxImageMiB)); value(w, 242, std::to_wstring(s.maxImageMiB));
@@ -867,6 +873,7 @@ Settings collectSettings(HWND w, SettingsView *v)
s.translit = value(w, 212); s.translit = value(w, 212);
s.undo = value(w, 213); s.undo = value(w, 213);
s.clipboard = value(w, 214); s.clipboard = value(w, 214);
s.screenshot = value(w, 215);
s.maxItems = integer(w, 240); s.maxItems = integer(w, 240);
s.maxMiB = integer(w, 241); s.maxMiB = integer(w, 241);
s.maxImageMiB = integer(w, 242); s.maxImageMiB = integer(w, 242);
@@ -1146,11 +1153,11 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
fillSettings(w, v); fillSettings(w, v);
return 0; return 0;
} }
if (id == 254 && v->app->history) if (id == 254)
{ {
if (MessageBoxW(w, L"Delete all clipboard history, including pinned items?", L"Clear history", if (MessageBoxW(w, L"Delete all clipboard history, including pinned items?", L"Clear history",
MB_YESNO | MB_ICONWARNING) == IDYES) MB_YESNO | MB_ICONWARNING) == IDYES)
v->app->history->clear(); v->app->clearHistory();
return 0; return 0;
} }
break; break;
@@ -1294,6 +1301,8 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
} }
return DefWindowProcW(w, msg, wp, lp); return DefWindowProcW(w, msg, wp, lp);
} }
#endif
#ifndef KS_CONFIG_APP
LRESULT CALLBACK entryProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) LRESULT CALLBACK entryProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{ {
if (msg == WM_SIZE) if (msg == WM_SIZE)
@@ -1305,7 +1314,9 @@ LRESULT CALLBACK entryProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
disposeFont(w); disposeFont(w);
return DefWindowProcW(w, msg, wp, lp); return DefWindowProcW(w, msg, wp, lp);
} }
#endif
} // namespace } // namespace
#ifndef KS_CONFIG_APP
void refreshHistory(App &app) void refreshHistory(App &app)
{ {
if (app.historyWindow) if (app.historyWindow)
@@ -1498,7 +1509,7 @@ void refreshLogView(HWND w, App &app)
InvalidateRect(list, nullptr, TRUE); InvalidateRect(list, nullptr, TRUE);
if (row) if (row)
ListView_EnsureVisible(list, row - 1, FALSE); ListView_EnsureVisible(list, row - 1, FALSE);
value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: problems and suspicious repeats only." value(w, LogStatus, app.settings.writeLog ? L"Logging enabled: failed operations only."
: L"Logging disabled. Showing previously saved entries."); : L"Logging disabled. Showing previously saved entries.");
} }
LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
@@ -1609,6 +1620,8 @@ void showLogs(App &app)
showRequestedWindow(w); showRequestedWindow(w);
activateWindow(w); activateWindow(w);
} }
#endif
#ifndef KS_RESIDENT_APP
void showSettings(App &app, bool visible) void showSettings(App &app, bool visible)
{ {
if (app.settingsWindow) if (app.settingsWindow)
@@ -1620,11 +1633,11 @@ void showSettings(App &app, bool visible)
activateWindow(target); activateWindow(target);
return; return;
} }
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 780, 660); HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 780, 660, 0, false);
app.settingsWindow = w; app.settingsWindow = w;
label(w, L"Text conversion", 18, 16); label(w, L"Text conversion", 18, 16);
checkbox(w, L"Convert recently typed text when nothing is selected", 201, 18, 43, 440); checkbox(w, L"Convert recently typed text when nothing is selected", 201, 18, 43, 390);
checkbox(w, L"Switch Windows input layout after conversion", 202, 18, 70, 440); checkbox(w, L"Switch Windows input layout after conversion", 202, 18, 70, 390);
checkbox(w, L"Record clipboard history", 203, 18, 106); checkbox(w, L"Record clipboard history", 203, 18, 106);
checkbox(w, L"Start with Windows", 204, 18, 133); checkbox(w, L"Start with Windows", 204, 18, 133);
checkbox(w, L"Start with Windows as administrator", 206, 18, 160, 395); checkbox(w, L"Start with Windows as administrator", 206, 18, 160, 395);
@@ -1632,23 +1645,23 @@ void showSettings(App &app, bool visible)
checkbox(w, L"Write log", 208, 240, 184, 160); checkbox(w, L"Write log", 208, 240, 184, 160);
label(w, L"Click a shortcut and press the desired keys", 420, 16, 350); label(w, L"Click a shortcut and press the desired keys", 420, 16, 350);
const wchar_t *names[] = {L"Next layout", L"Change case", L"Transliterate", L"Undo", const wchar_t *names[] = {L"Next layout", L"Change case", L"Transliterate", L"Undo",
L"Clipboard history"}; L"Clipboard history", L"Screen capture"};
for (int i = 0; i < 5; ++i) for (int i = 0; i < 6; ++i)
{ {
label(w, names[i], 420, 48 + i * 30, 115); label(w, names[i], 420, 48 + i * 30, 115);
hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220); hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220);
} }
checkbox(w, L"Automatically correct layout (experimental)", 207, 420, 195, 350); checkbox(w, L"Automatically correct layout (experimental)", 207, 18, 210, 395);
label(w, L"Layout cycle — top to bottom", 18, 210, 400); label(w, L"Layout cycle — top to bottom", 18, 240, 400);
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 220, control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 220,
18, 232, 470, 190); 18, 262, 470, 172);
button(w, L"Move up", 232, 504, 232, 120); button(w, L"Move up", 232, 504, 262, 120);
button(w, L"Move down", 233, 632, 232, 128); button(w, L"Move down", 233, 632, 262, 128);
checkbox(w, L"Include in cycle", 230, 504, 275, 240); checkbox(w, L"Include in cycle", 230, 504, 300, 240);
label(w, L"Direct layout shortcut", 504, 313, 240); label(w, L"Direct layout shortcut", 504, 332, 240);
hotkeyEdit(app, w, 231, 504, 340, 256); hotkeyEdit(app, w, 231, 504, 356, 256);
label(w, L"Backspace / Delete clears a shortcut", 504, 365, 256); label(w, L"Backspace / Delete clears a shortcut", 504, 382, 256);
button(w, L"Refresh layouts", 253, 504, 384, 150); button(w, L"Refresh layouts", 253, 504, 408, 150);
label(w, L"History limits", 18, 444); label(w, L"History limits", 18, 444);
label(w, L"Items", 18, 478, 50); label(w, L"Items", 18, 478, 50);
edit(w, 240, 70, 474, 90); edit(w, 240, 70, 474, 90);
@@ -1699,11 +1712,17 @@ void showSnippets(App &app)
showRequestedWindow(w); showRequestedWindow(w);
SetForegroundWindow(w); SetForegroundWindow(w);
} }
#else
void showSettings(App &app, bool) { launchConfiguration(app); }
void showSnippets(App &app) { launchConfiguration(app, true); }
#endif
#ifndef KS_CONFIG_APP
void showShortcuts(App &app) void showShortcuts(App &app)
{ {
auto &s = app.settings; auto &s = app.settings;
std::wstring text = L"Next layout: " + s.toggle + L"\nChange case: " + s.casing + L"\nTransliterate: " + std::wstring text = L"Next layout: " + s.toggle + L"\nChange case: " + s.casing + L"\nTransliterate: " +
s.translit + L"\nUndo: " + s.undo + L"\nClipboard history: " + s.clipboard + L"\n"; s.translit + L"\nUndo: " + s.undo + L"\nClipboard history: " + s.clipboard +
L"\nScreen capture: " + s.screenshot + L"\n";
for (size_t i = 0; i < std::min(app.layouts.size(), s.direct.size()); ++i) for (size_t i = 0; i < std::min(app.layouts.size(), s.direct.size()); ++i)
if (!s.direct[i].empty()) if (!s.direct[i].empty())
text += L"\n" + app.layouts[i].name + L": " + s.direct[i]; text += L"\n" + app.layouts[i].name + L": " + s.direct[i];
@@ -1736,4 +1755,5 @@ void showEntry(App &app, const std::wstring &id)
error(app.historyWindow, e); error(app.historyWindow, e);
} }
} }
#endif
} // namespace ks } // namespace ks
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h" #include "platform.h"
namespace ks namespace ks
{ {
inline constexpr wchar_t AppVersion[] = L"0.2.2"; inline constexpr wchar_t AppVersion[] = L"0.4.2";
inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateMessage = WM_APP + 7;
struct UpdateResult struct UpdateResult
{ {
+143
View File
@@ -0,0 +1,143 @@
param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged'))
$ErrorActionPreference = 'Stop'
if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) {
throw 'Close KSwitcher and its configurator before this isolated process test.'
}
Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class ConfigurationTestUi {
[StructLayout(LayoutKind.Explicit, Size=40)] public struct Input {
[FieldOffset(0)] public uint type;
[FieldOffset(8)] public ushort key;
[FieldOffset(12)] public uint flags;
}
[DllImport("user32.dll")] public static extern uint SendInput(uint count,Input[] input,int size);
public static uint PressF24() { return SendInput(2,new Input[]{new Input{type=1,key=0x87},new Input{type=1,key=0x87,flags=2}},40); }
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr GetText(IntPtr w,uint msg,IntPtr count,System.Text.StringBuilder text);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string cls,string title);
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr w,int id);
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp);
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr SetText(IntPtr w,uint msg,IntPtr wp,string text);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr w,out uint pid);
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a,uint b,bool attach);
[DllImport("user32.dll")] public static extern IntPtr SetFocus(IntPtr w);
public static bool FocusControl(IntPtr w) {
uint pid; uint target=GetWindowThreadProcessId(w,out pid), current=GetCurrentThreadId();
if(!AttachThreadInput(current,target,true)) return false;
try { SetFocus(w); return true; } finally { AttachThreadInput(current,target,false); }
}
}
'@
$root=Join-Path ([IO.Path]::GetTempPath()) ('KSwitcher-config-tests-'+[guid]::NewGuid().ToString('N'))
$processes=[Collections.Generic.List[Diagnostics.Process]]::new()
$checks=0
function Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ }
function Window($class) {
for($i=0;$i -lt 100;$i++) {
$title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'}}
$window=[ConfigurationTestUi]::FindWindow($class,$title)
if($window -ne [IntPtr]::Zero){return $window}
Start-Sleep -Milliseconds 50
}
foreach($process in $processes){$process.Refresh(); Write-Host ("Diagnostic process {0}: exited={1} title={2}" -f $process.Id,$process.HasExited,$process.MainWindowTitle)}
throw "Window did not appear: $class"
}
function Launch($file,$argument) {
$options=@{FilePath=(Join-Path $root $file);PassThru=$true;WindowStyle='Hidden'}
if($argument){$options.ArgumentList=$argument}
$process=Start-Process @options
$processes.Add($process)
return $process
}
function Click($window,$id) {
$null=[ConfigurationTestUi]::PostMessage($window,0x111,[IntPtr]$id,[IntPtr]::Zero)
}
try {
New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null
foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) {
Copy-Item -LiteralPath (Join-Path $BuildDirectory $file) -Destination $root
}
# No clipboard history capture, auto-correction, autostart or user data in the fixture.
$settingsPath=Join-Path $root 'data/settings.json'
@{format='kswitcher-settings';schemaVersion=1;historyEnabled=$false;autoCorrectLayout=$false;writeLog=$false} |
ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8
$resident=Launch 'KSwitcher.exe' ''
$main=Window 'KSwitcher.Main'
$config=Launch 'KSwitcher.Config.exe' ''
$settings=Window 'KSwitcher.Settings'
[uint32]$ownerId=0
$null=[ConfigurationTestUi]::GetWindowThreadProcessId($settings,[ref]$ownerId)
Assert ($ownerId -eq $config.Id -and $ownerId -ne $resident.Id) 'settings live in the separate process'
$second=Launch 'KSwitcher.Config.exe' ''
Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'second launch forwards to existing instance'
Assert ((Window 'KSwitcher.Settings') -eq $settings) 'existing settings window reused'
# Only inject into the isolated fixture window, never another foreground app.
Assert ([ConfigurationTestUi]::GetForegroundWindow() -eq $settings) 'fixture owns foreground before shortcut capture test'
$field=[ConfigurationTestUi]::GetDlgItem($settings,210)
Assert ([ConfigurationTestUi]::FocusControl($field)) 'shortcut field receives actual focus'
Assert ([ConfigurationTestUi]::PressF24() -eq 2) 'test F24 events accepted'
$text=[Text.StringBuilder]::new(128)
for($i=0;$i -lt 100;$i++) {
$null=$text.Clear()
$null=[ConfigurationTestUi]::GetText($field,0x0d,[IntPtr]128,$text)
if($text.ToString() -eq 'F24'){break}
Start-Sleep -Milliseconds 20
}
Assert ($text.ToString() -eq 'F24') 'resident hook forwards shortcut capture to configurator'
$null=[ConfigurationTestUi]::SetText([ConfigurationTestUi]::GetDlgItem($settings,240),0x0c,[IntPtr]::Zero,'321')
Click $settings 1
Assert ($config.WaitForExit(6000) -and $config.ExitCode -eq 0) 'save exits configurator'
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'save committed through resident process'
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).toggleHotkey -eq 'F24') 'captured shortcut persisted by resident'
$resident.Refresh(); Assert (!$resident.HasExited) 'resident survives settings save'
$config=Launch 'KSwitcher.Config.exe' ''
$settings=Window 'KSwitcher.Settings'
$null=[ConfigurationTestUi]::SetText([ConfigurationTestUi]::GetDlgItem($settings,240),0x0c,[IntPtr]::Zero,'456')
Click $settings 2
Assert ($config.WaitForExit(6000)) 'cancel exits configurator'
Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'cancel preserves saved settings'
$config=Launch 'KSwitcher.Config.exe' '--snippets'
$snippets=Window 'KSwitcher.Snippets'
$second=Launch 'KSwitcher.Config.exe' ''
Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'settings and snippets share a single process'
$settings=Window 'KSwitcher.Settings'
Click $settings 2
Start-Sleep -Milliseconds 100
$config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive'
Click $snippets 2
Assert ($config.WaitForExit(6000)) 'closing last window exits configurator'
$config=Launch 'KSwitcher.Config.exe' ''
$null=Window 'KSwitcher.Settings'
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
Assert ($resident.WaitForExit(10000)) 'resident shuts down normally'
Assert ($config.WaitForExit(6000)) 'resident exit closes configurator'
$config=Launch 'KSwitcher.Config.exe' ''
$main=Window 'KSwitcher.Main'
[uint32]$bootstrappedId=0
$null=[ConfigurationTestUi]::GetWindowThreadProcessId($main,[ref]$bootstrappedId)
$bootstrapped=Get-Process -Id $bootstrappedId
$processes.Add($bootstrapped)
$settings=Window 'KSwitcher.Settings'
Assert ($bootstrappedId -ne $resident.Id) 'standalone configurator starts its resident sibling'
Click $settings 2
Assert ($config.WaitForExit(6000)) 'standalone configurator exits after cancel'
$null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero)
Assert ($bootstrapped.WaitForExit(10000)) 'bootstrapped resident shuts down normally'
"Passed $checks configurator process checks"
} finally {
foreach($process in $processes) {
$process.Refresh()
if(!$process.HasExited) { Stop-Process -Id $process.Id -Force }
$process.Dispose()
}
# The generated fixture is the only recursive deletion target.
$resolved=[IO.Path]::GetFullPath($root)
$temp=[IO.Path]::GetFullPath([IO.Path]::GetTempPath())
if(!$resolved.StartsWith($temp,[StringComparison]::OrdinalIgnoreCase) -or
[IO.Path]::GetFileName($resolved) -notlike 'KSwitcher-config-tests-*') { throw 'Unexpected fixture path' }
Remove-Item -LiteralPath $resolved -Recurse -Force
}
+411
View File
@@ -1,7 +1,9 @@
#include "app.h" #include "app.h"
#include "ui_geometry.h" #include "ui_geometry.h"
#include "screenshot.h"
#include <commctrl.h> #include <commctrl.h>
#include <iostream> #include <iostream>
#include <cmath>
namespace ks { namespace ks {
struct InputRegressionAccess { struct InputRegressionAccess {
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'}}}; }
@@ -29,6 +31,410 @@ int main(int argc, char **argv) {
// 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");
for(int variant=0;variant<3;++variant) for(int icon=0;icon<15;++icon)
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);
require(handle!=nullptr,"Lucide toolbar icon loads at standard and high DPI");
DestroyIcon(handle);
}
{
ks::ScreenshotHistory history;
std::vector<ks::ScreenshotMark> marks;
require(!history.undo(marks) && !history.redo(marks),"empty annotation history is safe");
history.remember(marks);
marks.push_back({{20,20},{20,20},1,24});
history.remember(marks);
marks.push_back({{44,20},{100,20},0,15,8,1,0});
require(history.undo(marks) && marks.size()==1 && history.canRedo(),"undo preserves state for redo");
require(history.undo(marks) && marks.empty(),"multiple undo steps restore empty canvas");
require(history.redo(marks) && marks[0].radius==24,"redo restores marker size");
require(history.redo(marks) && marks.size()==2 && marks[1].thickness==8 && marks[1].fromNumber==1,
"redo restores arrow style and endpoint links");
require(!history.canRedo(),"redo becomes unavailable after last replay");
history.undo(marks);
history.remember(marks); marks[0].radius=40;
require(!history.canRedo() && !history.redo(marks),"new edit discards redo branch");
require(history.undo(marks) && marks[0].radius==24,"new branch remains undoable");
ks::ScreenshotHistory bounded;
for(int i=0;i<110;++i) { bounded.remember(marks); marks[0].radius=i; }
int count=0; while(bounded.undo(marks)) ++count;
require(count==100,"annotation undo history stays bounded");
count=0; while(bounded.redo(marks)) ++count;
require(count==100 && marks[0].radius==109,"bounded history can replay all retained edits");
}
{
HDC dc=CreateCompatibleDC(nullptr);
BITMAPINFO info{};
info.bmiHeader={sizeof(BITMAPINFOHEADER),160,-100,1,32,BI_RGB};
void *pixels{};
auto bitmap=CreateDIBSection(dc,&info,DIB_RGB_COLORS,&pixels,nullptr,0);
auto old=SelectObject(dc,bitmap);
PatBlt(dc,0,0,160,100,WHITENESS);
auto plain=ks::screenshotClip(dc,160,100,{});
auto marked=ks::screenshotClip(dc,160,100,{{{30,30},{30,30},1},{{60,70},{130,70},0}});
auto &bytes=marked.formats[0].data;
BITMAPINFOHEADER header{}; memcpy(&header,bytes.data(),sizeof(header));
require(header.biWidth==160&&header.biHeight==-100,"capture preserves source resolution and orientation");
require(bytes.size()==sizeof(header)+160*100*4,"capture exports complete 32-bit DIB");
auto pixel=[&](int x,int y) { auto offset=sizeof(header)+(y*160+x)*4; return RGB(bytes[offset+2],bytes[offset+1],bytes[offset]); };
require(pixel(30,20)==RGB(225,42,50),"number badge renders in exported image");
require(pixel(90,70)==RGB(225,42,50),"arrow shaft renders in exported image");
require(pixel(5,5)==RGB(255,255,255),"unmarked pixels preserved");
ks::ScreenshotMark blueArrow{{20,50},{140,50},0}; blueArrow.color=RGB(0,80,220);
ks::ScreenshotMark greenBadge{{30,30},{30,30},1}; greenBadge.color=RGB(0,200,80);
auto colored=ks::screenshotClip(dc,160,100,{blueArrow,greenBadge});
auto colorAt=[&](int x,int y) {
auto &b=colored.formats[0].data; auto at=sizeof(BITMAPINFOHEADER)+(y*160+x)*4;
return RGB(b[at+2],b[at+1],b[at]);
};
require(colorAt(70,50)==blueArrow.color && colorAt(132,50)==blueArrow.color,
"arrow shaft and head use object color");
require(colorAt(30,20)==greenBadge.color,"badge fill uses object color");
ks::ScreenshotHistory recolors;
std::vector<ks::ScreenshotMark> coloredMarks{blueArrow,greenBadge};
recolors.remember(coloredMarks); coloredMarks[0].color=RGB(200,0,100);
recolors.remember(coloredMarks); coloredMarks[1].color=RGB(255,255,0);
require(recolors.undo(coloredMarks) && coloredMarks[1].color==greenBadge.color,"undo restores badge color");
require(recolors.undo(coloredMarks) && coloredMarks[0].color==blueArrow.color,"undo restores arrow color");
require(recolors.redo(coloredMarks) && coloredMarks[0].color==RGB(200,0,100),"redo restores arrow recoloring");
require(ks::cloneScreenshotMark(greenBadge,{greenBadge},160,100).color==greenBadge.color,
"cloned badge retains customized color");
ks::ScreenshotMark thickArrow{{20,50},{140,50},0,15,12};
auto grip=ks::screenshotThicknessHandle(thickArrow);
require(grip.x==80 && grip.y==72,"thickness grip sits beside arrow midpoint");
auto thicker=ks::dragScreenshotMark(thickArrow,ks::MarkPart::Thickness,grip,{grip.x,grip.y+4},160,100);
require(thicker.thickness==20 && thicker.from.x==20 && thicker.to.x==140,
"dragging thickness grip changes width without changing endpoints");
require(ks::dragScreenshotMark(thickArrow,ks::MarkPart::Thickness,grip,{grip.x+20,grip.y},160,100).thickness==12,
"movement along arrow does not change thickness");
require(ks::dragScreenshotMark(thickArrow,ks::MarkPart::Thickness,grip,{grip.x,0},160,100).thickness==1,
"thickness grip respects minimum width");
auto cloned=ks::cloneScreenshotMark(thickArrow,{},160,100);
require(cloned.thickness==12 && cloned.from.x!=thickArrow.from.x && cloned.to.x-cloned.from.x==120,
"cloned arrow is offset and preserves dimensions and style");
auto thick=ks::screenshotClip(dc,160,100,{thickArrow});
auto shaftEdge=sizeof(BITMAPINFOHEADER)+(54*160+40)*4;
require(thick.formats[0].data[shaftEdge+2]==225 && thick.formats[0].data[shaftEdge+1]==42,
"export renders edited arrow thickness");
thickArrow.thickness=1;
auto thin=ks::screenshotClip(dc,160,100,{thickArrow});
require(thin.formats[0].data[shaftEdge+1]==255,"thin arrow leaves surrounding pixels untouched");
thickArrow.thickness=12;
require(ks::hitScreenshotMark(thickArrow,{60,55},1)==ks::MarkPart::Body,
"thick arrow can be selected along its visible edge");
require(ks::dragScreenshotMark(thickArrow,ks::MarkPart::End,{140,50},{145,70},160,100).thickness==12,
"editing arrow geometry preserves thickness");
ks::ScreenshotMark badge{{60,50},{60,50},2};
auto clonedBadge=ks::cloneScreenshotMark(badge,{badge,{{90,20},{90,20},5}},160,100);
require(clonedBadge.number==1 && clonedBadge.radius==15 && clonedBadge.from.x==76,
"cloned badge fills first numbering gap and keeps diameter");
require(ks::nextScreenshotNumber({})==1,"empty capture starts numbering at one");
std::vector<ks::ScreenshotMark> numbered{
{{10,10},{10,10},4},{{20,10},{20,10},1},{{30,10},{30,10},3},{{0,0},{100,0},0}};
require(ks::nextScreenshotNumber(numbered)==2,"new badge fills interior gap regardless of stacking order");
numbered.push_back({{40,10},{40,10},2});
require(ks::nextScreenshotNumber(numbered)==5,"full sequence advances past existing numbers");
numbered.erase(numbered.begin()+1);
require(ks::nextScreenshotNumber(numbered)==1,"deleted first number is reused");
require(numbered[0].number==4 && numbered[1].number==3,"number allocation never renumbers existing objects");
std::vector<ks::ScreenshotMark> linked{
{{30,50},{30,50},1}, {{130,50},{130,50},2}, {{30,50},{130,50},0}};
ks::linkScreenshotEndpoint(linked[2],true,{30,50},linked,6);
ks::linkScreenshotEndpoint(linked[2],false,{130,50},linked,6);
ks::resolveScreenshotLinks(linked);
require(linked[2].fromNumber==1 && linked[2].toNumber==2 && linked[2].from.x==45 && linked[2].to.x==115,
"arrow endpoints attach to badge rims");
auto clonedLinked=ks::cloneScreenshotMark(linked[2],linked,160,100);
require(clonedLinked.fromNumber==0 && clonedLinked.toNumber==0,"cloned arrow does not retain original links");
auto widthLinked=ks::dragScreenshotMark(linked[2],ks::MarkPart::Thickness,{80,70},{80,74},160,100);
require(widthLinked.fromNumber==1 && widthLinked.toNumber==2,"thickness handle preserves endpoint links");
linked[0].from.y=20; linked[0].to.y=20;
linked[1].radius=25;
ks::resolveScreenshotLinks(linked);
require(linked[2].from.y>20 && linked[2].from.y<50 && linked[2].to.x<110,
"linked arrows follow badge movement and resize");
auto detached=ks::dragScreenshotMark(linked[2],ks::MarkPart::Start,linked[2].from,{10,90},160,100);
require(detached.fromNumber==0 && detached.toNumber==2,"endpoint drag detaches only that endpoint");
ks::linkScreenshotEndpoint(detached,true,{10,90},linked,6);
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);
require(translated.fromNumber==0 && translated.toNumber==0,"moving whole arrow detaches both endpoints");
auto lastPoint=linked[2].from;
linked.erase(linked.begin());
ks::resolveScreenshotLinks(linked);
require(linked[1].fromNumber==0 && linked[1].toNumber==2 && linked[1].from.x==lastPoint.x,
"deleting badge detaches without moving orphaned endpoint");
linked.push_back({{10,10},{10,10},1});
ks::resolveScreenshotLinks(linked);
require(linked[1].fromNumber==0,"reused number cannot reconnect a deleted link");
require(ks::hitScreenshotMark(badge,{60,50},20)==ks::MarkPart::Body,"badge center moves even with large hit tolerance");
require(ks::hitScreenshotMark(badge,{75,50},6)==ks::MarkPart::Radius,"badge rim exposes resize handle");
auto enlarged=ks::dragScreenshotMark(badge,ks::MarkPart::Radius,{75,50},{95,50},160,100);
require(enlarged.radius==35 && enlarged.number==2,"badge resizes without changing its number");
auto large=ks::screenshotClip(dc,160,100,{enlarged});
const auto &largeBytes=large.formats[0].data;
auto red=sizeof(BITMAPINFOHEADER)+(50*160+90)*4;
require(largeBytes[red+2]==225 && largeBytes[red+1]==42,"export uses edited badge diameter");
require(ks::dragScreenshotMark(badge,ks::MarkPart::Radius,{75,50},{60,50},160,100).radius==8,
"badge diameter has a usable minimum");
ks::ScreenshotMark arrow{{20,20},{100,60},0};
require(ks::hitScreenshotMark(arrow,{60,40},5)==ks::MarkPart::Body,"arrow shaft selects whole arrow");
require(ks::hitScreenshotMark(arrow,{100,60},5)==ks::MarkPart::End,"arrow tip selects endpoint");
require(ks::hitScreenshotMark(arrow,{60,80},5)==ks::MarkPart::None,"empty canvas misses arrow");
auto moved=ks::dragScreenshotMark(arrow,ks::MarkPart::Body,{60,40},{150,90},160,100);
require(moved.to.x==159 && moved.to.y==99 && moved.to.x-moved.from.x==80 && moved.to.y-moved.from.y==40,
"moving arrow preserves shape at image boundary");
auto stretched=ks::dragScreenshotMark(arrow,ks::MarkPart::End,{100,60},{140,80},160,100);
require(stretched.from.x==20 && stretched.from.y==20 && stretched.to.x==140 && stretched.to.y==80,
"endpoint drag leaves opposite endpoint fixed");
require(ks::screenshotClip(dc,160,100,{}).formats[0].data==plain.formats[0].data,
"annotations do not modify original capture");
ks::ScreenshotMark highlight{{20,50},{120,50},0,15,24};
highlight.points={{20,50},{120,50}}; highlight.color=RGB(255,255,0);
SetPixel(dc,60,50,RGB(0,0,0));
auto yellow=ks::screenshotClip(dc,160,100,{highlight});
auto readPixel=[](const ks::Clip &clip,int x,int y) {
auto &b=clip.formats[0].data; auto offset=sizeof(BITMAPINFOHEADER)+(y*160+x)*4;
return RGB(b[offset+2],b[offset+1],b[offset]);
};
require(readPixel(yellow,40,50)==RGB(255,255,153),"highlight is translucent in exported image");
require(readPixel(yellow,60,50)==RGB(0,0,0),"highlight keeps black text legible");
require(readPixel(yellow,40,10)==RGB(255,255,255),"highlight leaves pixels outside brush unchanged");
highlight.points.push_back({20,50});
require(readPixel(ks::screenshotClip(dc,160,100,{highlight}),40,50)==RGB(255,255,153),
"overlapping a stroke with itself does not accumulate opacity");
ks::ScreenshotHistory colorHistory;
std::vector<ks::ScreenshotMark> strokes{highlight};
colorHistory.remember(strokes); strokes[0].color=RGB(0,255,255);
require(readPixel(ks::screenshotClip(dc,160,100,strokes),40,50)==RGB(153,255,255),
"changing highlight color rerenders original stroke");
require(colorHistory.undo(strokes) && strokes[0].color==RGB(255,255,0),"undo restores highlight color");
require(colorHistory.redo(strokes) && strokes[0].color==RGB(0,255,255),"redo restores highlight recolor");
require(ks::hitScreenshotMark(highlight,{80,55},1)==ks::MarkPart::Body,"highlight stroke is selectable");
require(ks::hitScreenshotMark(highlight,{80,10},1)==ks::MarkPart::None,"highlight hit test excludes empty area");
auto movedHighlight=ks::dragScreenshotMark(highlight,ks::MarkPart::Body,{20,50},{30,60},160,100);
require(movedHighlight.points[1].x==130 && movedHighlight.points[1].y==60,"moving highlight translates entire stroke");
auto cloneHighlight=ks::cloneScreenshotMark(highlight,{highlight},160,100);
require(cloneHighlight.points[0].x!=highlight.points[0].x && cloneHighlight.color==highlight.color &&
cloneHighlight.thickness==24 && cloneHighlight.number==0,"cloning highlight preserves brush and color");
PatBlt(dc,0,0,160,100,WHITENESS);
ks::ScreenshotMark line{{20,50},{140,50},0}; line.ends=ks::LineEnds::None;
ks::ScreenshotMark textMark{{5,5},{5,5},0}; textMark.text=L"Hello &\r\nСвіт";
textMark.fontSize=14; textMark.color=RGB(0,80,180);
auto textBounds=ks::screenshotTextBounds(textMark);
require(textBounds.right>30 && textBounds.bottom>30,"multiline Unicode text is measured");
auto textImage=ks::screenshotClip(dc,160,100,{textMark});
int textInk=0;
for(int y=5;y<textBounds.bottom;++y) for(int x=5;x<textBounds.right;++x)
if(readPixel(textImage,x,y)==textMark.color) ++textInk;
require(textInk>20,"text exports visible colored glyphs");
require(ks::hitScreenshotMark(textMark,{6,6},2)==ks::MarkPart::Body,"text is movable inside its bounds");
require(ks::hitScreenshotMark(textMark,{150,90},2)==ks::MarkPart::None,"text excludes empty canvas");
auto bigText=textMark; bigText.fontSize=28;
auto bigBounds=ks::screenshotTextBounds(bigText);
require(bigBounds.bottom>textBounds.bottom && bigBounds.right>textBounds.right,"font size scales text bounds");
auto serifText=textMark; serifText.fontName=L"Courier New"; serifText.fontWeight=FW_BOLD; serifText.fontItalic=true;
require(ks::screenshotClip(dc,160,100,{serifText}).formats[0].data!=textImage.formats[0].data,
"font family and style affect exported glyphs");
auto movedText=ks::dragScreenshotMark(textMark,ks::MarkPart::Body,{6,6},{16,16},160,100);
auto rotatedText=textMark; rotatedText.rotation=90;
auto rotatedBounds=ks::screenshotTextBounds(rotatedText);
require(std::abs((rotatedBounds.right-rotatedBounds.left)-(textBounds.bottom-textBounds.top))<=1,
"rotation transforms text bounds");
POINT center{(textBounds.left+textBounds.right)/2,(textBounds.top+textBounds.bottom)/2};
require(ks::hitScreenshotMark(rotatedText,center,2)==ks::MarkPart::Body,"rotated text hit test follows text");
auto rotationGrip=ks::screenshotTextRotationHandle(textMark);
auto rotation=ks::dragScreenshotMark(textMark,ks::MarkPart::Rotation,rotationGrip,{center.x+40,center.y-25},160,100,true);
require(std::abs(std::remainder(rotation.rotation,15))<0.001 && rotation.rotation!=0,"Shift snaps rotation to 15 degrees");
auto reverseRotation=ks::dragScreenshotMark(textMark,ks::MarkPart::Rotation,rotationGrip,{center.x-40,center.y-25},160,100,false);
require(reverseRotation.rotation<0,"text rotates in the opposite direction");
require(ks::screenshotClip(dc,160,100,{rotatedText}).formats[0].data!=textImage.formats[0].data,
"rotation is included in exported text pixels");
require(ks::cloneScreenshotMark(rotatedText,{rotatedText},160,100).rotation==90,"cloning preserves text rotation");
ks::ScreenshotMark labeled{{20,70},{140,70},0}; labeled.label=L"Label"; labeled.fontSize=12;
auto label=ks::screenshotArrowLabel(labeled);
auto labelBounds=ks::screenshotTextBounds(label);
POINT labelCenter{(labelBounds.left+labelBounds.right)/2,(labelBounds.top+labelBounds.bottom)/2};
auto labelGrip=ks::screenshotTextRotationHandle(label);
auto diagonal=labeled; diagonal.to={100,150};
for(int degrees:{0,45,90,135,180,-135,-90,-45}) {
double angle=degrees*3.141592653589793/180;
auto turning=labeled; turning.from={100,100};
turning.to={100+LONG(std::lround(80*std::cos(angle))),100+LONG(std::lround(80*std::sin(angle)))};
auto box=ks::screenshotTextBounds(ks::screenshotArrowLabel(turning));
double x=(box.left+box.right)/2.0-(turning.from.x+turning.to.x)/2.0;
double y=(box.top+box.bottom)/2.0-(turning.from.y+turning.to.y)/2.0;
require(std::abs(-x*std::sin(angle)+y*std::cos(angle)+24)<1.5,
"caption keeps its perpendicular distance and side as line rotates");
require(std::abs(x*std::cos(angle)+y*std::sin(angle))<1.5,
"caption remains centered along rotated line");
}
auto beforeDrag=ks::screenshotTextBounds(ks::screenshotArrowLabel(diagonal));
auto offsetDiagonal=ks::dragScreenshotMark(diagonal,ks::MarkPart::Label,{0,0},{12,-8},200,200);
require(!offsetDiagonal.labelRotationManual,"moving caption preserves automatic rotation");
auto movedCaptionSlope=ks::dragScreenshotMark(offsetDiagonal,ks::MarkPart::End,offsetDiagonal.to,{20,150},200,200);
require(std::abs(ks::screenshotArrowLabel(movedCaptionSlope).rotation-90)<0.001,
"repositioned caption continues following line angle");
auto afterDrag=ks::screenshotTextBounds(ks::screenshotArrowLabel(offsetDiagonal));
require(std::abs(afterDrag.left-beforeDrag.left-12)<=1 && std::abs(afterDrag.top-beforeDrag.top+8)<=1,
"dragging diagonal caption follows pointer in screen coordinates");
require(std::abs(ks::screenshotArrowLabel(diagonal).rotation-45)<0.001,"caption defaults to line angle");
auto changedSlope=ks::dragScreenshotMark(diagonal,ks::MarkPart::End,diagonal.to,{20,150},200,200);
require(std::abs(ks::screenshotArrowLabel(changedSlope).rotation-90)<0.001,"automatic caption follows endpoint movement");
auto reversed=diagonal; std::swap(reversed.from,reversed.to);
require(std::abs(ks::screenshotArrowLabel(reversed).rotation-45)<0.001,"automatic caption remains upright on reversed line");
auto diagonalLabel=ks::screenshotArrowLabel(diagonal);
auto diagonalGrip=ks::screenshotTextRotationHandle(diagonalLabel);
auto clickOnly=ks::dragScreenshotMark(diagonal,ks::MarkPart::Rotation,diagonalGrip,diagonalGrip,200,200,true);
require(!clickOnly.labelRotationManual,"clicking rotation handle without dragging preserves automatic angle");
auto manual=ks::dragScreenshotMark(diagonal,ks::MarkPart::Rotation,diagonalGrip,{diagonalGrip.x+12,diagonalGrip.y},200,200,false);
require(manual.labelRotationManual && manual.rotation>45 && manual.rotation<90,
"manual rotation starts at displayed automatic angle without jumping");
auto manualResized=ks::dragScreenshotMark(manual,ks::MarkPart::End,manual.to,{20,150},200,200);
require(ks::screenshotArrowLabel(manualResized).rotation==manual.rotation,"manual angle survives line resizing");
require(ks::cloneScreenshotMark(manual,{manual},200,200).labelRotationManual,"clone preserves manual rotation mode");
ks::ScreenshotHistory autoAngleHistory; std::vector<ks::ScreenshotMark> autoAngles{diagonal};
autoAngleHistory.remember(autoAngles); autoAngles[0]=manual;
require(autoAngleHistory.undo(autoAngles) && !autoAngles[0].labelRotationManual,"undo restores automatic angle mode");
require(autoAngleHistory.redo(autoAngles) && autoAngles[0].labelRotationManual,"redo restores manual angle mode");
auto curvedCaption=diagonal; curvedCaption.freehand=true;
curvedCaption.points={{20,70},{60,70},{60,110},{100,150}};
require(std::abs(ks::screenshotArrowLabel(curvedCaption).rotation-90)<0.001,"curve caption follows its middle segment");
auto rotatedCaption=ks::dragScreenshotMark(labeled,ks::MarkPart::Rotation,labelGrip,
{labelCenter.x+35,labelCenter.y-20},160,100,true);
require(rotatedCaption.rotation!=0 && std::abs(std::remainder(rotatedCaption.rotation,15))<0.001,
"arrow caption rotation snaps to 15 degrees");
require(rotatedCaption.from.x==labeled.from.x && rotatedCaption.from.y==labeled.from.y &&
rotatedCaption.to.x==labeled.to.x && rotatedCaption.to.y==labeled.to.y &&
rotatedCaption.labelOffset.x==labeled.labelOffset.x && rotatedCaption.labelOffset.y==labeled.labelOffset.y,
"rotating caption preserves arrow geometry and caption position");
require(ks::screenshotArrowLabel(rotatedCaption).rotation==rotatedCaption.rotation,
"linked caption uses the regular text rotation");
require(ks::screenshotClip(dc,160,100,{rotatedCaption}).formats[0].data!=ks::screenshotClip(dc,160,100,{labeled}).formats[0].data,
"caption rotation is exported");
require(ks::cloneScreenshotMark(rotatedCaption,{rotatedCaption},160,100).rotation==rotatedCaption.rotation,
"cloning retains caption angle");
ks::ScreenshotHistory captionHistory; std::vector<ks::ScreenshotMark> captionMarks{labeled};
captionHistory.remember(captionMarks); captionMarks[0]=rotatedCaption;
require(captionHistory.undo(captionMarks) && captionMarks[0].rotation==0,"undo restores caption angle");
require(captionHistory.redo(captionMarks) && captionMarks[0].rotation==rotatedCaption.rotation,"redo restores caption angle");
require(ks::hitScreenshotMark(labeled,labelCenter,2)==ks::MarkPart::Label,"arrow label is independently draggable");
auto repositioned=ks::dragScreenshotMark(labeled,ks::MarkPart::Label,labelCenter,{labelCenter.x+10,labelCenter.y-8},160,100);
require(repositioned.labelOffset.x==10 && repositioned.labelOffset.y==-32 && repositioned.from.x==20,
"label drag changes only its offset");
auto movedArrow=ks::dragScreenshotMark(labeled,ks::MarkPart::Body,{20,70},{25,75},160,100);
auto movedLabel=ks::screenshotArrowLabel(movedArrow);
require(movedLabel.from.x==label.from.x+5 && movedLabel.from.y==label.from.y+5,"label follows arrow movement");
auto resizedArrow=ks::dragScreenshotMark(labeled,ks::MarkPart::End,labeled.to,{120,70},160,100);
require(ks::screenshotArrowLabel(resizedArrow).from.x==label.from.x-10,"label follows arrow resizing");
auto noLabel=labeled; noLabel.label.clear();
require(ks::screenshotClip(dc,160,100,{labeled}).formats[0].data!=ks::screenshotClip(dc,160,100,{noLabel}).formats[0].data,
"arrow label is included in clipboard export");
require(ks::cloneScreenshotMark(labeled,{labeled},160,100).label==labeled.label,"cloning retains arrow label");
ks::ScreenshotHistory annotationHistory; std::vector<ks::ScreenshotMark> annotations{rotatedText,labeled};
annotationHistory.remember(annotations); annotations[0]=rotation; annotations[1]=repositioned;
require(annotationHistory.undo(annotations) && annotations[0].rotation==90 && annotations[1].labelOffset.x==0,
"undo restores rotation and label position");
require(annotationHistory.redo(annotations) && annotations[0].rotation==rotation.rotation && annotations[1].labelOffset.x==10,
"redo restores rotation and label position");
require(movedText.from.x==15 && movedText.from.y==15 && movedText.text==textMark.text,"text movement preserves content");
auto oversizedText=textMark; oversizedText.fontSize=100;
auto oversizedBounds=ks::screenshotTextBounds(oversizedText);
require(oversizedBounds.right-oversizedBounds.left>160 && oversizedBounds.bottom-oversizedBounds.top>100,
"large text exceeds both canvas dimensions");
for(int direction:{-1,1}) {
auto moved=ks::dragScreenshotMark(oversizedText,ks::MarkPart::Body,{0,0},{direction*20,direction*20},160,100);
require(moved.from.x==oversizedText.from.x+direction*20 && moved.from.y==oversizedText.from.y+direction*20,
"oversized text moves in either direction on both axes");
auto limited=ks::dragScreenshotMark(oversizedText,ks::MarkPart::Body,{0,0},{direction*10000,direction*10000},160,100);
auto bounds=ks::screenshotTextBounds(limited);
require(bounds.right>=16 && bounds.left<=144 && bounds.bottom>=16 && bounds.top<=84,
"oversized text remains selectable at canvas edges");
}
auto clonedText=ks::cloneScreenshotMark(serifText,{serifText},160,100);
require(clonedText.text==serifText.text && clonedText.fontName==serifText.fontName &&
clonedText.fontWeight==FW_BOLD && clonedText.fontItalic,"text cloning preserves font and content");
ks::ScreenshotHistory textHistory; std::vector<ks::ScreenshotMark> texts{textMark};
textHistory.remember(texts); texts[0]=serifText; texts[0].text=L"Changed";
require(textHistory.undo(texts) && texts[0].text==textMark.text && texts[0].fontName==textMark.fontName,
"undo restores text and font");
require(textHistory.redo(texts) && texts[0].text==L"Changed" && texts[0].fontItalic,
"redo restores text edits");
for(auto shape:{ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse}) {
ks::ScreenshotMark figure{{30,20},{90,80},0}; figure.shape=shape;
if(shape==ks::ScreenshotShape::Ellipse) figure.to.x=130;
figure.color=RGB(0,140,80); figure.thickness=4;
auto image=ks::screenshotClip(dc,160,100,{figure});
int mid=(figure.from.x+figure.to.x)/2;
require(readPixel(image,mid,20)==figure.color,"shape contour renders in chosen color");
require(readPixel(image,mid,50)==RGB(255,255,255),"shape interior remains transparent");
require(ks::hitScreenshotMark(figure,{mid,20},2)==ks::MarkPart::Body,"shape contour is movable");
require(ks::hitScreenshotMark(figure,{mid,50},2)==ks::MarkPart::None,"shape interior does not intercept objects");
auto resized=ks::dragScreenshotMark(figure,ks::MarkPart::End,figure.to,{100,85},160,100);
require(resized.shape==shape && resized.to.x==100,"shape corner can resize object");
if(shape==ks::ScreenshotShape::Circle)
require(resized.to.x-resized.from.x==resized.to.y-resized.from.y,"circle remains round when resized");
auto moved=ks::dragScreenshotMark(figure,ks::MarkPart::Body,{mid,20},{mid+5,25},160,100);
require(moved.from.x==35 && moved.to.x==figure.to.x+5,"moving shape preserves dimensions");
auto duplicate=ks::cloneScreenshotMark(figure,{figure},160,100);
require(duplicate.shape==shape && duplicate.color==figure.color,"cloning preserves shape and color");
ks::ScreenshotHistory history; std::vector<ks::ScreenshotMark> figures{figure};
history.remember(figures); figures[0]=resized;
require(history.undo(figures) && figures[0].to.x==figure.to.x,"shape resize can be undone");
require(history.redo(figures) && figures[0].to.x==resized.to.x,"shape resize can be redone");
}
auto circleCorner=ks::constrainScreenshotShape(ks::ScreenshotShape::Circle,{60,60},{0,10},160,100);
require(circleCorner.x==0 && circleCorner.y==0,"circle supports reverse drawing and canvas boundaries");
auto solidLine=ks::screenshotClip(dc,160,100,{line});
require(readPixel(solidLine,28,47)==RGB(255,255,255) && readPixel(solidLine,132,47)==RGB(255,255,255),
"plain line has no arrowheads");
for(auto ends:{ks::LineEnds::End,ks::LineEnds::Start,ks::LineEnds::Both}) {
line.ends=ends;
auto image=ks::screenshotClip(dc,160,100,{line});
require((readPixel(image,28,47)==line.color)==(ends!=ks::LineEnds::End),"start arrowhead matches ends setting");
require((readPixel(image,132,47)==line.color)==(ends!=ks::LineEnds::Start),"end arrowhead matches ends setting");
}
line.ends=ks::LineEnds::None;
std::vector<ks::Bytes> styleImages;
ks::ScreenshotMark shaky{{20,50},{140,50},0};
shaky.freehand=true; shaky.ends=ks::LineEnds::Both;
shaky.points={{20,50},{19,50},{40,50},{120,50},{141,50},{140,50}};
auto shakyImage=ks::screenshotClip(dc,160,100,{shaky});
require(readPixel(shakyImage,28,47)==shaky.color && readPixel(shakyImage,12,47)==RGB(255,255,255),
"start arrowhead points outward despite initial mouse jitter");
require(readPixel(shakyImage,132,47)==shaky.color && readPixel(shakyImage,148,47)==RGB(255,255,255),
"end arrowhead points outward despite final mouse jitter");
for(auto style:{ks::LineStyle::Solid,ks::LineStyle::Dotted,ks::LineStyle::Dashed,ks::LineStyle::DashDot}) {
line.style=style;
auto image=ks::screenshotClip(dc,160,100,{line});
int ink=0,gaps=0;
for(int x=25;x<135;++x) { if(readPixel(image,x,50)==line.color) ++ink; else ++gaps; }
require(ink>0 && (style==ks::LineStyle::Solid?gaps==0:gaps>0),"line pattern exports visible ink and expected gaps");
for(auto &previous:styleImages) require(previous!=image.formats[0].data,"line styles have distinct output");
styleImages.push_back(image.formats[0].data);
}
ks::ScreenshotMark curve{{20,80},{140,30},0}; curve.freehand=true; curve.ends=ks::LineEnds::None;
curve.points={{20,80},{50,20},{90,70},{140,30}}; curve.color=RGB(0,80,220);
auto curveImage=ks::screenshotClip(dc,160,100,{curve});
require(!curve.isHighlight() && readPixel(curveImage,50,20)==curve.color,"freehand line exports opaque curved path");
require(ks::hitScreenshotMark(curve,{50,20},3)==ks::MarkPart::Body,"curve hit test follows actual stroke");
auto curveMoved=ks::dragScreenshotMark(curve,ks::MarkPart::Body,{50,20},{55,25},160,100);
require(curveMoved.points[1].x==55 && curveMoved.points[1].y==25,"curve movement preserves all intermediate points");
auto curveEnd=ks::dragScreenshotMark(curve,ks::MarkPart::End,curve.to,{150,40},160,100);
require(curveEnd.points.back().x==150 && curveEnd.points.back().y==40 && curveEnd.points[1].y==20,
"curve endpoint editing preserves intermediate shape");
curve.style=ks::LineStyle::DashDot; curve.ends=ks::LineEnds::Both;
auto curveCopy=ks::cloneScreenshotMark(curve,{curve},160,100);
require(curveCopy.freehand && curveCopy.style==curve.style && curveCopy.ends==curve.ends,"curve cloning preserves style and arrowheads");
ks::ScreenshotHistory lineHistory;
std::vector<ks::ScreenshotMark> lines{curve}; lineHistory.remember(lines);
lines[0].style=ks::LineStyle::Dotted; lines[0].ends=ks::LineEnds::None;
require(lineHistory.undo(lines) && lines[0].style==ks::LineStyle::DashDot && lines[0].ends==ks::LineEnds::Both,
"undo restores line pattern and endpoints");
require(lineHistory.redo(lines) && lines[0].style==ks::LineStyle::Dotted && lines[0].ends==ks::LineEnds::None,
"redo restores line pattern and endpoints");
SelectObject(dc,old); DeleteObject(bitmap); DeleteDC(dc);
}
auto event=[](DWORD vk,DWORD scan=0,bool own=false) { auto event=[](DWORD vk,DWORD scan=0,bool own=false) {
KBDLLHOOKSTRUCT k{}; k.vkCode=vk; k.scanCode=scan; KBDLLHOOKSTRUCT k{}; k.vkCode=vk; k.scanCode=scan;
k.flags=LLKHF_INJECTED|LLKHF_LOWER_IL_INJECTED; k.flags=LLKHF_INJECTED|LLKHF_LOWER_IL_INJECTED;
@@ -81,6 +487,11 @@ int main(int argc, char **argv) {
ks::showSettings(app,false); ks::showSettings(app,false);
auto settings=app.settingsWindow; auto settings=app.settingsWindow;
require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus"); require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus");
for(auto pair:{std::pair{207,215},std::pair{207,232},std::pair{207,220},std::pair{232,230},std::pair{230,231},std::pair{231,253}}) {
RECT first{},second{},overlap{};
GetWindowRect(GetDlgItem(settings,pair.first),&first); GetWindowRect(GetDlgItem(settings,pair.second),&second);
require(!IntersectRect(&overlap,&first,&second),"capture hotkey and layout controls do not overlap");
}
RECT frame{},client{},cancel{}; RECT frame{},client{},cancel{};
GetWindowRect(settings,&frame); GetWindowRect(settings,&frame);
MONITORINFO monitor{sizeof(monitor)}; MONITORINFO monitor{sizeof(monitor)};
+156
View File
@@ -0,0 +1,156 @@
// Exercise the actual editor controls without capturing the desktop or changing the clipboard.
// Including the implementation lets this integration test drive private editor state directly.
#include "../src/screenshot.cpp"
#include <iostream>
int main()
{
auto temp=std::filesystem::temp_directory_path()/(L"KSwitcher-editor-test-"+std::to_wstring(GetCurrentProcessId()));
try {
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls);
ks::App app(GetModuleHandleW(nullptr),true,temp);
app.window=CreateWindowW(L"STATIC",L"",0,0,0,0,0,HWND_MESSAGE,nullptr,app.instance,nullptr);
ks::Capture c(app);
c.image=std::make_unique<ks::Surface>(320,240);
PatBlt(c.image->dc,0,0,320,240,WHITENESS); c.start={0,0}; c.end={320,240};
WNDCLASSW cls{}; cls.hInstance=app.instance; cls.lpfnWndProc=ks::captureProc;
cls.lpszClassName=L"KSwitcher.Editor.Integration"; RegisterClassW(&cls);
auto window=CreateWindowW(cls.lpszClassName,L"Editor test",WS_POPUP,0,0,320,240,app.window,nullptr,app.instance,&c);
auto require=[](bool ok,const char *message){ if(!ok) throw std::runtime_error(message); };
require(window!=nullptr,"editor creation");
ks::edit(window,c); ShowWindow(window,SW_HIDE);
require(GetDlgItem(window,50)!=nullptr && c.statusHeight>0,"status bar is created");
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
for(int size:{32,48,64,96}) {
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
ICONINFO info{};
require(cursor && GetIconInfo(cursor,&info) && !info.fIcon,"pencil cursor supports standard and high DPI");
require(info.xHotspot<UINT(size/3) && info.yHotspot>UINT(size*2/3),"pencil cursor draws from its tip");
if(info.hbmMask) DeleteObject(info.hbmMask);
if(info.hbmColor) DeleteObject(info.hbmColor);
DestroyCursor(cursor);
}
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
require(std::wstring(windowTitle)==L"KSwitcher — Screenshot","title has no usage hints");
RECT client{}; GetClientRect(window,&client);
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
ks::updateStatus(c);
require(c.statusText.find(L"Freehand")!=std::wstring::npos,"default tool hint is shown");
SendMessageW(GetDlgItem(window,13),WM_MOUSEMOVE,0,0);
require(c.statusText.find(L"Ctrl+C")!=std::wstring::npos,"button hover shows action shortcut");
SendMessageW(GetDlgItem(window,13),WM_MOUSELEAVE,0,0);
require(c.statusText.find(L"Freehand")!=std::wstring::npos,"button leave restores contextual hint");
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");
ks::Mark text{{20,20},{20,20},0}; text.fontSize=18;
ks::beginInlineText(window,c,text,-1);
require(c.inlineText!=nullptr,"inline text control opens");
ks::updateStatus(c);
require(c.statusText.find(L"Ctrl+Enter")!=std::wstring::npos,"inline editing hint shows commit and cancel keys");
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"First line\r\nДругий рядок");
require(c.textDraft.text==L"First line\r\nДругий рядок","inline text changes update draft");
SendMessageW(window,ks::FinishTextMessage,1,(LPARAM)c.inlineText);
require(!c.inlineText && c.marks.size()==1 && c.marks[0].text==text.text+L"First line\r\nДругий рядок","commit adds multiline text");
ks::beginInlineText(window,c,c.marks[0],0);
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Cancelled");
SendMessageW(window,ks::FinishTextMessage,2,(LPARAM)c.inlineText);
require(c.marks[0].text!=L"Cancelled","Escape cancels changes");
SendMessageW(window,WM_KEYDOWN,VK_F2,0);
require(c.inlineText!=nullptr,"F2 edits inline");
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Edited"); ks::finishInlineText(window,c,true);
require(c.marks[0].text==L"Edited","existing text edit commits");
SendMessageW(window,WM_COMMAND,12,0);
require(c.marks[0].text!=L"Edited","undo restores original text");
SendMessageW(window,WM_COMMAND,18,0);
require(c.marks[0].text==L"Edited","redo restores text edit");
ks::beginInlineText(window,c,c.marks[0],0);
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L""); ks::finishInlineText(window,c,true);
require(c.marks.empty(),"committing empty text deletes object");
c.marks.push_back({{30,150},{260,150},0}); c.selected=0;
SendMessageW(window,WM_COMMAND,35,0);
require(c.inlineText && c.editingLabel,"label button opens inline editor");
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Arrow caption");
ks::finishInlineText(window,c,true);
require(c.marks.size()==1 && c.marks[0].label==L"Arrow caption" && c.marks[0].isLine(),"label attaches to arrow");
SendMessageW(window,WM_KEYDOWN,VK_F2,0);
require(c.inlineText && c.editingLabel,"F2 edits arrow caption");
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L""); ks::finishInlineText(window,c,true);
require(c.marks.size()==1 && c.marks[0].label.empty(),"empty label removes caption without deleting arrow");
SendMessageW(window,WM_COMMAND,12,0);
require(c.marks[0].label==L"Arrow caption","undo restores arrow caption");
auto caption=ks::screenshotArrowLabel(c.marks[0]);
auto captionGrip=ks::screenshotTextRotationHandle(caption,double(c.unit*0.6)*c.image->width/(c.view.right-c.view.left));
c.selected=0;
require(ks::markAt(c,captionGrip).second==ks::MarkPart::Rotation,"caption rotation handle is interactive");
c.marks[0]=ks::dragScreenshotMark(c.marks[0],ks::MarkPart::Rotation,captionGrip,{captionGrip.x+40,captionGrip.y+20},320,240,true);
auto angle=c.marks[0].rotation;
SendMessageW(window,WM_KEYDOWN,VK_F2,0);
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Rotated caption"); ks::finishInlineText(window,c,true);
require(c.marks[0].rotation==angle && c.marks[0].label==L"Rotated caption","editing caption preserves its rotation");
auto rotating=text; rotating.text=L"Rotate"; c.marks.push_back(rotating); c.selected=1;
auto handle=ks::screenshotTextRotationHandle(rotating,double(c.unit*0.6)*c.image->width/(c.view.right-c.view.left));
require(ks::markAt(c,handle).second==ks::MarkPart::Rotation,"rotation handle is interactive");
c.hoveredPart=ks::MarkPart::Rotation; ks::updateStatus(c);
require(c.statusText.find(L"15-degree")!=std::wstring::npos,"rotation hint explains Shift snapping");
c.hoveredPart=ks::MarkPart::None;
std::vector<ks::Mark> nudgeMarks;
nudgeMarks.push_back({{60,60},{60,60},1});
nudgeMarks.push_back({{60,60},{120,100},0});
auto nudgeText=text; nudgeText.text=L"Move"; nudgeText.from=nudgeText.to={60,60}; nudgeMarks.push_back(nudgeText);
for(auto shape:{ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse}) {
ks::Mark mark{{60,60},{100,100},0}; mark.shape=shape; nudgeMarks.push_back(mark);
}
for(bool freehand:{false,true}) {
ks::Mark mark{{60,60},{120,100},0}; mark.points={{60,60},{90,80},{120,100}}; mark.freehand=freehand;
nudgeMarks.push_back(mark);
}
for(auto mark:nudgeMarks) {
c.marks={mark}; c.selected=0; c.selectedLabel=false;
SendMessageW(window,WM_KEYDOWN,VK_RIGHT,0); SendMessageW(window,WM_KEYDOWN,VK_DOWN,0);
require(c.marks[0].from.x==61 && c.marks[0].from.y==61,"every object nudges one pixel per key");
if(!mark.points.empty()) require(c.marks[0].points[1].x==91 && c.marks[0].points[1].y==81,"nudge moves entire stroke");
SendMessageW(window,WM_KEYDOWN,VK_LEFT,0); SendMessageW(window,WM_KEYDOWN,VK_UP,0);
require(c.marks[0].from.x==60 && c.marks[0].from.y==60,"opposite keys restore original position");
}
ks::Mark nudgeArrow{{60,60},{140,140},0}; nudgeArrow.label=L"Caption"; nudgeArrow.fontSize=12;
c.marks={nudgeArrow}; c.selected=0; c.selectedLabel=true;
auto beforeNudge=ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0]));
for(int i=0;i<5;++i) SendMessageW(window,WM_KEYDOWN,VK_RIGHT,0);
auto afterNudge=ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0]));
require(afterNudge.left==beforeNudge.left+5 && afterNudge.top==beforeNudge.top,
"caption nudges exact screen pixels at a diagonal angle");
require(c.marks[0].from.x==60 && !c.marks[0].labelRotationManual,"caption nudge leaves arrow and automatic angle intact");
SendMessageW(window,WM_COMMAND,12,0);
require(ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0])).left==beforeNudge.left+4,"undo reverses one nudge");
SendMessageW(window,WM_COMMAND,18,0);
require(ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0])).left==beforeNudge.left+5,"redo restores one nudge");
SetWindowPos(window,nullptr,80,60,720,500,SWP_NOZORDER|SWP_SHOWWINDOW);
SetPropW(window,L"ConfirmationCentered",nullptr);
SetTimer(window,987,10,[](HWND owner,UINT,UINT_PTR id,DWORD) {
EnumThreadWindows(GetCurrentThreadId(),[](HWND popup,LPARAM parent)->BOOL {
wchar_t className[64]{}; GetClassNameW(popup,className,64);
if(GetWindow(popup,GW_OWNER)==(HWND)parent && std::wstring(className)==L"#32770") {
RECT ownerRect{},popupRect{};
GetWindowRect((HWND)parent,&ownerRect); GetWindowRect(popup,&popupRect);
if(std::abs(ownerRect.left+ownerRect.right-popupRect.left-popupRect.right)<=4 &&
std::abs(ownerRect.top+ownerRect.bottom-popupRect.top-popupRect.bottom)<=4)
SetPropW((HWND)parent,L"ConfirmationCentered",(HANDLE)1);
PostMessageW(popup,TDM_CLICK_BUTTON,IDNO,0);
}
return TRUE;
},(LPARAM)owner);
KillTimer(owner,id);
});
SendMessageW(window,WM_CLOSE,0,0);
require(IsWindow(window),"declining close confirmation keeps the screenshot open");
require(GetPropW(window,L"ConfirmationCentered")!=nullptr,"confirmation is centered on editor instead of screen");
RemovePropW(window,L"ConfirmationCentered");
c.copied=true;
SendMessageW(window,WM_KEYDOWN,VK_ESCAPE,0);
require(IsWindow(window),"Escape does not close the screenshot editor");
SendMessageW(window,WM_CLOSE,0,0);
require(!IsWindow(window),"copied screenshot closes without confirmation");
DestroyWindow(app.window); app.window=nullptr;
std::filesystem::remove_all(temp);
std::cout<<"Passed screenshot editor UI integration checks\n";
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; return 1; }
}
+14
View File
@@ -389,6 +389,10 @@ int main(int argc, char **argv)
settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}}; settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}};
ks::saveSettings(temp / L"settings.json", settings); ks::saveSettings(temp / L"settings.json", settings);
auto restored = ks::loadSettings(temp / L"settings.json", {}); auto restored = ks::loadSettings(temp / L"settings.json", {});
require(ks::encodeSettings(ks::decodeSettings(ks::encodeSettings(settings), {})) == ks::encodeSettings(settings),
"configurator snapshot survives serialization without changing the concurrency token");
rejects([&] { ks::decodeSettings(ks::Bytes{'{', '}'}, {}); },
"configurator rejects missing settings format");
require(!restored.writeLog, "logging setting persists when disabled"); require(!restored.writeLog, "logging setting persists when disabled");
require(ks::loadSettings(temp / L"defaults.json", {}).writeLog, require(ks::loadSettings(temp / L"defaults.json", {}).writeLog,
"logging defaults on for existing diagnostic installations"); "logging defaults on for existing diagnostic installations");
@@ -424,6 +428,16 @@ int main(int argc, char **argv)
require(!ks::loadSettings(temp / L"defaults.json", {}).elevatedAutostart, require(!ks::loadSettings(temp / L"defaults.json", {}).elevatedAutostart,
"elevated autostart is opt-in for existing and default settings"); "elevated autostart is opt-in for existing and default settings");
auto invalid = settings; auto invalid = settings;
auto captureSettings = settings;
captureSettings.screenshot = L"Ctrl+Shift+F9";
require(ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshot == captureSettings.screenshot,
"screen capture shortcut roundtrip");
captureSettings.screenshot = captureSettings.clipboard;
rejects([&] { ks::validateSettings(captureSettings); }, "screen capture shortcut duplicates rejected");
captureSettings.screenshot.clear();
ks::validateSettings(captureSettings);
require(ks::loadSettings(temp / L"defaults.json", {}).screenshot == L"Win+Shift+S",
"screen capture default for existing settings");
invalid.undo = invalid.toggle; invalid.undo = invalid.toggle;
rejects([&] { ks::validateSettings(invalid); }, "duplicate shortcut rejected"); rejects([&] { ks::validateSettings(invalid); }, "duplicate shortcut rejected");
invalid = settings; invalid = settings;
+41
View File
@@ -22,6 +22,9 @@ try {
Assert ((Select-Release @($release) ([version]'0.2.0')).version -eq 'v0.2.1') 'newer release' Assert ((Select-Release @($release) ([version]'0.2.0')).version -eq 'v0.2.1') 'newer release'
Assert ($null -eq (Select-Release @($release) ([version]'0.2.1'))) 'same version' Assert ($null -eq (Select-Release @($release) ([version]'0.2.1'))) 'same version'
Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade' Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade'
Assert ((Get-StableVersion 'release-v0.5.0') -eq [version]'0.5.0') 'modern stable release tag'
$modern=@{draft=$false;prerelease=$false;tag_name='release-v0.5.0';assets=$assets}
Assert ((Select-Release @($release,$modern) ([version]'0.4.2')).version -eq 'release-v0.5.0') 'bridge discovers modern channel'
$draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets} $draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets}
Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored' Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored'
Reject { Select-Release @(@{draft=$false;prerelease=$false;tag_name='v2.0.0';assets=@()}) ([version]'0.2.0') } 'incomplete release rejected' Reject { Select-Release @(@{draft=$false;prerelease=$false;tag_name='v2.0.0';assets=@()}) ([version]'0.2.0') } 'incomplete release rejected'
@@ -30,15 +33,34 @@ try {
} }
$source=Join-Path $root 'source'; [IO.Directory]::CreateDirectory($source) | Out-Null $source=Join-Path $root 'source'; [IO.Directory]::CreateDirectory($source) | Out-Null
Copy-Item -LiteralPath $Executable -Destination (Join-Path $source 'KSwitcher.exe') Copy-Item -LiteralPath $Executable -Destination (Join-Path $source 'KSwitcher.exe')
$configurator = Join-Path (Split-Path $Executable) 'KSwitcher.Config.exe'
if (Test-Path -LiteralPath $configurator) {
Copy-Item -LiteralPath $configurator -Destination (Join-Path $source 'KSwitcher.Config.exe')
}
$screenshot = Join-Path (Split-Path $Executable) 'KSwitcher.Screenshot.exe'
if (Test-Path -LiteralPath $screenshot) { Copy-Item -LiteralPath $screenshot -Destination (Join-Path $source 'KSwitcher.Screenshot.exe') }
foreach ($name in @('LICENSE','json-LICENSE.txt','README.md')) { [IO.File]::WriteAllText((Join-Path $source $name),'fixture') } foreach ($name in @('LICENSE','json-LICENSE.txt','README.md')) { [IO.File]::WriteAllText((Join-Path $source $name),'fixture') }
[IO.Directory]::CreateDirectory((Join-Path $source 'dictionaries')) | Out-Null [IO.Directory]::CreateDirectory((Join-Path $source 'dictionaries')) | Out-Null
[IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary') [IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary')
function Write-TestManifest($Folder) {
$entries=@(Get-ChildItem -LiteralPath $Folder -File -Recurse | Where-Object Name -ne 'release-files.json' | ForEach-Object {
@{path=$_.FullName.Substring($Folder.Length+1).Replace('\','/');size=$_.Length;sha256=(Get-FileHash -LiteralPath $_.FullName).Hash}
})
@{schema=1;version=$binaryVersion.ToString();files=$entries} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $Folder 'release-files.json') -Encoding utf8
}
[IO.File]::WriteAllText((Join-Path $source 'future-tool.exe'),'future companion fixture')
Write-TestManifest $source
Add-Type -AssemblyName System.IO.Compression.FileSystem Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive=Join-Path $root 'release.zip' $archive=Join-Path $root 'release.zip'
[IO.Compression.ZipFile]::CreateFromDirectory($source,$archive) [IO.Compression.ZipFile]::CreateFromDirectory($source,$archive)
$sum=Join-Path $root 'SHA256SUMS.txt' $sum=Join-Path $root 'SHA256SUMS.txt'
((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash+' KSwitcher-win-x64.zip') | Set-Content -LiteralPath $sum ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash+' KSwitcher-win-x64.zip') | Set-Content -LiteralPath $sum
Assert-Checksum $archive $sum; ++$script:checks Assert-Checksum $archive $sum; ++$script:checks
& {
function Get-FileHash { throw 'Get-FileHash unavailable in inherited module environment' }
Assert-Checksum $archive $sum
}
++$script:checks
$stage=Join-Path $root 'stage' $stage=Join-Path $root 'stage'
Expand-Release $archive $stage $binaryVersion; ++$script:checks Expand-Release $archive $stage $binaryVersion; ++$script:checks
Reject { Expand-Release $archive (Join-Path $root 'wrong-version') ([version]'9.0.0') } 'version mismatch rejected' Reject { Expand-Release $archive (Join-Path $root 'wrong-version') ([version]'9.0.0') } 'version mismatch rejected'
@@ -48,6 +70,19 @@ try {
$z=[IO.Compression.ZipFile]::Open($bad,[IO.Compression.ZipArchiveMode]::Create) $z=[IO.Compression.ZipFile]::Open($bad,[IO.Compression.ZipArchiveMode]::Create)
$null=$z.CreateEntry('../escape.exe'); $z.Dispose() $null=$z.CreateEntry('../escape.exe'); $z.Dispose()
Reject { Expand-Release $bad (Join-Path $root 'badstage') ([version]'0.2.1') } 'zip traversal rejected' Reject { Expand-Release $bad (Join-Path $root 'badstage') ([version]'0.2.1') } 'zip traversal rejected'
foreach ($unsafe in @('../escape','C:/escape','data/settings.json','DATA/history.bin','folder/../escape','folder/file:stream','CON.txt','folder/file.','/absolute','folder\file')) {
Reject { Assert-PackagePath $unsafe } 'unsafe or protected manifest path rejected'
}
$corrupt=Join-Path $root 'corrupt.zip'
Copy-Item -LiteralPath $archive -Destination $corrupt
$z=[IO.Compression.ZipFile]::Open($corrupt,[IO.Compression.ZipArchiveMode]::Update)
$z.GetEntry('future-tool.exe').Delete()
$writer=[IO.StreamWriter]::new($z.CreateEntry('future-tool.exe').Open()); $writer.Write('tampered'); $writer.Dispose(); $z.Dispose()
Reject { Expand-Release $corrupt (Join-Path $root 'corruptstage') $binaryVersion } 'manifest detects tampered companion'
$extra=Join-Path $root 'extra.zip'; Copy-Item -LiteralPath $archive -Destination $extra
$z=[IO.Compression.ZipFile]::Open($extra,[IO.Compression.ZipArchiveMode]::Update)
$null=$z.CreateEntry('undeclared.dll'); $z.Dispose()
Reject { Expand-Release $extra (Join-Path $root 'extrastage') $binaryVersion } 'undeclared file rejected'
$target=Join-Path $root 'install'; $backup=Join-Path $root 'backup' $target=Join-Path $root 'install'; $backup=Join-Path $root 'backup'
[IO.Directory]::CreateDirectory((Join-Path $target 'data')) | Out-Null [IO.Directory]::CreateDirectory((Join-Path $target 'data')) | Out-Null
[IO.Directory]::CreateDirectory((Join-Path $target 'dictionaries')) | Out-Null [IO.Directory]::CreateDirectory((Join-Path $target 'dictionaries')) | Out-Null
@@ -57,11 +92,17 @@ try {
$changed=[Collections.Generic.List[string]]::new() $changed=[Collections.Generic.List[string]]::new()
Install-Files $stage $target $backup $changed Install-Files $stage $target $backup $changed
Assert ((Get-FileHash -LiteralPath (Join-Path $target 'KSwitcher.exe')).Hash -eq (Get-FileHash -LiteralPath $Executable).Hash) 'replacement installed' Assert ((Get-FileHash -LiteralPath (Join-Path $target 'KSwitcher.exe')).Hash -eq (Get-FileHash -LiteralPath $Executable).Hash) 'replacement installed'
if (Test-Path -LiteralPath $configurator) {
Assert ((Get-FileHash -LiteralPath (Join-Path $target 'KSwitcher.Config.exe')).Hash -eq (Get-FileHash -LiteralPath $configurator).Hash) 'configurator installed'
}
Assert ([IO.File]::ReadAllText((Join-Path $target 'future-tool.exe')) -eq 'future companion fixture') 'new companion name installs without changing updater'
Assert ([IO.File]::ReadAllText((Join-Path $target 'data/settings.json')) -eq 'personal settings') 'settings preserved' Assert ([IO.File]::ReadAllText((Join-Path $target 'data/settings.json')) -eq 'personal settings') 'settings preserved'
Assert ([IO.File]::ReadAllText((Join-Path $target 'dictionaries/en.txt')) -eq 'personal dictionary') 'dictionary preserved' Assert ([IO.File]::ReadAllText((Join-Path $target 'dictionaries/en.txt')) -eq 'personal dictionary') 'dictionary preserved'
Undo-Files $target $backup $changed Undo-Files $target $backup $changed
Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'rollback restores original' Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'rollback restores original'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'README.md'))) 'rollback removes newly introduced files' Assert (!(Test-Path -LiteralPath (Join-Path $target 'README.md'))) 'rollback removes newly introduced files'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Config.exe'))) 'rollback removes newly introduced configurator'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Screenshot.exe'))) 'rollback removes newly introduced screenshot editor'
# Fail after the original exe has moved to backup, as if the staged file vanished. # Fail after the original exe has moved to backup, as if the staged file vanished.
$stage2=Join-Path $root 'stage2'; [IO.Directory]::CreateDirectory($stage2) | Out-Null $stage2=Join-Path $root 'stage2'; [IO.Directory]::CreateDirectory($stage2) | Out-Null
[IO.File]::WriteAllText((Join-Path $stage2 'KSwitcher.exe'),'new binary fixture') [IO.File]::WriteAllText((Join-Path $stage2 'KSwitcher.exe'),'new binary fixture')
+42
View File
@@ -0,0 +1,42 @@
"""Development only: pip install resvg-py==0.5.0 Pillow.
Render the pinned Lucide SVG originals into embedded, multi-resolution Windows icons.
"""
from pathlib import Path
from io import BytesIO
from PIL import Image
import resvg_py
import re
import struct
root = Path(__file__).resolve().parents[1] / 'resources' / 'icons' / 'lucide'
names = ['circle-dot', 'arrow-up-right', 'undo-2', 'copy', 'x', 'redo-2', 'highlighter', 'palette', 'pencil', 'slash', 'square', 'circle', 'ellipse', 'type', 'a-large-small']
for name in names:
svg = (root / f'{name}.svg').read_text(encoding='utf-8')
for variant, color in [('normal', '#334155'), ('active', '#2563eb'), ('disabled', '#94a3b8')]:
png = resvg_py.svg_to_bytes(svg_string=svg.replace('currentColor', color), width=256, height=256)
im = Image.open(BytesIO(png)).convert('RGBA')
im.save(root / f'{name}-{variant}.ico', sizes=[(s, s) for s in (16,20,22,24,28,32,40,44,48,56,64,96,128,256)])
# Small local visual QA sheet of the actual downloaded paths.
preview = Image.new('RGB', (len(names)*60, 72), '#f8fafc')
for i, name in enumerate(names):
icon = Image.open(root / f'{name}-normal.ico').convert('RGBA').resize((32,32),Image.Resampling.LANCZOS)
preview.paste(icon,(14+i*60,20),icon)
preview.save(root / 'preview.png')
# Cursor uses the same Lucide pencil, with a white outline for dark screenshots.
paths = '\n'.join(re.findall(r'<path\b[^>]+/>', (root / 'pencil.svg').read_text(encoding='utf-8')))
cursor_svg = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 28 28" fill="none" stroke-linecap="round" stroke-linejoin="round">
<g stroke="white" stroke-width="4">{paths}</g><g stroke="#334155" stroke-width="2">{paths}</g></svg>'''
cursor_png = resvg_py.svg_to_bytes(svg_string=cursor_svg, width=256, height=256)
cursor_image = Image.open(BytesIO(cursor_png)).convert('RGBA')
buffer = BytesIO()
cursor_image.save(buffer, format='ICO', bitmap_format='bmp', sizes=[(s,s) for s in (32,48,64,96)])
cursor = bytearray(buffer.getvalue())
struct.pack_into('<H', cursor, 2, 2) # CUR instead of ICO
for index in range(struct.unpack_from('<H', cursor, 4)[0]):
entry = 6 + index * 16
size = cursor[entry] or 256
# Drawing point is the tip of the pencil, not the center of its image.
struct.pack_into('<HH', cursor, entry+4, round(size*4/28), round(size*24/28))
(root / 'pencil.cur').write_bytes(cursor)
+10 -1
View File
@@ -6,7 +6,16 @@ try {
$stop = Start-Process -FilePath $exe -ArgumentList '--exit' -WindowStyle Hidden -PassThru $stop = Start-Process -FilePath $exe -ArgumentList '--exit' -WindowStyle Hidden -PassThru
if (-not $stop.WaitForExit(5000)) { throw 'Exit command timed out.' } if (-not $stop.WaitForExit(5000)) { throw 'Exit command timed out.' }
foreach ($process in $running) { if (-not $process.WaitForExit(10000)) { throw 'KSwitcher did not stop.' } } foreach ($process in $running) { if (-not $process.WaitForExit(10000)) { throw 'KSwitcher did not stop.' } }
$config = @(Get-Process 'KSwitcher.Config' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq (Join-Path $root 'build\KSwitcher.Config.exe') })
foreach ($process in $config) { if (-not $process.WaitForExit(10000)) { throw 'Close the configurator before updating.' } }
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.Config.exe') -Destination (Join-Path $root 'build\KSwitcher.Config.exe') -Force
Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.exe') -Destination $exe -Force Copy-Item -LiteralPath (Join-Path $root 'build\staged\KSwitcher.exe') -Destination $exe -Force
Start-Process -FilePath $exe -WindowStyle Hidden $eventName = 'Local\KSwitcher.Update.Started.' + [guid]::NewGuid().ToString('N')
$started = [System.Threading.EventWaitHandle]::new($false, [System.Threading.EventResetMode]::ManualReset, $eventName)
try {
$newProcess = Start-Process -FilePath $exe -ArgumentList "--update-ready-event=$eventName" -WorkingDirectory (Split-Path $exe) -WindowStyle Hidden -PassThru
if (-not $started.WaitOne(10000)) { throw 'KSwitcher did not confirm startup within 10 seconds.' }
if ($newProcess.WaitForExit(1000)) { throw "KSwitcher exited after startup (code $($newProcess.ExitCode))." }
} finally { $started.Dispose() }
'OK' | Set-Content -LiteralPath (Join-Path $root 'build\update-result.txt') 'OK' | Set-Content -LiteralPath (Join-Path $root 'build\update-result.txt')
} catch { $_.Exception.Message | Set-Content -LiteralPath (Join-Path $root 'build\update-result.txt'); exit 1 } } catch { $_.Exception.Message | Set-Content -LiteralPath (Join-Path $root 'build\update-result.txt'); exit 1 }