Compare commits

..

6 Commits

106 changed files with 4295 additions and 103 deletions
+31 -7
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.2.2 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.0 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
@@ -8,23 +8,46 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp)
target_include_directories(kscore PUBLIC src PRIVATE vendor)
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)
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/ui.cpp resources/app.rc)
target_link_libraries(kscore PUBLIC user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
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)
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/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
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/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherInputTests PRIVATE kscore)
target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES)
add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp resources/app.rc)
target_link_libraries(KSwitcherScreenshotUITests PRIVATE kscore)
target_compile_definitions(KSwitcherScreenshotUITests PRIVATE KS_SCREENSHOT_RESOURCES)
add_executable(KSwitcherScreenshot WIN32 src/screenshot_main.cpp src/screenshot_notifications.cpp src/screenshot_activation.cpp src/screenshot.cpp resources/app.rc)
set_target_properties(KSwitcherScreenshot PROPERTIES OUTPUT_NAME "KSwitcher.Screenshot")
target_compile_definitions(KSwitcherScreenshot PRIVATE KS_SCREENSHOT_RESOURCES)
target_link_libraries(KSwitcherScreenshot PRIVATE kscore)
if(MSVC)
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)
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
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 KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
target_compile_options(kscore PRIVATE /W4 /utf-8)
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
target_compile_options(KSwitcherScreenshot PRIVATE /W4 /utf-8)
else()
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(KSwitcherScreenshot PRIVATE -municode -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(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
@@ -33,9 +56,10 @@ endif()
enable_testing()
add_test(NAME native-regressions COMMAND KSwitcherTests)
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>")
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
install(TARGETS KSwitcher RUNTIME DESTINATION .)
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME 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")
+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,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
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.
+123 -5
View File
@@ -12,6 +12,29 @@ 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 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.
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. Screen capture and all screenshot editors run on demand in
**KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same
process. Closing the last editor (or cancelling the only selection) exits that process. It does not
load resident settings, dictionaries, clipboard history or keyboard hooks. Editor windows have
stable numbered titles within that process. Keep all three executables together when copying or
updating the application. Close screenshot editors before installing an update.
If settings change in the tray while the configurator is open, a conflicting Save is rejected; reopen
the configurator to load the latest settings.
Updates from v0.3.x–v0.4.1 first install the compatible **v0.4.2 bridge** through
Update in the tray menu. Select Update again to install the full current release;
manual extraction is not required. Future full releases use stable `release-vX.Y.Z`
tags, which the bridge and newer clients recognize. Older clients ignore those tags
and continue to find the bridge instead of skipping it. Versions v0.2.3 and earlier
still require the previously documented initial configurator migration.
Modern ZIPs contain `release-files.json` (schema, version, file paths, sizes and SHA-256
hashes). The updater validates that every packaged file is declared, all hashes match,
and paths stay within the installation directory. `data/` is protected; existing
dictionaries are preserved. Adding a new program file no longer needs a new updater.
In Settings, enable **Start with Windows** and **Start with Windows as administrator**
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;
@@ -43,6 +66,86 @@ 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
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 selected area is copied immediately and Windows shows a notification with a thumbnail.
Click the notification to open the editor, including after the screenshot process has exited.
If notifications are disabled or cannot be sent, the already-copied screenshot opens in the editor.
Originals in the local notification cache are DPAPI-encrypted. Thumbnail PNGs live in a separate
per-user cache under ProgramData, readable only by that user and SYSTEM, so the Windows notification
broker can load them even when the profile directory is inaccessible to it.
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
To annotate: **N** places sequential numbered red markers with a click, and **A** draws
red arrows by dragging from the tail to the tip. Click an existing marker or arrow to select it;
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 keeps the editor open and publishes the image
at its original pixel size, ready to paste. The preview fits the window without changing export size.
The shortcut is configurable in Settings under **Screen capture**.
Each capture opens an independent editor available through Alt+Tab; use the shortcut again to take another screenshot while
keeping earlier editors and their annotations open.
Captures use ordinary clipboard history settings and limits. Protected video and secure desktops cannot be captured.
### Updates
Select **Update** in the tray menu to check the stable Releases of
@@ -62,8 +165,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.
Settings and the other utility windows use the destination monitor's DPI for their initial frame,
controls and fonts. They can be resized, stay within a remaining monitor's work area after display
changes, and provide scrollbars when their content does not fit. Tab navigation scrolls the focused
controls and fonts. Settings has a fixed form size, without manual resizing or maximization; other
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.
## Implemented
@@ -112,7 +216,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
log writes. Disabling it also clears diagnostic context from memory; existing log files remain.
Successful operations stay in a memory buffer of at most 40 diagnostic events for 30 seconds.
Only failures 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.
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,
@@ -121,8 +225,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
busy, empty copy, verification mismatch, unchanged conversion, and cancelled paste. Requests
record the process, window, input version, and repeated attempts within ten seconds without new
input. A second repeated manual command triggers a suspicion, not proof of failure; intentional
layout cycling can also trigger it. An unchanged result containing no letters, or a direct command
input. Successful repeated commands do not flush the log; intentional layout cycling is normal.
An unchanged result containing no letters, or a direct command
to the detected current layout, is treated as expected. `paste-sent-unverified` means Windows
accepted the paste shortcut; it does not prove that the editor applied the replacement.
The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates.
@@ -227,6 +331,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 -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
.\tests\configuration.ps1 # Separate-process settings tests; close KSwitcher first
```
`-Test` also checks remote/own input separation, hidden Settings layout and scrolling, and updater
@@ -255,9 +360,22 @@ It still uses the normal instance lock; close a running KSwitcher first if measu
- `platform.*`: Win32 layouts, clipboard, input, ownership, DPAPI and file primitives.
- `storage.*`: settings and encrypted, disk-backed history.
- `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.
- `ocr.*`: Windows WIC and OCR via the native Windows Runtime ABI.
- `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`.
Dictionary data has its own CC BY-SA 4.0 license as described above.
The screenshot process lifecycle can be checked interactively with `tests/screenshot_process.ps1`
against a staged build. It briefly opens real selection/editor windows without changing the clipboard.
### Release channel rule
Keep `v0.4.2` as the last legacy `vX.Y.Z` release. Publish full packages as
`release-vX.Y.Z` (stable, not prerelease), including `release-files.json`,
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt`. Never publish a manifest package under a
legacy version tag: clients that have not installed the bridge would select it.
Keep the compatibility bridge in the first 100 releases returned by Gitea while
supporting old clients; their immutable update code only reads that page.
Binary file not shown.
+22 -18
View File
@@ -1,31 +1,35 @@
# 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.
- Explicitly compile the UTF-8 Windows resource source as UTF-8, both in the resource file and the portable build command.
- Add regression checks against the compiled executable's version and exact Unicode description.
- Fix Settings spacing around Screen capture, automatic layout correction and layout controls.
- 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.
- 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.
The executable description is `KSwitcher — native keyboard and clipboard utility`.
Verified: 173 native checks, 379 input/rendering checks, screenshot editor UI integration checks,
29 updater checks, including missing Get-FileHash coverage, and package validation.
Download **KSwitcher-win-x64.zip**, extract the entire archive to a writable folder,
and launch `KSwitcher.exe`. Keep `dictionaries/` next to the executable.
The archive contains the application, offline dictionaries, documentation and license notices.
It does not contain personal settings or clipboard history.
`SHA256SUMS.txt` contains the archive checksum.
**Upgrade from v0.3.0 or v0.4.0:** use Update in the tray menu. Both executables are supported.
If an older updater reports missing `Get-FileHash`, restart KSwitcher normally from Explorer
(outside a PowerShell 7 session) or install this ZIP manually once. v0.4.1 removes that dependency.
**Upgrade from v0.2.3 or earlier:** install manually once because those updaters reject the additional configurator EXE.
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;
installation starts only after confirming Update. Older versions need this one manual upgrade:
exit KSwitcher and replace the application files, retaining `data/` and any customized dictionaries.
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains both executables,
offline dictionaries, documentation and license notices; no personal settings or clipboard history.
This folder holds the latest compiled version; older versions remain in Git history and Releases.
To refresh the tracked package from source:
Build and package:
```powershell
.\build.ps1 -Stage -Test
.\package.ps1 -PublishRelease
```
Commit the ZIP and checksum together with their source changes. Publish both files as assets of
one stable release tagged with the executable version, so the updater can verify it.
Commit the archive, checksum and sources together. Publish both assets on a stable release whose
tag matches the executable version.
+1 -1
View File
@@ -1 +1 @@
9705cdb0c0a732eeae10b88e42101f9ff6714399ea8779e154fecc79269059a0 KSwitcher-win-x64.zip
e1d11c833adab5190ec49af1bb4eb25babdd6658aafb7423f9c502a1e71b2581 KSwitcher-win-x64.zip
+23 -5
View File
@@ -13,31 +13,49 @@ New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
Push-Location $root
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')
$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')) {
$libs = @('-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
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
if ($LASTEXITCODE) { throw 'Resource compilation failed' }
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
New-Item -ItemType Directory -Force build/release | Out-Null
& $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_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
if ($LASTEXITCODE) { throw 'Link failed' }
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
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
if ($Test) {
& $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' }
& .\build\KSwitcherTests.exe
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/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input test build failed' }
& .\build\KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input tests failed' }
& $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
& .\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
if ($LASTEXITCODE) { throw 'Updater tests failed' }
}
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/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration test build failed' }
& .\build\KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration tests failed' }
+12 -1
View File
@@ -4,12 +4,23 @@ $root = $PSScriptRoot
$destination = Join-Path $root 'dist\KSwitcher'
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.Config.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Screenshot.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
# 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','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
$entries = foreach ($path in $files) {
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
}
}
$version=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $destination 'KSwitcher.exe')).FileVersion
$manifest=Join-Path $destination 'release-files.json'
@{schema=1; version=$version; files=@($entries)} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifest -Encoding utf8
$files += $manifest
$archive = Join-Path $root 'dist\KSwitcher-win-x64.zip'
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
Get-FileHash -LiteralPath $archive -Algorithm SHA256
+58 -3
View File
@@ -2,11 +2,15 @@
#include <windows.h>
#include "resource.h"
IDI_KSWITCHER ICON "kswitcher.ico"
#ifdef KS_SCREENSHOT_RESOURCES
IDI_SCREENSHOT_QUESTION ICON "icons/question.ico"
IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
#endif
1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1"
1 VERSIONINFO
FILEVERSION 0,2,2,0
PRODUCTVERSION 0,2,2,0
FILEVERSION 0,6,0,0
PRODUCTVERSION 0,6,0,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
@@ -16,7 +20,7 @@ BEGIN
BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.2.2"
VALUE "FileVersion", "0.6.0"
END
END
BLOCK "VarFileInfo"
@@ -24,3 +28,54 @@ BEGIN
VALUE "Translation", 0x409, 1200
END
END
#ifdef KS_SCREENSHOT_RESOURCES
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"
#endif
+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

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<defs>
<linearGradient id="blue" x1="8" y1="4" x2="36" y2="44" gradientUnits="userSpaceOnUse">
<stop stop-color="#38bdf8"/>
<stop offset=".55" stop-color="#1687e8"/>
<stop offset="1" stop-color="#2563eb"/>
</linearGradient>
</defs>
<circle cx="24" cy="25" r="21" fill="#1e40af" opacity=".12"/>
<circle cx="24" cy="23" r="21" fill="url(#blue)"/>
<circle cx="24" cy="23" r="20.5" fill="none" stroke="white" stroke-opacity=".25"/>
<path d="M18 17.5c0-3.1 2.5-5 6-5s6 2 6 5c0 4.8-6 4.8-6 9" fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round"/>
<circle cx="24" cy="33" r="2" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 711 B

+2
View File
@@ -1,2 +1,4 @@
#pragma once
#define IDI_KSWITCHER 101
#define IDI_SCREENSHOT_QUESTION 102
#define IDC_SCREENSHOT_PENCIL 500
+90 -7
View File
@@ -9,8 +9,8 @@ $ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function Get-StableVersion([string]$Tag) {
if ($Tag -notmatch '^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
try { return [version]($Tag -replace '^v','') } catch { return $null }
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 '^(?:release-)?v','') } catch { return $null }
}
function Assert-ReleaseUrl([string]$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$' })
if ($matchesFound.Count -ne 1) { throw 'Missing or ambiguous archive checksum.' }
$expected = ($matchesFound[0] -split '\s+')[0]
if ((Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash -ne $expected) { throw 'Release checksum verification failed.' }
# 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
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
try {
@@ -74,7 +80,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
foreach ($entry in $zip.Entries) {
$name = $entry.FullName.Replace('\','/')
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
(($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($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')) {
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() }
$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)
if ($version.ProductName -ne 'KSwitcher' -or (Get-StableVersion $version.FileVersion) -ne $Expected) {
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)
if ($pe -lt 64 -or $pe -gt $bytes.Length-6 -or [BitConverter]::ToUInt32($bytes,$pe) -ne 0x4550 -or
[BitConverter]::ToUInt16($bytes,$pe+4) -ne 0x8664) { throw 'Release is not a Windows x64 executable.' }
}
}
function 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) {
$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)) {
$relative = $file.FullName.Substring($Stage.Length+1)
$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
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($destination))
if (Test-Path -LiteralPath $destination) {
@@ -165,6 +246,8 @@ try {
Receive-File $release.checksum $checksum 64KB
Assert-Checksum $archive $checksum
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}
$ready.Set() | Out-Null
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
+55 -12
View File
@@ -1,6 +1,8 @@
#include "app.h"
#include "../resources/resource.h"
#include "ocr.h"
#include "configuration.h"
#include "screenshot_notifications.h"
#include <algorithm>
#include <commctrl.h>
#include <set>
@@ -16,7 +18,8 @@ std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
{Case, s.casing},
{Translit, s.translit},
{Undo, s.undo},
{ShowHistory, s.clipboard}};
{ShowHistory, s.clipboard},
{Screenshot, s.screenshot}};
for (size_t i = 0; i < s.direct.size(); ++i)
out.emplace_back(Direct + (int)i, s.direct[i]);
return out;
@@ -398,6 +401,18 @@ void App::reloadLayouts()
}
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)
{
for (auto &[id, text] : shortcuts(settings))
@@ -496,6 +511,10 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
// down AND up events use exactly the same path as physical keyboard input.
if (ownKeyboardInput(key) || installingUpdate)
return false;
DWORD captureProcess{};
if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess);
if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess))
setHotkeyCapture(nullptr);
if (hotkeyCapture && GetForegroundWindow() == GetAncestor(hotkeyCapture, GA_ROOT))
{
bool modifier = key.vkCode == VK_CONTROL || key.vkCode == VK_SHIFT || key.vkCode == VK_MENU ||
@@ -569,6 +588,11 @@ void App::dispatch(int command)
return;
try
{
if (command == Screenshot)
{
if (!busy) { resetInput(); launchScreenshot(*this); }
return;
}
if (command == ShowHistory)
{
showHistory(*this);
@@ -579,6 +603,11 @@ void App::dispatch(int command)
showSettings(*this);
return;
}
if (command == 22)
{
showLogs(*this);
return;
}
if (command == 21)
{
showSnippets(*this);
@@ -619,9 +648,6 @@ void App::transform(int command, int snippet)
L" window=" + std::to_wstring(reinterpret_cast<uintptr_t>(target)) +
L" version=" + std::to_wstring(currentVersion) +
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))
{
@@ -643,8 +669,7 @@ void App::transform(int command, int snippet)
version = inputVersion;
}
enqueue(
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt,
suspicious = manual && repeatAttempts >= 2]
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt]
{
struct BusyReset
{
@@ -679,7 +704,6 @@ void App::transform(int command, int snippet)
uint64_t version, attempt;
ULONGLONG start;
bool enabled;
bool suspicious;
std::wstring &reason;
std::wstring &text, &result, &expected;
int &hint, &to;
@@ -693,10 +717,10 @@ void App::transform(int command, int snippet)
L" source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
L" expected=" + diagnosticText(expected) + L" copied=" + diagnosticText(text) +
L" result=" + diagnosticText(result),
suspicious || (reason != L"paste-sent-unverified" && reason != L"layout-only" &&
reason != L"unchanged-expected"));
reason != L"paste-sent-unverified" && reason != L"layout-only" &&
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
{
auto valid = [&]
@@ -1079,6 +1103,7 @@ void App::trayMenu()
{
HMENU menu = CreatePopupMenu();
AppendMenuW(menu, MF_STRING, 10, L"Clipboard history");
AppendMenuW(menu, MF_STRING, 24, L"Capture screen region…");
AppendMenuW(menu, MF_STRING, 11, L"Settings…");
SetMenuDefaultItem(menu, 11, FALSE);
AppendMenuW(menu, MF_STRING, 12, L"Snippets…");
@@ -1110,6 +1135,9 @@ void App::trayMenu()
{
switch (cmd)
{
case 24:
dispatch(Screenshot);
break;
case 10:
showHistory(*this);
break;
@@ -1134,9 +1162,9 @@ void App::trayMenu()
break;
case 16:
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"account.\nNo .NET, WebView or graphics framework.",
L"account.\nNo .NET, WebView or graphics framework.").c_str(),
L"About KSwitcher", MB_OK);
break;
case 17:
@@ -1170,6 +1198,11 @@ void App::trayMenu()
void App::checkUpdate(bool interactive, bool install, const std::wstring &version)
{
if (updateRunning) return;
if (install && FindWindowW(L"KSwitcher.Config", nullptr))
{
report(L"Close the configurator before installing an update.");
return;
}
if (install && busy)
{
report(L"Wait for the current text operation to finish, then select Update again.");
@@ -1289,6 +1322,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
case WM_COPYDATA:
{
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))
{
int cmd;
@@ -1345,6 +1383,9 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
DestroyWindow(hwnd);
return 0;
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);
return 0;
default:
@@ -1412,6 +1453,7 @@ int App::execute()
confirmUpdatedStartup(updateStartupEvent);
if (startCommand)
PostMessageW(window, WM_HOTKEY, startCommand, 0);
auto screenshotActivation=startScreenshotActivationListener();
MSG msg;
while (GetMessageW(&msg, nullptr, 0, 0) > 0)
{
@@ -1430,6 +1472,7 @@ int App::execute()
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
if(screenshotActivation) CoRevokeClassObject(screenshotActivation);
return (int)msg.wParam;
}
} // namespace ks
+6 -2
View File
@@ -14,7 +14,7 @@ namespace ks
inline constexpr UINT TrayMessage = WM_APP + 1, ErrorMessage = WM_APP + 2, HistoryMessage = WM_APP + 3,
SnippetMessage = WM_APP + 4;
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 CaptureHotkeyMessage = WM_APP + 5;
struct LastTransform
@@ -43,6 +43,7 @@ class App
std::atomic<ULONGLONG> pauseUntil{0};
HWND pasteTarget{};
HWND hotkeyCapture{};
DWORD hotkeyCaptureProcess{};
HWND logWindow{};
std::mutex stateMutex;
Run run;
@@ -66,6 +67,8 @@ class App
bool registerHotkeys(const Settings &, bool rollback = false);
void applySettings(const Settings &);
void reloadLayouts();
void clearHistory();
void setHotkeyCapture(HWND);
bool isHotkey(DWORD, UINT);
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
@@ -96,7 +99,8 @@ class App
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
};
void showHistory(App &);
void showHistory(App &, bool visible = true);
void launchScreenshot(App &);
void refreshHistory(App &);
void showSettings(App &, bool visible = true);
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"};
int maxItems = 500, maxMiB = 200, maxImageMiB = 20;
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> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
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");
bool existing = GetLastError() == ERROR_ALREADY_EXISTS;
int cmd = ks::ShowHistory;
if (arg == L"--convert")
if (arg == L"--capture")
cmd = ks::Screenshot;
else if (arg == L"--convert")
cmd = ks::Toggle;
else if (arg == L"--case")
cmd = ks::Case;
+1580
View File
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include "platform.h"
namespace ks
{
// Only the on-demand screenshot process owns this state; no resident App dependency.
struct ScreenshotHost {
HINSTANCE instance{};
HWND window{};
unsigned windows{};
bool (*notify)(const Clip &){};
};
inline constexpr UINT ScreenshotRequest=WM_APP+71, ScreenshotIdle=WM_APP+72;
void showScreenshot(ScreenshotHost &);
void showScreenshotEditor(ScreenshotHost &,const Clip &);
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
enum class 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);
}
+100
View File
@@ -0,0 +1,100 @@
#include "screenshot_notifications.h"
#include <atomic>
namespace ks {
namespace {
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
constexpr GUID ActivatorId{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}};
}
bool validScreenshotToken(const std::wstring &id)
{
if(id.size()!=36) return false;
for(size_t i=0;i<id.size();++i) {
if(i==8 || i==13 || i==18 || i==23) { if(id[i]!=L'-') return false; }
else if(!((id[i]>=L'0' && id[i]<=L'9') || (id[i]>=L'a' && id[i]<=L'f') || (id[i]>=L'A' && id[i]<=L'F'))) return false;
}
return true;
}
namespace {
// Windows SDK INotificationActivationCallback ABI (notificationactivationcallback.h).
constexpr GUID CallbackId{0x53e31837,0x6600,0x4a81,{0x93,0x95,0x75,0xcf,0xfe,0x74,0x6f,0x94}};
struct NotificationInput { LPCWSTR key,value; };
struct NotificationCallback : IUnknown {
virtual HRESULT STDMETHODCALLTYPE Activate(LPCWSTR,LPCWSTR,const NotificationInput *,ULONG)=0;
};
class Activator final : public NotificationCallback {
std::atomic<ULONG> references{1};
DWORD thread=GetCurrentThreadId();
std::wstring target;
bool quit;
public:
Activator(std::wstring path,bool shouldQuit):target(std::move(path)),quit(shouldQuit) {}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID id,void **out) override {
if(!out) return E_POINTER;
*out=nullptr;
if(id!=IID_IUnknown && id!=CallbackId) return E_NOINTERFACE;
*out=static_cast<NotificationCallback *>(this); AddRef(); return S_OK;
}
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
HRESULT STDMETHODCALLTYPE Activate(LPCWSTR app,LPCWSTR args,const NotificationInput *,ULONG) override {
if(!app || std::wstring(app)!=AppId || !args || !validScreenshotToken(args)) return E_INVALIDARG;
auto path=target; auto command=L"\""+path+L"\" --open \"kswitcher-screenshot:"+args+L"\"";
STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{};
auto ok=CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,nullptr,nullptr,&startup,&process);
auto result=ok?S_OK:HRESULT_FROM_WIN32(GetLastError());
if(ok) {
AllowSetForegroundWindow(process.dwProcessId); ResumeThread(process.hThread);
CloseHandle(process.hThread); CloseHandle(process.hProcess);
}
if(quit) PostThreadMessageW(thread,WM_QUIT,0,0);
return result;
}
};
class ActivatorFactory final : public IClassFactory {
std::atomic<ULONG> references{1};
std::wstring target;
bool quit;
public:
ActivatorFactory(std::wstring path,bool shouldQuit):target(std::move(path)),quit(shouldQuit) {}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID id,void **out) override {
if(!out) return E_POINTER;
*out=nullptr;
if(id!=IID_IUnknown && id!=IID_IClassFactory) return E_NOINTERFACE;
*out=static_cast<IClassFactory *>(this); AddRef(); return S_OK;
}
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
HRESULT STDMETHODCALLTYPE CreateInstance(IUnknown *outer,REFIID id,void **out) override {
if(outer) return CLASS_E_NOAGGREGATION;
auto object=new Activator(target,quit); auto result=object->QueryInterface(id,out); object->Release(); return result;
}
HRESULT STDMETHODCALLTYPE LockServer(BOOL) override { return S_OK; }
};
}
int runScreenshotToastActivator()
{
if(FAILED(CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED))) return 1;
MSG message{}; PeekMessageW(&message,nullptr,0,0,PM_NOREMOVE);
auto factory=new ActivatorFactory(executable(),true); DWORD registration{};
auto result=CoRegisterClassObject(ActivatorId,factory,CLSCTX_LOCAL_SERVER,REGCLS_MULTIPLEUSE,&registration);
factory->Release();
if(SUCCEEDED(result)) {
auto timer=SetTimer(nullptr,0,60000,[](HWND,UINT,UINT_PTR,DWORD){PostQuitMessage(0);});
while(GetMessageW(&message,nullptr,0,0)>0) { TranslateMessage(&message); DispatchMessageW(&message); }
if(timer) KillTimer(nullptr,timer);
CoRevokeClassObject(registration);
}
CoUninitialize(); return FAILED(result)?1:0;
}
DWORD startScreenshotActivationListener()
{
auto target=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
auto factory=new ActivatorFactory(target.wstring(),false);
DWORD registration{};
auto result=CoRegisterClassObject(ActivatorId,factory,CLSCTX_LOCAL_SERVER,REGCLS_MULTIPLEUSE,&registration);
factory->Release();
return SUCCEEDED(result)?registration:0;
}
}
+21
View File
@@ -0,0 +1,21 @@
#include "app.h"
namespace ks {
void launchScreenshot(App &)
{
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
if (!std::filesystem::exists(path))
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
auto command=L"\""+path.wstring()+L"\"";
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
// Grant foreground rights before the child's first selection window opens.
if (!CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,
nullptr,path.parent_path().c_str(),&startup,&process))
throw std::runtime_error(utf8(lastError(L"Cannot start screenshot editor")));
AllowSetForegroundWindow(process.dwProcessId);
ResumeThread(process.hThread);
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
}
}
+109
View File
@@ -0,0 +1,109 @@
#include "screenshot.h"
#include "screenshot_notifications.h"
#include <commctrl.h>
#include <shellapi.h>
#include <roapi.h>
namespace {
constexpr wchar_t ControllerClass[]=L"KSwitcher.Screenshot.Controller";
LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM lp)
{
auto host=(ks::ScreenshotHost *)GetWindowLongPtrW(window,GWLP_USERDATA);
if(message==WM_NCCREATE) {
host=(ks::ScreenshotHost *)((CREATESTRUCTW *)lp)->lpCreateParams;
SetWindowLongPtrW(window,GWLP_USERDATA,(LONG_PTR)host);
}
if(host && message==ks::ScreenshotRequest) {
try { ks::showScreenshot(*host); }
catch(const std::exception &e) {
MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screen capture",MB_OK|MB_ICONERROR);
PostMessageW(window,ks::ScreenshotIdle,0,0);
}
return TRUE;
}
if(host && message==WM_COPYDATA) {
auto data=(COPYDATASTRUCT *)lp;
if(!data || data->dwData!=73 || data->cbData!=37*sizeof(wchar_t) || !data->lpData) return FALSE;
std::wstring token((const wchar_t *)data->lpData,36);
if(!ks::validScreenshotToken(token)) return FALSE;
try { ks::showScreenshotEditor(*host,ks::loadNotifiedScreenshot(token)); }
catch(const std::exception &e) {
MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screenshot",MB_OK|MB_ICONINFORMATION);
PostMessageW(window,ks::ScreenshotIdle,0,0);
}
return TRUE;
}
if(host && message==ks::ScreenshotIdle && !host->windows) {
DestroyWindow(window);
PostQuitMessage(0);
return 0;
}
return DefWindowProcW(window,message,wp,lp);
}
}
int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
{
std::wstring token;
int argc{}; auto argv=CommandLineToArgvW(GetCommandLineW(),&argc);
if(argc>=2 && std::wstring(argv[1])==L"--toast-activated") {
LocalFree(argv); return ks::runScreenshotToastActivator();
}
if(argc==2 && std::wstring(argv[1])==L"--register-notifications") {
LocalFree(argv); CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
int result=0; try { ks::registerScreenshotNotifications(); } catch(...) { result=1; }
CoUninitialize(); return result;
}
if(argc>1) {
if(argc==3 && std::wstring(argv[1])==L"--open") {
std::wstring uri=argv[2]; const std::wstring prefix=L"kswitcher-screenshot:";
if(uri.starts_with(prefix)) token=uri.substr(prefix.size());
}
if(!ks::validScreenshotToken(token)) { LocalFree(argv); return 1; }
}
LocalFree(argv);
COPYDATASTRUCT open{73,DWORD((token.size()+1)*sizeof(wchar_t)),token.data()};
HANDLE mutex=CreateMutexW(nullptr,FALSE,L"Local\\KSwitcher.Screenshot.Singleton");
if(!mutex) return 1;
// Wait through startup/shutdown races; the controller accepts requests only while alive.
bool acquired=false;
for(int attempt=0;attempt<100;++attempt) {
auto wait=WaitForSingleObject(mutex,0);
if(wait==WAIT_OBJECT_0 || wait==WAIT_ABANDONED) { acquired=true; break; }
if(auto existing=FindWindowW(ControllerClass,nullptr)) {
DWORD pid{}; GetWindowThreadProcessId(existing,&pid);
AllowSetForegroundWindow(pid);
DWORD_PTR accepted{};
if(SendMessageTimeoutW(existing,token.empty()?ks::ScreenshotRequest:WM_COPYDATA,0,
token.empty()?0:(LPARAM)&open,SMTO_ABORTIFHUNG,2000,&accepted) && accepted) {
CloseHandle(mutex); return 0;
}
}
Sleep(50);
}
if(!acquired) { CloseHandle(mutex); return 1; }
RoInitialize(RO_INIT_SINGLETHREADED);
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_BAR_CLASSES};
InitCommonControlsEx(&controls);
ks::ScreenshotHost host{instance};
host.notify=ks::notifyScreenshot;
WNDCLASSW cls{}; cls.hInstance=instance; cls.lpfnWndProc=controller; cls.lpszClassName=ControllerClass;
RegisterClassW(&cls);
host.window=CreateWindowW(ControllerClass,L"KSwitcher Screenshot",0,0,0,0,0,nullptr,nullptr,instance,&host);
int result=1;
if(host.window) {
// Toast protocol activation may run unelevated while the editor was launched by an elevated resident.
// WM_COPYDATA accepts only a validated cache GUID, never a path or executable command.
ChangeWindowMessageFilterEx(host.window,WM_COPYDATA,MSGFLT_ALLOW,nullptr);
if(token.empty()) PostMessageW(host.window,ks::ScreenshotRequest,0,0);
else SendMessageW(host.window,WM_COPYDATA,0,(LPARAM)&open);
MSG message{};
while((result=GetMessageW(&message,nullptr,0,0))>0) {
TranslateMessage(&message); DispatchMessageW(&message);
}
result=result<0?1:0;
}
RoUninitialize();
ReleaseMutex(mutex); CloseHandle(mutex);
return result;
}
+220
View File
@@ -0,0 +1,220 @@
#include "screenshot_notifications.h"
#include "ocr.h"
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <aclapi.h>
#include <sddl.h>
#include <shlobj.h>
#include <propkey.h>
#include <propvarutil.h>
#include <roapi.h>
#include <winstring.h>
#include <wincodec.h>
#ifdef __MINGW32__
// This SDK aliases BYTE and boolean, producing duplicate template specializations.
// Notifications use the boolean specialization; IReference<BYTE> is unused here.
#define ____FIReference_1_BYTE_INTERFACE_DEFINED__
#endif
#include <windows.ui.notifications.h>
#include <windows.data.xml.dom.h>
#include <wrl/client.h>
namespace ks {
namespace {
using Microsoft::WRL::ComPtr;
namespace Notifications=ABI::Windows::UI::Notifications;
namespace Xml=ABI::Windows::Data::Xml::Dom;
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
constexpr GUID ActivatorId{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}};
std::string notificationError;
void check(HRESULT result) { if(FAILED(result)) { char message[100]; snprintf(message,sizeof(message),"Windows screenshot notification failed (0x%08lx)",(unsigned long)result); throw std::runtime_error(message); } }
struct String {
HSTRING value{};
explicit String(const std::wstring &s) { check(WindowsCreateString(s.c_str(),(UINT32)s.size(),&value)); }
~String() { WindowsDeleteString(value); }
};
std::filesystem::path cacheDirectory()
{
PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_LocalAppData,0,nullptr,&folder));
auto path=std::filesystem::path(folder)/L"KSwitcher"/L"ScreenshotNotifications";
CoTaskMemFree(folder); std::filesystem::create_directories(path); return path;
}
std::filesystem::path previewDirectory()
{
HANDLE token{};
if(!OpenProcessToken(GetCurrentProcess(),TOKEN_QUERY,&token)) check(HRESULT_FROM_WIN32(GetLastError()));
DWORD size{}; GetTokenInformation(token,TokenUser,nullptr,0,&size);
std::vector<BYTE> data(size);
BOOL read=GetTokenInformation(token,TokenUser,data.data(),size,&size);
DWORD error=read?ERROR_SUCCESS:GetLastError(); CloseHandle(token); check(HRESULT_FROM_WIN32(error));
auto user=reinterpret_cast<TOKEN_USER *>(data.data())->User.Sid;
LPWSTR sidText{}; if(!ConvertSidToStringSidW(user,&sidText)) check(HRESULT_FROM_WIN32(GetLastError()));
std::wstring sid(sidText); LocalFree(sidText);
PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_ProgramData,0,nullptr,&folder));
auto path=std::filesystem::path(folder)/(L"KSwitcher.ScreenshotPreviews-"+sid); CoTaskMemFree(folder);
// The notification broker cannot load previews from some profile directories.
// Keep this cache outside the profile, private to the current user and SYSTEM.
auto sddl=L"O:"+sid+L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;"+sid+L")";
PSECURITY_DESCRIPTOR security{};
if(!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(),SDDL_REVISION_1,&security,nullptr))
check(HRESULT_FROM_WIN32(GetLastError()));
SECURITY_ATTRIBUTES attributes{sizeof(attributes),security,FALSE};
BOOL created=CreateDirectoryW(path.c_str(),&attributes);
error=created?ERROR_SUCCESS:GetLastError();
LocalFree(security);
if(error!=ERROR_ALREADY_EXISTS) check(HRESULT_FROM_WIN32(error));
// Do not use a pre-created directory or junction controlled by another user.
DWORD flags=GetFileAttributesW(path.c_str());
if(flags==INVALID_FILE_ATTRIBUTES || !(flags&FILE_ATTRIBUTE_DIRECTORY) || (flags&FILE_ATTRIBUTE_REPARSE_POINT))
throw std::runtime_error("Invalid screenshot preview directory");
PSID owner{}; PSECURITY_DESCRIPTOR existing{};
error=GetNamedSecurityInfoW(const_cast<wchar_t *>(path.c_str()),SE_FILE_OBJECT,OWNER_SECURITY_INFORMATION,
&owner,nullptr,nullptr,nullptr,&existing);
check(HRESULT_FROM_WIN32(error));
bool owned=EqualSid(owner,user); LocalFree(existing);
if(!owned) throw std::runtime_error("Screenshot preview directory belongs to another user");
return path;
}
void cleanCache(const std::filesystem::path &path)
{
std::vector<std::filesystem::directory_entry> files;
for(const auto &file:std::filesystem::directory_iterator(path))
if(file.is_regular_file() && validScreenshotToken(file.path().stem().wstring()) &&
(file.path().extension()==L".capture" || file.path().extension()==L".png")) files.push_back(file);
std::sort(files.begin(),files.end(),[](const auto &a,const auto &b){return a.last_write_time()>b.last_write_time();});
uintmax_t size=0;
for(size_t i=0;i<files.size();++i) {
size+=files[i].file_size();
if(i>=40 || size>256*1024*1024 || std::filesystem::file_time_type::clock::now()-files[i].last_write_time()>std::chrono::hours(24)) {
std::error_code ignored; std::filesystem::remove(files[i].path(),ignored);
}
}
}
void registryString(const std::wstring &path,const wchar_t *name,const std::wstring &value)
{
HKEY key{};
if(RegCreateKeyExW(HKEY_CURRENT_USER,path.c_str(),0,nullptr,0,KEY_SET_VALUE,nullptr,&key,nullptr)!=ERROR_SUCCESS)
throw std::runtime_error("Cannot register screenshot notifications");
auto result=RegSetValueExW(key,name,0,REG_SZ,(const BYTE *)value.c_str(),DWORD((value.size()+1)*sizeof(wchar_t)));
RegCloseKey(key);
if(result!=ERROR_SUCCESS) throw std::runtime_error("Cannot register screenshot notification activation");
}
void registerNotifications()
{
auto exe=executable();
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",nullptr,L"\""+exe+L"\" --toast-activated");
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",L"ServerExecutable",exe);
auto identity=std::wstring(L"Software\\Classes\\AppUserModelId\\")+AppId;
registryString(identity,L"DisplayName",L"KSwitcher Screenshot");
registryString(identity,L"IconUri",exe);
registryString(identity,L"CustomActivator",L"{e3f78047-f0ad-49b6-9074-411931706167}");
// A shortcut supplies desktop notification identity and its COM activation callback.
ComPtr<IShellLinkW> link; check(CoCreateInstance(CLSID_ShellLink,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&link)));
check(link->SetPath(exe.c_str())); check(link->SetIconLocation(exe.c_str(),0));
ComPtr<IPropertyStore> properties; check(link.As(&properties));
PROPVARIANT id{}; check(InitPropVariantFromString(AppId,&id));
auto result=properties->SetValue(PKEY_AppUserModel_ID,id); PropVariantClear(&id); check(result);
GUID activator=ActivatorId;
PROPVARIANT clsid{}; clsid.vt=VT_CLSID; clsid.puuid=&activator;
check(properties->SetValue(PKEY_AppUserModel_ToastActivatorCLSID,clsid));
check(properties->Commit());
PWSTR programs{}; check(SHGetKnownFolderPath(FOLDERID_Programs,0,nullptr,&programs));
auto shortcut=std::filesystem::path(programs)/L"KSwitcher Screenshot.lnk"; CoTaskMemFree(programs);
ComPtr<IPersistFile> file; check(link.As(&file)); check(file->Save(shortcut.c_str(),TRUE));
// The notification broker caches protocol handlers independently of ShellExecute.
SHChangeNotify(SHCNE_ASSOCCHANGED,SHCNF_IDLIST,nullptr,nullptr);
check(SetCurrentProcessExplicitAppUserModelID(AppId));
}
void savePreview(const Clip &clip,const std::filesystem::path &path)
{
auto pixels=decodeImage(clip,360,220);
for(size_t i=3;i<pixels.bgra.size();i+=4) pixels.bgra[i]=255;
ComPtr<IWICImagingFactory> factory; check(CoCreateInstance(CLSID_WICImagingFactory,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&factory)));
ComPtr<IWICStream> stream; check(factory->CreateStream(&stream)); check(stream->InitializeFromFilename(path.c_str(),GENERIC_WRITE));
ComPtr<IWICBitmapEncoder> encoder; check(factory->CreateEncoder(GUID_ContainerFormatPng,nullptr,&encoder));
check(encoder->Initialize(stream.Get(),WICBitmapEncoderNoCache));
ComPtr<IWICBitmapFrameEncode> frame; check(encoder->CreateNewFrame(&frame,nullptr)); check(frame->Initialize(nullptr));
check(frame->SetSize(pixels.width,pixels.height));
auto format=GUID_WICPixelFormat32bppBGRA; check(frame->SetPixelFormat(&format));
if(format!=GUID_WICPixelFormat32bppBGRA) throw std::runtime_error("Unsupported preview pixel format");
check(frame->WritePixels(pixels.height,pixels.width*4,(UINT)pixels.bgra.size(),pixels.bgra.data()));
check(frame->Commit()); check(encoder->Commit());
}
std::wstring escapeXml(const std::wstring &s)
{
std::wstring result;
for(auto ch:s) { if(ch==L'&') result+=L"&amp;"; else if(ch==L'\"') result+=L"&quot;"; else if(ch==L'<') result+=L"&lt;"; else result+=ch; }
return result;
}
}
Clip loadNotifiedScreenshot(const std::wstring &id)
{
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
auto directory=cacheDirectory(); cleanCache(directory);
auto path=directory/(id+L".capture");
if(!std::filesystem::exists(path)) throw std::runtime_error("This screenshot notification has expired. Take a new screenshot.");
return Clip{{{CF_DIB,L"",unprotect(readFile(path,160*1024*1024))}}};
}
bool notifyScreenshot(const Clip &clip)
{
notificationError.clear();
const char *stage="registration";
try {
registerNotifications();
stage="notification manager";
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
ComPtr<Notifications::IToastNotifier> notifier; String appId(AppId);
stage="notifier";
check(manager->CreateToastNotifierWithId(appId.value,&notifier));
stage="settings";
Notifications::NotificationSetting setting{};
auto settingResult=notifier->get_Setting(&setting);
// A first-time sender has no settings record until its first notification.
if(settingResult!=HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) check(settingResult);
if(setting!=Notifications::NotificationSetting_Enabled) throw std::runtime_error("Screenshot notifications are disabled in Windows settings");
stage="cache";
auto directory=cacheDirectory(); cleanCache(directory);
GUID guid{}; check(CoCreateGuid(&guid)); wchar_t value[40]{}; StringFromGUID2(guid,value,40);
std::wstring id(value+1,36);
auto dib=std::find_if(clip.formats.begin(),clip.formats.end(),[](const auto &format){return format.id==CF_DIB;});
if(dib==clip.formats.end() || dib->data.size()>128*1024*1024) return false;
atomicWrite(directory/(id+L".capture"),protect(dib->data));
stage="preview";
auto previews=previewDirectory(); cleanCache(previews);
auto preview=previews/(id+L".png"); savePreview(clip,preview);
cleanCache(previews);
cleanCache(directory);
stage="XML";
String documentClass(L"Windows.Data.Xml.Dom.XmlDocument"); ComPtr<IInspectable> documentInstance;
check(RoActivateInstance(documentClass.value,&documentInstance));
ComPtr<Xml::IXmlDocument> document; check(documentInstance.As(&document));
ComPtr<Xml::IXmlDocumentIO> documentIO; check(document.As(&documentIO));
auto uri=L"file:///"+preview.generic_wstring();
String content(L"<toast launch=\""+id+L"\"><visual><binding template=\"ToastGeneric\">"
L"<text>Screenshot copied</text><text>Click to edit and annotate</text><image id=\"1\" placement=\"appLogoOverride\" alt=\"Screenshot preview\" src=\""+escapeXml(uri)+L"\"/></binding></visual><audio silent=\"true\"/></toast>");
check(documentIO->LoadXml(content.value));
stage="toast factory";
ComPtr<Notifications::IToastNotificationFactory> toastFactory; String toastClass(L"Windows.UI.Notifications.ToastNotification");
check(RoGetActivationFactory(toastClass.value,IID_PPV_ARGS(&toastFactory)));
ComPtr<Notifications::IToastNotification> toast; check(toastFactory->CreateToastNotification(document.Get(),&toast));
ComPtr<ABI::Windows::Foundation::IPropertyValueStatics> values;
String propertyClass(L"Windows.Foundation.PropertyValue");
check(RoGetActivationFactory(propertyClass.value,IID_PPV_ARGS(&values)));
FILETIME now{}; GetSystemTimeAsFileTime(&now);
ULARGE_INTEGER timestamp{}; timestamp.LowPart=now.dwLowDateTime; timestamp.HighPart=now.dwHighDateTime;
ABI::Windows::Foundation::DateTime expires{(INT64)(timestamp.QuadPart+24ull*60*60*10000000)};
ComPtr<IInspectable> boxed; check(values->CreateDateTime(expires,&boxed));
ComPtr<ABI::Windows::Foundation::IReference<ABI::Windows::Foundation::DateTime>> expiration;
check(boxed.As(&expiration)); check(toast->put_ExpirationTime(expiration.Get()));
stage="show";
check(notifier->Show(toast.Get()));
return true;
} catch(const std::exception &e) { notificationError=std::string(stage)+": "+e.what(); return false; } // Clipboard is already populated; fall back to the editor.
}
std::string screenshotNotificationError() { return notificationError; }
void registerScreenshotNotifications() { registerNotifications(); }
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include "platform.h"
namespace ks {
bool notifyScreenshot(const Clip &);
std::string screenshotNotificationError();
void registerScreenshotNotifications();
int runScreenshotToastActivator();
DWORD startScreenshotActivationListener();
bool validScreenshotToken(const std::wstring &);
Clip loadNotifiedScreenshot(const std::wstring &);
}
+15 -5
View File
@@ -45,7 +45,7 @@ bool validId(const std::wstring &id)
void validateSettings(const Settings &s)
{
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());
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)
{
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;
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.directIds.push_back(layouts[i].id);
}
if (!std::filesystem::exists(path))
if (bytes.empty())
return s;
auto j = parse(readFile(path, 4 * 1024 * 1024));
auto j = parse(bytes);
auto format = j.value("format", std::string());
if (format != "kswitcher-settings" && format != "typlo-settings")
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.undo = str(j, "undoHotkey", s.undo);
s.clipboard = str(j, "clipboardHotkey", s.clipboard);
s.screenshot = str(j, "screenshotHotkey", s.screenshot);
if (j.contains("layouts"))
{
s.cycle.clear();
@@ -177,6 +182,10 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
return s;
}
void saveSettings(const std::filesystem::path &path, const Settings &s)
{
atomicWrite(path, encodeSettings(s));
}
Bytes encodeSettings(const Settings &s)
{
validateSettings(s);
Json j = {{"format", "kswitcher-settings"},
@@ -195,7 +204,8 @@ void saveSettings(const std::filesystem::path &path, const Settings &s)
{"caseHotkey", utf8(s.casing)},
{"translitHotkey", utf8(s.translit)},
{"undoHotkey", utf8(s.undo)},
{"clipboardHotkey", utf8(s.clipboard)}};
{"clipboardHotkey", utf8(s.clipboard)},
{"screenshotHotkey", utf8(s.screenshot)}};
j["layouts"] = Json::array();
for (auto &id : s.cycle)
j["layouts"].push_back(utf8(id));
@@ -221,7 +231,7 @@ void saveSettings(const std::filesystem::path &path, const Settings &s)
j["excludedApps"] = Json::array();
for (auto &name : s.excluded)
j["excludedApps"].push_back(utf8(name));
atomicWrite(path, jsonBytes(j));
return jsonBytes(j);
}
Bytes encodeClip(const Clip &clip)
{
+2
View File
@@ -4,6 +4,8 @@
namespace ks
{
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 validateSettings(const Settings &);
struct Entry
+64 -33
View File
@@ -1,5 +1,6 @@
#include "../resources/resource.h"
#include "app.h"
#include "configuration.h"
#include "ocr.h"
#include "richtext.h"
#include "ui_geometry.h"
@@ -30,7 +31,7 @@ HFONT font(HWND w)
struct WindowLayout
{
int width{}, height{}, x{}, y{};
bool scrolling{}, arranging{};
bool scrolling{}, arranging{}, history{};
int wheelRemainder{}, horizontalWheelRemainder{};
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
};
@@ -67,9 +68,18 @@ void arrangeWindow(HWND w)
layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE);
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
}
RECT client{}; GetClientRect(w, &client);
int extraWidth = layout->history ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
int extraHeight = layout->history ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
for (const auto &[child, r] : layout->controls)
SetWindowPos(child, nullptr, scale(w, r.left) - layout->x, scale(w, r.top) - layout->y,
scale(w, r.right - r.left), scale(w, r.bottom - r.top), SWP_NOACTIVATE | SWP_NOZORDER);
{
int id = GetDlgCtrlID(child);
SetWindowPos(child, nullptr, scale(w, r.left) - layout->x,
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0),
scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0),
scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0),
SWP_NOACTIVATE | SWP_NOZORDER);
}
layout->arranging = false;
InvalidateRect(w, nullptr, TRUE);
}
@@ -185,13 +195,13 @@ LRESULT CALLBACK hotkeyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
auto *app = (App *)data;
if (msg == WM_SETFOCUS)
{
app->hotkeyCapture = w;
app->setHotkeyCapture(w);
SendMessageW(w, EM_SETSEL, 0, -1);
}
if (msg == WM_KILLFOCUS || msg == WM_NCDESTROY)
{
if (app->hotkeyCapture == w)
app->hotkeyCapture = nullptr;
app->setHotkeyCapture(nullptr);
}
if (msg == CaptureHotkeyMessage)
{
@@ -218,13 +228,13 @@ LRESULT CALLBACK hotkeyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DW
return 0;
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);
SendMessageW(field, EM_SETCUEBANNER, FALSE, (LPARAM)L"Click, then press a shortcut");
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);
}
@@ -241,11 +251,11 @@ void value(HWND w, int id, const std::wstring &text)
{
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;
}
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);
}
@@ -254,7 +264,7 @@ void error(HWND w, const std::exception &e)
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,
DWORD extra = 0)
DWORD extra = 0, bool resizable = true)
{
WNDCLASSW wc{};
wc.hInstance = app.instance;
@@ -266,7 +276,8 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
RegisterClassW(&wc);
UINT dpi = GetDpiForSystem();
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);
POINT cursor{};
GetCursorPos(&cursor);
@@ -352,7 +363,7 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
fitToMonitor(w, *(RECT *)lp);
arrangeWindow(w);
}
std::filesystem::path pickFile(HWND owner, bool save)
[[maybe_unused]] std::filesystem::path pickFile(HWND owner, bool save)
{
wchar_t path[32768]{};
OPENFILENAMEW ofn{sizeof(ofn)};
@@ -372,6 +383,7 @@ void disposeFont(HWND w)
DeleteObject(h);
}
#ifndef KS_CONFIG_APP
struct SearchResult
{
uint64_t generation;
@@ -755,6 +767,8 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
return DefWindowProcW(w, msg, wp, lp);
}
#endif
#ifndef KS_RESIDENT_APP
struct SettingsView
{
App *app{};
@@ -818,6 +832,7 @@ void fillSettings(HWND w, SettingsView *v)
value(w, 212, s.translit);
value(w, 213, s.undo);
value(w, 214, s.clipboard);
value(w, 215, s.screenshot);
value(w, 240, std::to_wstring(s.maxItems));
value(w, 241, std::to_wstring(s.maxMiB));
value(w, 242, std::to_wstring(s.maxImageMiB));
@@ -867,6 +882,7 @@ Settings collectSettings(HWND w, SettingsView *v)
s.translit = value(w, 212);
s.undo = value(w, 213);
s.clipboard = value(w, 214);
s.screenshot = value(w, 215);
s.maxItems = integer(w, 240);
s.maxMiB = integer(w, 241);
s.maxImageMiB = integer(w, 242);
@@ -1146,11 +1162,11 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
fillSettings(w, v);
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",
MB_YESNO | MB_ICONWARNING) == IDYES)
v->app->history->clear();
v->app->clearHistory();
return 0;
}
break;
@@ -1294,6 +1310,8 @@ LRESULT CALLBACK snippetProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
return DefWindowProcW(w, msg, wp, lp);
}
#endif
#ifndef KS_CONFIG_APP
LRESULT CALLBACK entryProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
if (msg == WM_SIZE)
@@ -1305,7 +1323,9 @@ LRESULT CALLBACK entryProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
disposeFont(w);
return DefWindowProcW(w, msg, wp, lp);
}
#endif
} // namespace
#ifndef KS_CONFIG_APP
void refreshHistory(App &app)
{
if (app.historyWindow)
@@ -1314,7 +1334,7 @@ void refreshHistory(App &app)
search(app.historyWindow, v);
}
}
void showHistory(App &app)
void showHistory(App &app, bool visible)
{
if (!app.history)
{
@@ -1322,7 +1342,7 @@ void showHistory(App &app)
return;
}
HWND foreground = GetForegroundWindow();
if (app.historyWindow && IsWindowVisible(app.historyWindow) && foreground == app.historyWindow)
if (visible && app.historyWindow && IsWindowVisible(app.historyWindow) && foreground == app.historyWindow)
{
auto *v = (HistoryView *)GetWindowLongPtrW(app.historyWindow, GWLP_USERDATA);
if (v->items.size() > 1)
@@ -1335,6 +1355,7 @@ void showHistory(App &app)
{
HWND w = makeWindow(app, L"KSwitcher.History", L"Clipboard history", historyProc, 760, 500);
app.historyWindow = w;
windowLayoutState(w)->history = true;
((HistoryView *)GetWindowLongPtrW(w, GWLP_USERDATA))->rich = createRichText(w, 105, false);
label(w, L"Search clipboard history", 16, 12, 500);
edit(w, 101, 16, 38, 728);
@@ -1351,6 +1372,7 @@ void showHistory(App &app)
SetWindowSubclass(list, historyKey, 1, (DWORD_PTR)w);
SetWindowSubclass(GetDlgItem(w, 101), historyKey, 1, (DWORD_PTR)w);
}
if (!visible) return;
POINT cursor;
GetCursorPos(&cursor);
MONITORINFO monitor{sizeof(monitor)};
@@ -1498,7 +1520,7 @@ void refreshLogView(HWND w, App &app)
InvalidateRect(list, nullptr, TRUE);
if (row)
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.");
}
LRESULT CALLBACK logProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
@@ -1609,6 +1631,8 @@ void showLogs(App &app)
showRequestedWindow(w);
activateWindow(w);
}
#endif
#ifndef KS_RESIDENT_APP
void showSettings(App &app, bool visible)
{
if (app.settingsWindow)
@@ -1620,11 +1644,11 @@ void showSettings(App &app, bool visible)
activateWindow(target);
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;
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"Switch Windows input layout after conversion", 202, 18, 70, 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, 390);
checkbox(w, L"Record clipboard history", 203, 18, 106);
checkbox(w, L"Start with Windows", 204, 18, 133);
checkbox(w, L"Start with Windows as administrator", 206, 18, 160, 395);
@@ -1632,23 +1656,23 @@ void showSettings(App &app, bool visible)
checkbox(w, L"Write log", 208, 240, 184, 160);
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",
L"Clipboard history"};
for (int i = 0; i < 5; ++i)
L"Clipboard history", L"Screen capture"};
for (int i = 0; i < 6; ++i)
{
label(w, names[i], 420, 48 + i * 30, 115);
hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220);
}
checkbox(w, L"Automatically correct layout (experimental)", 207, 420, 195, 350);
label(w, L"Layout cycle — top to bottom", 18, 210, 400);
checkbox(w, L"Automatically correct layout (experimental)", 207, 18, 210, 395);
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,
18, 232, 470, 190);
button(w, L"Move up", 232, 504, 232, 120);
button(w, L"Move down", 233, 632, 232, 128);
checkbox(w, L"Include in cycle", 230, 504, 275, 240);
label(w, L"Direct layout shortcut", 504, 313, 240);
hotkeyEdit(app, w, 231, 504, 340, 256);
label(w, L"Backspace / Delete clears a shortcut", 504, 365, 256);
button(w, L"Refresh layouts", 253, 504, 384, 150);
18, 262, 470, 172);
button(w, L"Move up", 232, 504, 262, 120);
button(w, L"Move down", 233, 632, 262, 128);
checkbox(w, L"Include in cycle", 230, 504, 300, 240);
label(w, L"Direct layout shortcut", 504, 332, 240);
hotkeyEdit(app, w, 231, 504, 356, 256);
label(w, L"Backspace / Delete clears a shortcut", 504, 382, 256);
button(w, L"Refresh layouts", 253, 504, 408, 150);
label(w, L"History limits", 18, 444);
label(w, L"Items", 18, 478, 50);
edit(w, 240, 70, 474, 90);
@@ -1699,11 +1723,17 @@ void showSnippets(App &app)
showRequestedWindow(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)
{
auto &s = app.settings;
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)
if (!s.direct[i].empty())
text += L"\n" + app.layouts[i].name + L": " + s.direct[i];
@@ -1736,4 +1766,5 @@ void showEntry(App &app, const std::wstring &id)
error(app.historyWindow, e);
}
}
#endif
} // namespace ks
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.2.2";
inline constexpr wchar_t AppVersion[] = L"0.6.0";
inline constexpr UINT UpdateMessage = WM_APP + 7;
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
}
+425
View File
@@ -1,7 +1,9 @@
#include "app.h"
#include "ui_geometry.h"
#include "screenshot.h"
#include <commctrl.h>
#include <iostream>
#include <cmath>
namespace ks {
struct InputRegressionAccess {
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.
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");
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) {
KBDLLHOOKSTRUCT k{}; k.vkCode=vk; k.scanCode=scan;
k.flags=LLKHF_INJECTED|LLKHF_LOWER_IL_INJECTED;
@@ -79,8 +485,27 @@ int main(int argc, char **argv) {
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
ks::showSettings(app,false);
if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
ks::showHistory(app,false);
auto history=app.historyWindow;
require(history&&!IsWindowVisible(history),"history layout tested without changing focus");
auto childRect=[&](int id) { RECT r{}; GetWindowRect(GetDlgItem(history,id),&r); MapWindowPoints(nullptr,history,(POINT*)&r,2); return r; };
RECT historyFrame{}; GetWindowRect(history,&historyFrame);
auto listBefore=childRect(102),searchBefore=childRect(101),buttonBefore=childRect(110);
SetWindowPos(history,nullptr,0,0,historyFrame.right-historyFrame.left+120,historyFrame.bottom-historyFrame.top+180,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
auto listAfter=childRect(102),searchAfter=childRect(101),buttonAfter=childRect(110);
require(listAfter.right-listBefore.right==120 && listAfter.bottom-listBefore.bottom==180,"history list fills added width and height");
require(searchAfter.right-searchBefore.right==120 && searchAfter.top==searchBefore.top,"history search grows horizontally only");
require(buttonAfter.top-buttonBefore.top==180 && buttonAfter.bottom-listAfter.bottom==buttonBefore.bottom-listBefore.bottom,"history actions stay below list after resize");
SetWindowPos(history,nullptr,0,0,historyFrame.right-historyFrame.left,historyFrame.bottom-historyFrame.top,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
require(EqualRect(&listBefore,&(listAfter=childRect(102))),"history resize roundtrip restores geometry");
auto settings=app.settingsWindow;
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{};
GetWindowRect(settings,&frame);
MONITORINFO monitor{sizeof(monitor)};
+73
View File
@@ -0,0 +1,73 @@
param([string]$Executable = "$PSScriptRoot\..\build\staged\KSwitcher.Screenshot.exe")
$ErrorActionPreference='Stop'
if (Get-Process KSwitcher.Screenshot -ErrorAction SilentlyContinue) { throw 'Close existing screenshot editors before this test.' }
Add-Type @'
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
public static class ScreenshotProcessTest {
public delegate bool EnumProc(IntPtr w, IntPtr p);
[DllImport("user32.dll")] static extern bool EnumWindows(EnumProc callback, IntPtr param);
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr w, out uint pid);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr w, StringBuilder name, int count);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetWindowText(IntPtr w, StringBuilder name, int count);
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w, uint msg, IntPtr wp, IntPtr lp);
public static IntPtr[] Windows(int process, string cls) {
var windows=new List<IntPtr>();
EnumWindows((w,p)=>{ uint pid; GetWindowThreadProcessId(w,out pid);
var name=new StringBuilder(256); GetClassName(w,name,256);
if(pid==process && name.ToString()==cls) windows.Add(w); return true;
},IntPtr.Zero); return windows.ToArray();
}
public static string Title(IntPtr w) { var s=new StringBuilder(256); GetWindowText(w,s,256); return s.ToString(); }
}
'@
function Wait-For([scriptblock]$Check,[string]$Message) {
for($attempt=0;$attempt -lt 100;$attempt++) { if (& $Check) { return }; Start-Sleep -Milliseconds 50 }
throw $Message
}
function Send($Window,$Message,$Wparam=0,$Lparam=0) {
[ScreenshotProcessTest]::PostMessage($Window,$Message,[IntPtr]$Wparam,[IntPtr]$Lparam) | Out-Null
}
function Capture-Windows { @([ScreenshotProcessTest]::Windows($script:process.Id,'KSwitcher.Capture')) }
function Select-Region($Window) {
Send $Window 0x201 1 ((40 -shl 16) -bor 40)
Send $Window 0x200 1 ((240 -shl 16) -bor 360)
Send $Window 0x202 0 ((240 -shl 16) -bor 360)
Wait-For { (Capture-Windows) -notcontains $Window } 'Selection did not close after copying (notifications may be disabled).'
}
function Close-Editor($Window) {
Send $Window 0x10
Wait-For { (Capture-Windows) -notcontains $Window } 'Unedited copied screenshot did not close.'
}
function Latest-Token {
(Get-ChildItem -LiteralPath (Join-Path $env:LOCALAPPDATA 'KSwitcher/ScreenshotNotifications') -Filter '*.capture' | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1).BaseName
}
$process=$null
try {
$process=Start-Process -FilePath $Executable -WindowStyle Hidden -PassThru
Wait-For { (Capture-Windows).Count -eq 1 } 'First selection did not open.'
Select-Region (Capture-Windows)[0]
if (!$process.WaitForExit(5000)) { throw 'Process remained alive after notification.' }
$token=Latest-Token
$process=Start-Process -FilePath $Executable -ArgumentList @('--open',"kswitcher-screenshot:$token") -WindowStyle Hidden -PassThru
Wait-For { (Capture-Windows).Count -eq 1 } 'Notification activation did not open an editor.'
$first=(Capture-Windows)[0]
$secondLaunch=Start-Process -FilePath $Executable -ArgumentList @('--open',"kswitcher-screenshot:$token") -WindowStyle Hidden -PassThru
if (!$secondLaunch.WaitForExit(10000) -or $secondLaunch.ExitCode -ne 0) { throw 'Second activation did not hand off.' }
Wait-For { (Capture-Windows).Count -eq 2 } 'Second editor did not open in same process.'
$second=(Capture-Windows | Where-Object { $_ -ne $first })[0]
if ([ScreenshotProcessTest]::Title($first) -eq [ScreenshotProcessTest]::Title($second)) { throw 'Editor indices collide.' }
Close-Editor $first
if ($process.HasExited) { throw 'Process exited with an editor open.' }
Close-Editor $second
if (!$process.WaitForExit(5000) -or $process.ExitCode -ne 0) { throw 'Last editor did not exit process.' }
$process=Start-Process -FilePath $Executable -WindowStyle Hidden -PassThru
Wait-For { (Capture-Windows).Count -eq 1 } 'New process did not open selection.'
Send (Capture-Windows)[0] 0x100 27 # Esc cancels a selection only.
if (!$process.WaitForExit(5000) -or $process.ExitCode -ne 0) { throw 'Cancelling selection did not exit process.' }
'Passed screenshot process lifecycle checks'
} finally {
if ($process -and !$process.HasExited) { Stop-Process -Id $process.Id }
}

Some files were not shown because too many files have changed in this diff Show More