Compare commits

..

10 Commits

102 changed files with 5103 additions and 177 deletions
+27 -6
View File
@@ -1,36 +1,57 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.1.0 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.4.2 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
set(CMAKE_CXX_STANDARD 20)
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)
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)
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
target_link_libraries(KSwitcher PRIVATE kscore)
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/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/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherInputTests PRIVATE kscore)
add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp src/app.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherScreenshotUITests PRIVATE kscore)
if(MSVC)
target_compile_options(KSwitcherScreenshotUITests PRIVATE /utf-8)
set_property(TARGET KSwitcherScreenshotUITests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
else()
target_link_options(KSwitcherScreenshotUITests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
add_executable(KSwitcherOcrTests tests/ocr.cpp)
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
if(MSVC)
set_property(TARGET kscore KSwitcher KSwitcherTests KSwitcherIntegration KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
target_compile_options(kscore PRIVATE /W4 /utf-8)
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
target_compile_options(KSwitcherConfig 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(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++)
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
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 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.
+173 -14
View File
@@ -1,8 +1,10 @@
# KSwitcher
Native C++20 / Win32 rewrite of the Windows functionality of Typlo. Windows 10/11 x64.
Native C++20 / Win32 rewrite of the Windows functionality of [Typlo](https://github.com/bitcodepro/Typlo). Windows 10/11 x64.
No .NET, Avalonia, WebView, Qt, external graphics engine, or separately installed C++ runtime.
The original Typlo project and its data remain separate.
The original [Typlo](https://github.com/bitcodepro/Typlo) project and its data remain separate.
**[User guide / Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki)** — setup, features, shortcuts, dictionaries, screenshots and troubleshooting (Ukrainian).
## Run
@@ -10,6 +12,16 @@ Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the e
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
The 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. Keep both executables together when copying or updating the application.
If settings change in the tray while the configurator is open, a conflicting Save is rejected; reopen
the configurator to load the latest settings.
The first upgrade from v0.2.3 or earlier to this two-executable build requires manual extraction:
the older updater rejects additional executable files. Exit KSwitcher, replace both executables,
and retain `data/` and customized dictionaries. Subsequent updates support both executables.
In Settings, enable **Start with Windows** and **Start with Windows as administrator**
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;
@@ -17,7 +29,7 @@ the task runs in the signed-in user's interactive session, including on battery
Disabling the administrator option restores ordinary per-user startup; disabling startup stops
future automatic launches. Saving unrelated settings does not recreate startup entries.
`KSwitcher.exe --settings` opens Settings, including when the app is already running.
Exit Typlo when using overlapping shortcuts to avoid competing keyboard hooks.
Exit [Typlo](https://github.com/bitcodepro/Typlo) when using overlapping shortcuts to avoid competing keyboard hooks.
### Change a shortcut
@@ -34,6 +46,109 @@ combinations are intercepted. Unassigned shortcuts and simulated copy/paste pass
Choose another shortcut or exit KSwitcher to restore the original action. Windows protects
Ctrl+Alt+Delete and Win+L; these cannot be assigned.
Keyboard and mouse input forwarded by Microsoft Mouse Without Borders follows the same input
path as a local keyboard/mouse, including shortcuts, recent-word conversion and snippets.
KSwitcher marks its own generated input so copy/paste, selection, modifier releases and shell-mask
keys cannot trigger recursive commands or overwrite the recorded typing run. Other injected input
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 editor opens with the selected area. Press **Ctrl+C** or **Enter** to copy it immediately,
or annotate first: **N** places sequential numbered red markers with a click, and **A** draws
red arrows by dragging from the tail to the tip. Click an existing marker or arrow to select it;
drag its body to move it. Drag the square handle on a number's right edge to change its diameter
(the text scales with it), or either endpoint handle of an arrow to change its length and direction.
Select an arrow and drag the extra handle beside its midpoint perpendicular to the line to change
its thickness from 1 to 30 pixels. The arrowhead scales with the line. Minus/plus keys also work.
Alternatively, scroll the mouse wheel over an arrow to change its thickness, or over a number to
change its diameter. Wheel up increases the size; wheel down decreases it. These edits support Undo
and update linked arrows. **Ctrl+D** clones the selected object with a small offset. A cloned number
gets the smallest available positive number, just like a newly placed number: gaps left by deletion
are filled first, without renumbering existing objects. A cloned arrow keeps its style but starts without endpoint links.
The compact toolbar uses [Lucide](https://lucide.dev) icons with hover tooltips showing each action
and its keyboard shortcut.
Drop either arrow endpoint onto a number to link it to the circle's edge. Linked endpoints follow
the number when it moves or changes diameter. Drag an endpoint away to unlink it; moving the whole
arrow unlinks both ends. In Arrow mode, hold **Ctrl** while dragging to start a new arrow directly
from a number. Deleting a number leaves its arrows in place and removes their links; Undo restores them.
**Delete** removes the selected object. **Ctrl+Z** (Undo) reverses additions, moves, resizes and deletions
(up to 100 actions). **Ctrl+Y** or **Ctrl+Shift+Z** (Redo) restores undone edits; a new edit clears the
redo history. Undo and Redo buttons are disabled when unavailable. Enabled toolbar buttons use a
hand cursor. Selection handles are visible only in the editor, never in the copied image.
The **palette** changes the selected object's color: arrow, numbered marker, or highlight.
It also sets the color for newly drawn objects of every type. With nothing selected, only the
color for new objects changes. Number text switches between dark and white for readability.
**Highlight (H)** paints a translucent freehand marker stroke. Dark text remains readable beneath
the highlight. Drag a selected stroke
to move it, scroll over it to change width (4–100 pixels), or use Delete / Ctrl+D / Undo / Redo.
Hold Ctrl while drawing to paint a new stroke over an existing object. Highlights render beneath
arrows and numbered markers. The palette's color indicator reflects the selected object, or the
color for new objects when nothing is selected.
The editor status bar shows contextual tool, selection, rotation and text-editing hints.
Hover a toolbar button to see its action and shortcut; the title stays **KSwitcher — Screenshot**.
**Text (T)** adds text directly on the image. Enter inserts a line break; Ctrl+Enter or a click
outside commits the edit, and Escape cancels it. Double-click text or press F2 to edit it again.
The **Aa** icon opens the standard font dialog for family, size (6–200 pt), bold and italic.
The mouse wheel over text adjusts its font size. Text supports color, dragging, Ctrl+D,
Delete and Undo/Redo. Arrow keys move the selected object by one source-image pixel;
selecting an arrow caption moves only that caption. Repositioning a caption preserves its
automatic angle; only dragging the rotation handle switches to a fixed angle.
Text has a rotation handle above its selection. Drag to rotate; hold Shift to snap to 15-degree steps.
Double-click an arrow, press F2, or use the label icon to add/edit its caption. Captions follow
the arrow when moved or resized; drag the caption to adjust its offset. By default, the caption
angle follows the line (or middle curve segment) and stays upright. Rotating its handle manually
fixes its angle; Shift snaps to 15-degree steps. Undo restores automatic mode. The Aa icon changes
the selected caption font, and the mouse wheel over it changes its size. Captions and rotation
are included in clipboard export, cloning and Undo/Redo.
**Straight line (L)** creates a line without arrowheads. **Rectangle (R)**, **Circle (O)** and
**Ellipse (E)** create transparent shapes with colored outlines. Drag the two corner handles to
resize; circles keep equal width and height. Move shapes by their outlines, adjust thickness with
the mouse wheel or side handle, recolor, clone with Ctrl+D, and undo/redo as with other objects.
Hold Ctrl to start drawing over an existing object. Line style applies to outlines; Ends is disabled
for shapes.
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
move it, change its color or thickness, clone it, or drag its endpoints. Hold Ctrl to start a new
stroke over an existing object. The **Line style** selector supports Solid, Dotted, Dashed and
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
The window close button asks for confirmation before discarding an uncopied screenshot.
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy closes the editor and publishes the image
at its original pixel size, ready to paste. The preview fits the window without changing export size.
The shortcut is configurable in Settings under **Screen capture**.
Each capture opens an independent editor; use the shortcut again to take another screenshot while
keeping earlier editors and their annotations open.
Captures use ordinary clipboard history settings and limits. Protected video and secure desktops cannot be captured.
### Updates
Select **Update** in the tray menu to check the stable Releases of
[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases).
A startup check also reports an available version, but never installs it automatically.
Confirm the offered version to download and install it. The updater uses the existing
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
before the application exits. If replacement or startup fails, it restores the previous files;
if recovery itself fails, it reports the retained backup directory.
Saved settings and clipboard history in `data/` or the local-app-data fallback are preserved.
Existing dictionary files are preserved too; missing dictionary files are installed. Save settings
edits before updating. The installation directory must be writable at KSwitcher's current privilege
level. A blocked network request, incomplete release or checksum mismatch leaves the running version
intact and is reported when Update is selected. Startup network failures remain non-interactive.
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. 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
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
@@ -45,7 +160,7 @@ Ctrl+Alt+Delete and Win+L; these cannot be assigned.
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
`{{cursor}}` inserts the literal marker. Snippets are stored as plaintext settings, as in Typlo.
`{{cursor}}` inserts the literal marker. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
- Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste;
full-text search, pinning, deletion, full text viewing and image previews.
- Image thumbnails and text/file icons appear directly in the history list. Only visible rows
@@ -65,7 +180,7 @@ Ctrl+Alt+Delete and Win+L; these cannot be assigned.
- Single instance and `--history`, `--convert`, `--case`, `--transliterate`, `--settings`,
`--snippets`, `--exit` commands. `--convert` cycles layouts.
Default shortcuts match Typlo: Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
Default shortcuts match [Typlo](https://github.com/bitcodepro/Typlo): Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
Z (undo), 1/2/3 (first layouts), and Ctrl+Shift+V (history).
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
@@ -77,23 +192,43 @@ Pressing the history shortcut a second time pastes the second-most-recent item.
Data is in `data/` beside the executable, falling back to `%LOCALAPPDATA%/KSwitcher` when
the executable's directory is not writable. Each clipboard payload is an encrypted disk file;
only bounded previews/metadata stay in memory. Image data is loaded on demand and released when
the history popup is hidden. The log contains operational messages, never copied text or keystrokes.
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 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,
escaped UTF-16 (`\uXXXX` for non-ASCII and control characters) for exact reproduction, with
original length and a truncation flag; each field is limited to 512 UTF-16 code units.
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. 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.
Open **View log…** from Settings or the tray menu to read logs inside KSwitcher.
Choose the current or previous log and press **Refresh** to reload. The ListView shows Time,
Attempt, Event, and Details columns. Select rows and use **Copy selected** or Ctrl+C to copy
tab-separated entries; Ctrl+A selects all rows. Viewing saved logs also works when logging is disabled.
**Clear log** clears only the selected current or previous file and refreshes the table.
Clearing the current log also discards buffered diagnostic context; new problems can still be logged.
Settings **Import** accepts a Typlo `settings.json`, preserving supported Windows layout IDs,
hotkeys, snippets, exclusions and limits. Import is staged until Save. Existing Typlo files are
never modified. Typlo's encrypted SQLite history is **not migrated** to the new DPAPI file format.
Settings **Import** accepts a [Typlo](https://github.com/bitcodepro/Typlo) `settings.json`, preserving supported Windows layout IDs,
hotkeys, snippets, exclusions and limits. Import is staged until Save. Existing [Typlo](https://github.com/bitcodepro/Typlo) files are
never modified. [Typlo](https://github.com/bitcodepro/Typlo)'s encrypted SQLite history is **not migrated** to the new DPAPI file format.
DPAPI data belongs to the Windows account/machine; copying the portable folder is not a cross-account backup.
## Differences still to port
This is a working native implementation, not a claim of complete UI/release parity:
- UI currently uses English and the native Windows control appearance. Typlo's ten-language UI and
- UI currently uses English and the native Windows control appearance. [Typlo](https://github.com/bitcodepro/Typlo)'s ten-language UI and
explicit light/dark theme selector are not yet ported.
- Automatic release download/replacement is not enabled. KSwitcher has no configured release feed;
using Typlo's feed would install the wrong application. Updates are manual.
- No import of old encrypted clipboard history. Export/import applies to settings.
- The clipboard popup is fixed-size with per-monitor DPI scaling; PNG/DIB previews use Windows WIC/GDI.
- The clipboard popup uses per-monitor DPI scaling and scrolls when space is limited; PNG/DIB previews use Windows WIC/GDI.
The Windows focus/injection rules still apply: an unelevated process cannot edit an elevated app.
Conversion is cancelled rather than pasting into a different window or replacing unverified recent text.
@@ -175,8 +310,21 @@ 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
package validation, file preservation and rollback in temporary fixture directories. It does not
replace a running installation. Optional read-only network checks:
```powershell
.\build\KSwitcherInputTests.exe --update-check
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\tests\update.ps1 -Network
```
These validate the live release metadata/download/checksum in a temporary directory. They do not
prove hardware interoperability between two laptops or a physical monitor-disconnection sequence.
The interactive test requires installed English-US and Russian layouts. It opens its own editor,
tests real copy/paste, undo, repeat conversion, recent-input verification and snippet cursor placement,
then restores the clipboard. Test data is stored in its own temporary directory. It never sends messages
@@ -191,9 +339,20 @@ It still uses the normal instance lock; close a running KSwitcher first if measu
- `platform.*`: Win32 layouts, clipboard, input, ownership, DPAPI and file primitives.
- `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 attribution in `LICENSE`.
Application code is MIT licensed; preserves the original [Typlo](https://github.com/bitcodepro/Typlo) attribution in `LICENSE`.
Dictionary data has its own CC BY-SA 4.0 license as described above.
## Compatibility update bridge (v0.4.2)
This two-executable release can be installed by the v0.3.x–v0.4.1 updater.
It upgrades the updater to discover stable `release-vX.Y.Z` tags and validate
manifest-based packages. Use Update again after this version starts to install
the full current release, including the standalone screenshot editor.
The screenshot editor remains embedded only in this transitional release.
Future full releases must use the `release-v` prefix so unmigrated clients
continue to discover v0.4.2 first rather than skip this bridge.
Binary file not shown.
+24 -10
View File
@@ -1,21 +1,35 @@
# Latest Windows release
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.
## v0.4.1 — 2026-10-28
For upgrades, exit KSwitcher first. Back up any edited dictionaries before replacing files.
Keep your existing `data/` directory to retain settings and clipboard history.
`SHA256SUMS.txt` contains the checksum of the archive.
- 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.
This folder holds only the latest compiled version; older versions remain in Git history.
Verified: 173 native checks, 379 input/rendering checks, screenshot editor UI integration checks,
29 updater checks, including missing Get-FileHash coverage, and package validation.
To refresh it from source:
**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.
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.
Build and package:
```powershell
.\build.ps1 -Stage -Test
.\package.ps1 -PublishRelease
```
Commit the updated ZIP and checksum together with the corresponding source changes.
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 @@
906de70d92045cc6ff50802841cc357c6b47536fa0cbbb63301d779b33179469 KSwitcher-win-x64.zip
7f0ee55eb5b56a6227431c1951ac2751208d3d0c7c34e4d1d616e180c37b1d9f KSwitcher-win-x64.zip
+23 -5
View File
@@ -14,24 +14,42 @@ 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','app','ui','main')) {
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','app','ui','main','configuration','config_main')) {
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
}
& windres resources/app.rc -O coff -o build/app-res.o
& $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' }
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/app.o build/ui.o build/main.o build/app-res.o @libs -o $executablePath
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
if ($LASTEXITCODE) { throw 'Link failed' }
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
if ($LASTEXITCODE) { throw 'Configurator link failed' }
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
if ($Test) {
& $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 @libs -static -s -o build/KSwitcherTests.exe
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
if ($LASTEXITCODE) { throw 'Test build failed' }
& .\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/screenshot.o build/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input test build failed' }
& .\build\KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input tests failed' }
& $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
& .\build\KSwitcherScreenshotUITests.exe
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
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/app.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherIntegration.exe
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration test build failed' }
& .\build\KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration tests failed' }
+2 -1
View File
@@ -4,12 +4,13 @@ $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 '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','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
$archive = Join-Path $root 'dist\KSwitcher-win-x64.zip'
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
Get-FileHash -LiteralPath $archive -Algorithm SHA256
+54 -3
View File
@@ -1,10 +1,13 @@
#pragma code_page(65001)
#include <windows.h>
#include "resource.h"
IDI_KSWITCHER ICON "kswitcher.ico"
IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1"
1 VERSIONINFO
FILEVERSION 0,1,0,0
PRODUCTVERSION 0,1,0,0
FILEVERSION 0,4,2,0
PRODUCTVERSION 0,4,2,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
@@ -14,7 +17,7 @@ BEGIN
BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.1.0"
VALUE "FileVersion", "0.4.2"
END
END
BLOCK "VarFileInfo"
@@ -22,3 +25,51 @@ BEGIN
VALUE "Translation", 0x409, 1200
END
END
300 ICON "icons/lucide/circle-dot-normal.ico"
310 ICON "icons/lucide/circle-dot-active.ico"
320 ICON "icons/lucide/circle-dot-disabled.ico"
301 ICON "icons/lucide/arrow-up-right-normal.ico"
311 ICON "icons/lucide/arrow-up-right-active.ico"
321 ICON "icons/lucide/arrow-up-right-disabled.ico"
302 ICON "icons/lucide/undo-2-normal.ico"
312 ICON "icons/lucide/undo-2-active.ico"
322 ICON "icons/lucide/undo-2-disabled.ico"
303 ICON "icons/lucide/copy-normal.ico"
313 ICON "icons/lucide/copy-active.ico"
323 ICON "icons/lucide/copy-disabled.ico"
304 ICON "icons/lucide/x-normal.ico"
314 ICON "icons/lucide/x-active.ico"
324 ICON "icons/lucide/x-disabled.ico"
305 ICON "icons/lucide/redo-2-normal.ico"
315 ICON "icons/lucide/redo-2-active.ico"
325 ICON "icons/lucide/redo-2-disabled.ico"
306 ICON "icons/lucide/highlighter-normal.ico"
316 ICON "icons/lucide/highlighter-active.ico"
326 ICON "icons/lucide/highlighter-disabled.ico"
307 ICON "icons/lucide/palette-normal.ico"
317 ICON "icons/lucide/palette-active.ico"
327 ICON "icons/lucide/palette-disabled.ico"
308 ICON "icons/lucide/pencil-normal.ico"
318 ICON "icons/lucide/pencil-active.ico"
328 ICON "icons/lucide/pencil-disabled.ico"
400 ICON "icons/lucide/slash-normal.ico"
410 ICON "icons/lucide/slash-active.ico"
420 ICON "icons/lucide/slash-disabled.ico"
401 ICON "icons/lucide/square-normal.ico"
411 ICON "icons/lucide/square-active.ico"
421 ICON "icons/lucide/square-disabled.ico"
402 ICON "icons/lucide/circle-normal.ico"
412 ICON "icons/lucide/circle-active.ico"
422 ICON "icons/lucide/circle-disabled.ico"
403 ICON "icons/lucide/ellipse-normal.ico"
413 ICON "icons/lucide/ellipse-active.ico"
423 ICON "icons/lucide/ellipse-disabled.ico"
404 ICON "icons/lucide/type-normal.ico"
414 ICON "icons/lucide/type-active.ico"
424 ICON "icons/lucide/type-disabled.ico"
405 ICON "icons/lucide/a-large-small-normal.ico"
415 ICON "icons/lucide/a-large-small-active.ico"
425 ICON "icons/lucide/a-large-small-disabled.ico"
+43
View File
@@ -0,0 +1,43 @@
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5
View File
@@ -0,0 +1,5 @@
Lucide https://github.com/lucide-icons/lucide
Commit: be09619c1c1124ad09c19fc3e87ee3b939409bbd
SVG originals retained. ICO variants generated with tools/generate-toolbar-icons.py.
Ellipse is adapted from the pinned Lucide circle SVG (r replaced with rx/ry).
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

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

After

Width:  |  Height:  |  Size: 391 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

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

After

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

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

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

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

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

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

After

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

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

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

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

After

Width:  |  Height:  |  Size: 317 B

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

After

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

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

After

Width:  |  Height:  |  Size: 571 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

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

After

Width:  |  Height:  |  Size: 377 B

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

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

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

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

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

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

After

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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

After

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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

After

Width:  |  Height:  |  Size: 260 B

+1
View File
@@ -1,2 +1,3 @@
#pragma once
#define IDI_KSWITCHER 101
#define IDC_SCREENSHOT_PENCIL 500
+294
View File
@@ -0,0 +1,294 @@
param(
[ValidateSet('Check','Install')][string]$Mode = 'Check',
[string]$CurrentVersion, [string]$ExpectedVersion,
[string]$InstallDirectory, [string]$WorkDirectory,
[int]$ParentProcessId, [string]$ReadyEvent,
[switch]$Library
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function Get-StableVersion([string]$Tag) {
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
try { return [version]($Tag -replace '^(?:release-)?v','') } catch { return $null }
}
function Assert-ReleaseUrl([string]$Url) {
$uri = [uri]$Url
if (!$uri.IsAbsoluteUri -or $uri.Scheme -ne 'https' -or $uri.Host -ne 'gitea.hrynco.com' -or
!$uri.IsDefaultPort -or $uri.UserInfo -or $uri.Query -or $uri.Fragment -or
!$uri.AbsolutePath.StartsWith('/hrynco/hrynco-kswitcher/releases/download/',[StringComparison]::Ordinal)) {
throw 'Release asset URL is outside the KSwitcher repository.'
}
}
function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$request = [Net.HttpWebRequest]::Create($Url)
$request.AllowAutoRedirect = $false
$request.Timeout = 15000
$request.ReadWriteTimeout = 15000
$request.UserAgent = 'KSwitcher-Updater'
$response = $null; $inputStream = $null; $outputStream = $null
try {
$response = $request.GetResponse()
if ([int]$response.StatusCode -ne 200 -or $response.ContentLength -gt $Limit) { throw 'Invalid or oversized server response.' }
$inputStream = $response.GetResponseStream()
$outputStream = [IO.File]::Create($Path)
$buffer = New-Object byte[] 65536
$total = 0L
$deadline = [DateTime]::UtcNow.AddSeconds(60)
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
$total += $count
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
$outputStream.Write($buffer,0,$count)
}
} finally {
if ($outputStream) { $outputStream.Dispose() }
if ($inputStream) { $inputStream.Dispose() }
if ($response) { $response.Dispose() }
}
}
function Select-Release($Releases,[version]$Current) {
$candidates = @($Releases | Where-Object { !$_.draft -and !$_.prerelease -and (Get-StableVersion $_.tag_name) } |
Sort-Object { Get-StableVersion $_.tag_name } -Descending)
if (!$candidates.Count -or (Get-StableVersion $candidates[0].tag_name) -le $Current) { return $null }
$release = $candidates[0]
$zip = @($release.assets | Where-Object name -CEQ 'KSwitcher-win-x64.zip')
$sum = @($release.assets | Where-Object name -CEQ 'SHA256SUMS.txt')
if ($zip.Count -ne 1 -or $sum.Count -ne 1) { throw 'The newest release lacks its x64 ZIP or SHA256SUMS.txt.' }
Assert-ReleaseUrl $zip[0].browser_download_url
Assert-ReleaseUrl $sum[0].browser_download_url
return @{ version=$release.tag_name; zip=$zip[0].browser_download_url; checksum=$sum[0].browser_download_url }
}
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]
# 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-LegacyRelease([string]$Archive,[string]$Destination,[version]$Expected) {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
try {
$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$total = 0L
foreach ($entry in $zip.Entries) {
$name = $entry.FullName.Replace('\','/')
if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue }
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"
}
$total += $entry.Length
if ($total -gt 100MB -or $zip.Entries.Count -gt 100) { throw 'Release expands beyond allowed limits.' }
$destinationFile = Join-Path $Destination $name
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destinationFile)) | Out-Null
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry,$destinationFile,$false)
}
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() }
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.'
}
$bytes = [IO.File]::ReadAllBytes($exe)
if ($bytes.Length -lt 64 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) { throw 'Invalid release executable.' }
$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
while ($item) {
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Update directory must not contain junctions or symbolic links.' }
$item = $item.Parent
}
}
function Install-Files([string]$Stage,[string]$Target,[string]$Backup,[Collections.Generic.List[string]]$Changed) {
Assert-PlainDirectory $Target
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\',[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) {
if ((Get-Item -LiteralPath $destination -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Cannot replace a linked file.' }
$backupFile = Join-Path $Backup $relative
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($backupFile)) | Out-Null
Move-Item -LiteralPath $destination -Destination $backupFile
}
$Changed.Add($relative)
Move-Item -LiteralPath $file.FullName -Destination $destination
}
}
function Undo-Files([string]$Target,[string]$Backup,[Collections.Generic.List[string]]$Changed) {
for ($i=$Changed.Count-1; $i -ge 0; --$i) {
$destination = Join-Path $Target $Changed[$i]
if (Test-Path -LiteralPath $destination) { Remove-Item -LiteralPath $destination -Force }
$original = Join-Path $Backup $Changed[$i]
if (Test-Path -LiteralPath $original) { Move-Item -LiteralPath $original -Destination $destination }
}
}
function Write-Result($Value) {
$Value | ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $WorkDirectory 'result.json') -Encoding UTF8
}
if ($Library) { return }
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
$changed = [Collections.Generic.List[string]]::new()
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null
try {
$current = Get-StableVersion $CurrentVersion
if (!$current) { throw 'Invalid installed version.' }
if ($Mode -eq 'Install') {
$parentProcess = Get-Process -Id $ParentProcessId
if ([IO.Path]::GetFullPath($parentProcess.Path) -ne [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.exe'))) {
throw 'The running executable does not match the update target.'
}
$ready = [Threading.EventWaitHandle]::OpenExisting($ReadyEvent)
}
$metadata = Join-Path $WorkDirectory 'releases.json'
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' $metadata 2MB
$release = Select-Release (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json) $current
if (!$release) { Write-Result @{state='current'}; exit 0 }
if ($Mode -eq 'Check') { Write-Result @{state='available';version=$release.version}; exit 0 }
if ($ExpectedVersion -ne $release.version) { throw 'Release changed since checking. Please select Update again.' }
Assert-PlainDirectory $InstallDirectory
$transaction = Join-Path $InstallDirectory ('.kswitcher-update-'+[guid]::NewGuid().ToString('N'))
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
[IO.Directory]::CreateDirectory($stage) | Out-Null
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt'
Receive-File $release.zip $archive 50MB
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.' }
$parentExited = $true
Install-Files $stage $InstallDirectory $backup $changed
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
$success = $true
} catch {
$message = $_.Exception.Message
if ($parentExited) {
try {
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
Undo-Files $InstallDirectory $backup $changed
Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -WorkingDirectory $InstallDirectory -WindowStyle Hidden
$message += "`nThe previous version has been restored and restarted."
} catch {
$preserveBackup = $true
$message += "`nRecovery needs attention: $($_.Exception.Message)`nBackup: $backup"
}
}
Write-Result @{state='error';error=$message}
if ($ready -and $ready.WaitOne(0)) {
Add-Type -AssemblyName System.Windows.Forms
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
}
exit 1
} finally {
if ($ready) { $ready.Dispose() }
if ($started) { $started.Dispose() }
if ($parentProcess) { $parentProcess.Dispose() }
# Only remove the uniquely created transaction directory directly under the install directory.
if ($transaction -and !$preserveBackup -and [IO.Path]::GetDirectoryName($transaction) -eq [IO.Path]::GetFullPath($InstallDirectory).TrimEnd('\') -and (Test-Path -LiteralPath $transaction)) {
Remove-Item -LiteralPath $transaction -Recurse -Force -ErrorAction SilentlyContinue
}
if ($success) {
# The script is loaded in memory; its unique temporary working directory is no longer needed.
if ((Split-Path -Leaf $WorkDirectory) -like 'KSwitcher-update-*' -and [IO.Path]::GetDirectoryName($WorkDirectory) -eq [IO.Path]::GetTempPath().TrimEnd('\')) {
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
+316 -74
View File
@@ -1,6 +1,7 @@
#include "app.h"
#include "../resources/resource.h"
#include "ocr.h"
#include "configuration.h"
#include <algorithm>
#include <commctrl.h>
#include <set>
@@ -16,7 +17,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;
@@ -125,7 +127,10 @@ class Transaction
write(textClip(L"\x2063KSwitcher-" + std::to_wstring(GetTickCount64())));
DWORD before = expected;
if (!valid() || !chord(L'C', MOD_CONTROL))
{
diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked");
return {};
}
ULONGLONG deadline = GetTickCount64() + 2000;
DWORD changed = before;
ULONGLONG stable = 0;
@@ -146,7 +151,11 @@ class Transaction
Sleep(10);
}
if (changed == before || !valid())
{
diagnosticEvent(changed == before ? L"Copy failed: clipboard timeout (2000 ms)" :
L"Copy failed: context changed");
return {};
}
if ((GetClipboardOwner() ? !ownsClipboard(target) : changedAgain) ||
changed != GetClipboardSequenceNumber())
throw std::runtime_error("Operation cancelled: copy did not belong to the target application");
@@ -159,13 +168,19 @@ class Transaction
{
if (f.name == L"VisualStudioEditorOperationsLineCutCopyClipboardTag" ||
f.name == L"MSDEVLineSelect")
{
diagnosticEvent(L"Copy rejected: editor copied line without selection");
return {};
}
if (f.name == L"vscode-editor-data")
{
std::string json(f.data.begin(), f.data.end());
if (json.find("\"isFromEmptySelection\":true") != json.npos ||
json.find("\"isFromEmptySelection\": true") != json.npos)
{
diagnosticEvent(L"Copy rejected: editor reported empty selection");
return {};
}
}
}
return text;
@@ -188,7 +203,7 @@ class Transaction
auto text = copy();
if (valid() && matches(text))
{
log(L"Selection: verified word boundary");
diagnosticEvent(L"Selection: verified word boundary");
return text;
}
if (!valid() || !chord(VK_RIGHT))
@@ -230,6 +245,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
settings = loadSettings(data / L"missing-defaults.json", layouts);
registrationWarning = L"Settings were not loaded: " + wide(e.what());
}
setLoggingEnabled(settings.writeLog);
try
{
history = std::make_unique<History>(data / L"history");
@@ -242,6 +258,8 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
}
App::~App()
{
if (updateWorker.joinable())
updateWorker.join();
if (keyboardHook)
UnhookWindowsHookEx(keyboardHook);
if (mouseHook)
@@ -349,6 +367,7 @@ void App::applySettings(const Settings &s)
{
std::lock_guard lock(stateMutex);
settings = s;
setLoggingEnabled(s.writeLog);
run.reset();
last.reset();
++inputVersion;
@@ -381,6 +400,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))
@@ -398,23 +429,15 @@ void App::resetInput()
run.reset();
last.reset();
}
void App::keyboard(const KBDLLHOOKSTRUCT &k)
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
{
if (k.flags & LLKHF_INJECTED)
if (ownKeyboardInput(k))
return;
DWORD vk = k.vkCode;
if (vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LCONTROL || vk == VK_RCONTROL || vk == VK_LMENU ||
if (vk == VK_SHIFT || vk == VK_CONTROL || vk == VK_MENU ||
vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LCONTROL || vk == VK_RCONTROL || vk == VK_LMENU ||
vk == VK_RMENU || vk == VK_LWIN || vk == VK_RWIN)
return;
UINT mods = 0;
if (GetAsyncKeyState(VK_CONTROL) & 0x8000)
mods |= MOD_CONTROL;
if (GetAsyncKeyState(VK_MENU) & 0x8000)
mods |= MOD_ALT;
if (GetAsyncKeyState(VK_SHIFT) & 0x8000)
mods |= MOD_SHIFT;
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000)
mods |= MOD_WIN;
std::lock_guard lock(stateMutex);
if (isHotkey(vk, mods))
return;
@@ -433,7 +456,11 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k)
return;
}
bool shift = (mods & MOD_SHIFT) != 0;
wchar_t ch = vk == VK_SPACE ? L' ' : physicalKey(k.scanCode, shift);
// VK_PACKET carries UTF-16 in scanCode, not a physical key position.
DWORD scan = vk == VK_PACKET ? 0 : k.scanCode;
if (!scan && vk != VK_PACKET)
scan = MapVirtualKeyExW(vk, MAPVK_VK_TO_VSC_EX, layout);
wchar_t ch = vk == VK_SPACE ? L' ' : physicalKey(scan, shift);
if (ch && letter(ch) && (GetKeyState(VK_CAPITAL) & 1))
ch = shift ? lower(std::wstring(1, ch))[0] : upper(std::wstring(1, ch))[0];
if (!ch)
@@ -477,87 +504,94 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k)
}
}
}
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
{
if (code == HC_ACTION && (((KBDLLHOOKSTRUCT *)lp)->flags & LLKHF_INJECTED) &&
((KBDLLHOOKSTRUCT *)lp)->vkCode == 0xE8)
return CallNextHookEx(nullptr, code, wp, lp); // Our shell-mask key is never recorded.
if (code == HC_ACTION && current && current->hotkeyCapture &&
GetForegroundWindow() == GetAncestor(current->hotkeyCapture, GA_ROOT))
// Filter before capture, hotkey latching and typing history. External injected
// 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))
{
auto &key = *(KBDLLHOOKSTRUCT *)lp;
bool modifier = key.vkCode == VK_LCONTROL || key.vkCode == VK_RCONTROL || key.vkCode == VK_LSHIFT ||
bool modifier = key.vkCode == VK_CONTROL || key.vkCode == VK_SHIFT || key.vkCode == VK_MENU ||
key.vkCode == VK_LCONTROL || key.vkCode == VK_RCONTROL || key.vkCode == VK_LSHIFT ||
key.vkCode == VK_RSHIFT || key.vkCode == VK_LMENU || key.vkCode == VK_RMENU ||
key.vkCode == VK_LWIN || key.vkCode == VK_RWIN;
if (!modifier)
{
if (wp == WM_KEYUP || wp == WM_SYSKEYUP)
current->hotkeyHook.update(key.vkCode, false, 0, {});
if (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN)
if (!down)
hotkeyHook.update(key.vkCode, false, 0, {});
if (down)
{
UINT mods = 0;
if (GetAsyncKeyState(VK_CONTROL) & 0x8000)
mods |= MOD_CONTROL;
if (GetAsyncKeyState(VK_MENU) & 0x8000)
mods |= MOD_ALT;
if (GetAsyncKeyState(VK_SHIFT) & 0x8000)
mods |= MOD_SHIFT;
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000)
mods |= MOD_WIN;
if (mods & MOD_WIN)
chord(0xE8);
PostMessageW(current->hotkeyCapture, CaptureHotkeyMessage, key.vkCode, mods);
PostMessageW(hotkeyCapture, CaptureHotkeyMessage, key.vkCode, mods);
}
return 1; // Record the chord without executing a global shortcut or typing into an editor.
}
}
if (code == HC_ACTION && (((KBDLLHOOKSTRUCT *)lp)->flags & LLKHF_INJECTED))
return CallNextHookEx(nullptr, code, wp, lp);
if (code == HC_ACTION && (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN) && current)
if (down)
{
auto &key = *(KBDLLHOOKSTRUCT *)lp;
UINT mods = 0;
if (GetAsyncKeyState(VK_CONTROL) & 0x8000)
mods |= MOD_CONTROL;
if (GetAsyncKeyState(VK_MENU) & 0x8000)
mods |= MOD_ALT;
if (GetAsyncKeyState(VK_SHIFT) & 0x8000)
mods |= MOD_SHIFT;
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000)
mods |= MOD_WIN;
auto action = current->hotkeyHook.update(key.vkCode, true, mods, current->hookBindings);
auto action = hotkeyHook.update(key.vkCode, true, mods, hookBindings);
if (action > 0)
{
// Tell the shell that Win participated in a chord, preventing Start
// from opening on Win-up after we consumed V. 0xE8 is unassigned.
if (mods & (MOD_WIN | MOD_ALT))
chord(0xE8);
PostMessageW(current->window, WM_HOTKEY, action, 0);
PostMessageW(window, WM_HOTKEY, action, 0);
}
if (action != 0)
return 1;
current->keyboard(*(KBDLLHOOKSTRUCT *)lp);
keyboard(key, mods);
}
else if (hotkeyHook.update(key.vkCode, false, 0, hookBindings) != 0)
return true;
return false;
}
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
{
if (code == HC_ACTION && current &&
(wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN || wp == WM_KEYUP || wp == WM_SYSKEYUP))
{
// The current event has not updated asynchronous state yet; the preceding
// modifier events (including MWB input) have. Own events must still reach Windows.
UINT mods = 0;
if (GetAsyncKeyState(VK_CONTROL) & 0x8000) mods |= MOD_CONTROL;
if (GetAsyncKeyState(VK_MENU) & 0x8000) mods |= MOD_ALT;
if (GetAsyncKeyState(VK_SHIFT) & 0x8000) mods |= MOD_SHIFT;
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000) mods |= MOD_WIN;
if (current->handleKeyboard(*(KBDLLHOOKSTRUCT *)lp,
wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN, mods))
return 1;
}
if (code == HC_ACTION && (wp == WM_KEYUP || wp == WM_SYSKEYUP) && current &&
current->hotkeyHook.update(((KBDLLHOOKSTRUCT *)lp)->vkCode, false, 0, current->hookBindings) != 0)
return 1;
return CallNextHookEx(nullptr, code, wp, lp);
}
LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
{
if (code == HC_ACTION && current)
{
auto *e = (MSLLHOOKSTRUCT *)lp;
if (!(e->flags & LLMHF_INJECTED) &&
(wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL))
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
// range just like local clicks, including while a transaction is pending.
if (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL)
current->resetInput();
}
return CallNextHookEx(nullptr, code, wp, lp);
}
void App::dispatch(int command)
{
if (installingUpdate)
return;
try
{
if (command == Screenshot)
{
if (!busy) showScreenshot(*this);
return;
}
if (command == ShowHistory)
{
showHistory(*this);
@@ -568,6 +602,11 @@ void App::dispatch(int command)
showSettings(*this);
return;
}
if (command == 22)
{
showLogs(*this);
return;
}
if (command == 21)
{
showSnippets(*this);
@@ -587,9 +626,34 @@ void App::dispatch(int command)
}
void App::transform(int command, int snippet)
{
if (busy.exchange(true))
return;
if (installingUpdate) return;
const auto attempt = ++transformAttempt;
const auto requestedAt = GetTickCount64();
HWND target = GetForegroundWindow();
const bool manual = command != AutoCorrect && snippet < 0;
if (manual)
{
const auto currentVersion = inputVersion.load();
repeatAttempts = target == previousAttemptWindow && command == previousAttemptCommand &&
currentVersion == previousAttemptVersion &&
requestedAt - previousAttemptAt < 10000
? repeatAttempts + 1 : 1;
previousAttemptWindow = target;
previousAttemptCommand = command;
previousAttemptVersion = currentVersion;
previousAttemptAt = requestedAt;
diagnosticEvent(L"Transform request id=" + std::to_wstring(attempt) + L" command=" +
std::to_wstring(command) + L" process=" + processName(target) +
L" window=" + std::to_wstring(reinterpret_cast<uintptr_t>(target)) +
L" version=" + std::to_wstring(currentVersion) +
L" repeat=" + std::to_wstring(repeatAttempts));
}
if (busy.exchange(true))
{
if (manual)
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true);
return;
}
Run recent;
std::optional<LastTransform> previous;
Settings config;
@@ -604,7 +668,7 @@ void App::transform(int command, int snippet)
version = inputVersion;
}
enqueue(
[this, target, recent, previous, config, available, version, command, snippet]
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt]
{
struct BusyReset
{
@@ -615,6 +679,47 @@ void App::transform(int command, int snippet)
}
} reset{busy};
auto start = GetTickCount64();
std::wstring reason = L"cancelled-before-selection";
std::wstring text, result, expected;
int hint = -1, to = command >= Direct ? command - Direct : -1;
if (manual)
{
std::wstring layoutIds;
for (const auto &layout : available)
{
layoutIds += layout.id + L",";
if (layout.handle == recent.layout)
for (wchar_t key : recent.keys)
expected += key == L' ' ? L' ' : layout.at(physicalLayout().code(key));
}
diagnosticEvent(L"Transform context id=" + std::to_wstring(attempt) +
L" layouts=" + layoutIds + L" physicalKeys=" + diagnosticText(recent.keys) +
L" trackedExpected=" + diagnosticText(expected));
}
struct Audit
{
App &app;
HWND target;
uint64_t version, attempt;
ULONGLONG start;
bool enabled;
std::wstring &reason;
std::wstring &text, &result, &expected;
int &hint, &to;
~Audit()
{
if (enabled)
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=" + reason +
L" foregroundSame=" + std::to_wstring(GetForegroundWindow() == target) +
L" inputSame=" + std::to_wstring(app.inputVersion.load() == version) +
L" elapsedMs=" + std::to_wstring(GetTickCount64() - start) +
L" source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
L" expected=" + diagnosticText(expected) + L" copied=" + diagnosticText(text) +
L" result=" + diagnosticText(result),
reason != L"paste-sent-unverified" && reason != L"layout-only" &&
reason != L"unchanged-expected");
}
} audit{*this, target, version, attempt, requestedAt, manual, reason, text, result, expected, hint, to};
try
{
auto valid = [&]
@@ -627,16 +732,16 @@ void App::transform(int command, int snippet)
releaseModifiers();
if (!valid())
return;
int hint = -1;
for (int i = 0; i < (int)available.size(); ++i)
if (available[i].handle == windowLayout(target))
hint = i;
int to = command >= Direct ? command - Direct : -1;
reason = L"invalid-target-layout";
if (to >= (int)available.size())
return;
if (to >= 0 && !available[to].convertible)
{
switchWindowLayout(target, available[to].handle, valid);
reason = switchWindowLayout(target, available[to].handle, valid)
? L"layout-only" : L"layout-switch-failed";
return;
}
if (command == AutoCorrect)
@@ -654,12 +759,16 @@ void App::transform(int command, int snippet)
!autoCorrectionAllowed(target, config) || !valid())
return;
}
reason = L"clipboard-snapshot";
Transaction tx(*this, target, version);
log(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
reason = L"selection-unavailable";
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
L" recent=" + std::to_wstring(config.recent) + L" keys=" +
std::to_wstring(recent.keys.size()) + L" sameWindow=" +
std::to_wstring(recent.window == target));
std::wstring text, result;
std::to_wstring(recent.window == target) + L" overflow=" +
std::to_wstring(recent.overflow) + L" ageMs=" +
std::to_wstring(recent.at ? start - recent.at : 0) +
L" id=" + std::to_wstring(attempt));
bool automatic = false;
if (command == AutoCorrect)
{
@@ -692,6 +801,9 @@ void App::transform(int command, int snippet)
}
else if (command == Undo)
{
if (previous)
expected = previous->result;
reason = L"undo-unavailable";
if (!previous || previous->window != target || previous->version != version ||
GetTickCount64() - previous->at > 120000)
return;
@@ -700,6 +812,7 @@ void App::transform(int command, int snippet)
{ return newlines(candidate) == newlines(previous->result); });
if (newlines(text) != newlines(previous->result))
{
reason = L"undo-verification-mismatch";
if (tx.valid())
chord(VK_RIGHT);
return;
@@ -738,10 +851,11 @@ void App::transform(int command, int snippet)
tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate)
{ return recent.match(candidate, available, physicalLayout()) >= 0; });
hint = recent.match(text, available, physicalLayout());
log(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" +
diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" +
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
if (hint < 0)
{
reason = text.empty() ? L"recent-copy-empty" : L"recent-verification-mismatch";
if (tx.valid())
chord(VK_RIGHT);
return;
@@ -751,11 +865,13 @@ void App::transform(int command, int snippet)
else if (config.recent && previous && previous->window == target &&
previous->version == version)
{
expected = previous->result;
text = tx.copyRange(caretSteps(previous->result), previous->result,
[&](const std::wstring &candidate)
{ return newlines(candidate) == newlines(previous->result); });
if (newlines(text) != newlines(previous->result))
{
reason = L"previous-result-verification-mismatch";
if (tx.valid())
chord(VK_RIGHT);
return;
@@ -765,10 +881,12 @@ void App::transform(int command, int snippet)
}
else
text = tx.copy();
reason = L"context-changed-after-copy";
if (!tx.valid())
return;
if (text.empty())
{
reason = L"copy-empty";
if (to >= 0 && config.switchLayout)
switchWindowLayout(target, available[to].handle, valid);
return;
@@ -790,6 +908,7 @@ void App::transform(int command, int snippet)
cycle.push_back(i);
if (cycle.size() < 2)
{
reason = L"cycle-has-fewer-than-two-layouts";
if (automatic && tx.valid())
chord(VK_RIGHT);
return;
@@ -804,12 +923,20 @@ void App::transform(int command, int snippet)
}
}
bool sent = false;
log(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
diagnosticEvent(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
L" changed=" + std::to_wstring(result != text));
if (result != text)
sent = tx.paste(textClip(result));
else if (automatic && tx.valid())
chord(VK_RIGHT);
reason = result == text ? L"conversion-unchanged" :
sent ? L"paste-sent-unverified" : L"paste-cancelled";
if (result == text &&
(std::none_of(text.begin(), text.end(), letter) ||
(command >= Direct && detect(text, available, hint) == to)))
reason = L"unchanged-expected";
if (sent && !tx.valid())
reason = L"context-changed-after-paste";
if (sent && command == Undo && previous && previous->autoCorrected)
autoCorrector.reject(previous->original, config);
if (tx.valid() && (sent || automatic))
@@ -835,18 +962,22 @@ void App::transform(int command, int snippet)
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
{
bool switched = switchWindowLayout(target, available[to].handle, valid);
log(L"Layout switch confirmed=" + std::to_wstring(switched));
diagnosticEvent(L"Layout switch confirmed=" + std::to_wstring(switched));
if (!switched)
reason += L";layout-switch-failed";
}
log(L"Text operation completed in " + std::to_wstring(GetTickCount64() - start) + L" ms");
diagnosticEvent(L"Text operation completed in " + std::to_wstring(GetTickCount64() - start) + L" ms");
}
catch (const std::exception &e)
{
reason = L"exception:" + wide(e.what());
report(wide(e.what()));
}
});
}
void App::pasteEntry(std::wstring id, bool plain)
{
if (installingUpdate) return;
if (!history || busy.exchange(true))
return;
HWND target = pasteTarget;
@@ -971,6 +1102,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…");
@@ -984,6 +1116,10 @@ void App::trayMenu()
AppendMenuW(pause, MF_STRING, 23, L"Until restart");
AppendMenuW(menu, MF_POPUP, (UINT_PTR)pause, L"Pause clipboard history");
AppendMenuW(menu, MF_STRING, 15, L"Open data folder");
AppendMenuW(menu, MF_STRING, 18, L"View log…");
AppendMenuW(menu, MF_STRING | (updateRunning ? MF_GRAYED : 0), 19,
updateRunning ? L"Update (checking / preparing…)" :
availableUpdate.empty() ? L"Update" : L"Update (new version available)");
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
AppendMenuW(menu, MF_STRING, 16, L"About KSwitcher");
AppendMenuW(menu, MF_STRING, 17, L"Exit");
@@ -998,6 +1134,9 @@ void App::trayMenu()
{
switch (cmd)
{
case 24:
dispatch(Screenshot);
break;
case 10:
showHistory(*this);
break;
@@ -1022,14 +1161,20 @@ void App::trayMenu()
break;
case 16:
MessageBoxW(window,
L"KSwitcher 0.1.0\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:
PostMessageW(window, WM_CLOSE, 0, 0);
break;
case 18:
showLogs(*this);
break;
case 19:
checkUpdate(true);
break;
case 20:
pauseUntil = 0;
break;
@@ -1049,6 +1194,32 @@ void App::trayMenu()
report(wide(e.what()));
}
}
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.");
return;
}
if (updateWorker.joinable()) updateWorker.join();
updateRunning = true;
installingUpdate = install;
updateWorker = std::thread([this, interactive, install, version]
{
auto result = std::make_unique<UpdateResult>();
try { *result = runUpdate(install, version); }
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
result->interactive = interactive;
result->install = install;
if (PostMessageW(window, UpdateMessage, 0, reinterpret_cast<LPARAM>(result.get()))) result.release();
});
}
LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
{
auto *app = current;
@@ -1061,6 +1232,50 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
}
switch (msg)
{
case UpdateMessage:
{
std::unique_ptr<UpdateResult> result(reinterpret_cast<UpdateResult *>(lp));
if (app->updateWorker.joinable()) app->updateWorker.join();
app->updateRunning = false;
app->installingUpdate = false;
if (result->state == L"prepared")
{
// The helper has verified and staged the package; it waits for process exit.
DestroyWindow(hwnd);
}
else if (result->state == L"available")
{
app->availableUpdate = result->version;
if (result->interactive)
{
auto prompt = L"Install KSwitcher " + result->version +
L"?\n\nThe program will close and restart. Settings, history and existing dictionaries are preserved.";
if (MessageBoxW(hwnd, prompt.c_str(), L"KSwitcher Update", MB_YESNO | MB_ICONQUESTION) == IDYES)
app->checkUpdate(true, true, result->version);
}
else
{
NOTIFYICONDATAW icon{sizeof(icon)};
icon.hWnd = hwnd; icon.uID = 1; icon.uFlags = NIF_INFO; icon.dwInfoFlags = NIIF_INFO;
wcscpy_s(icon.szInfoTitle, L"KSwitcher update available");
auto text = result->version + L" is available. Select Update in the tray menu to install.";
wcsncpy_s(icon.szInfo, text.c_str(), _TRUNCATE);
Shell_NotifyIconW(NIM_MODIFY, &icon);
}
}
else if (result->state == L"current")
{
app->availableUpdate.clear();
if (result->interactive)
MessageBoxW(hwnd, (std::wstring(L"No newer stable release is available.\nInstalled version: ") + AppVersion).c_str(),
L"KSwitcher Update", MB_OK | MB_ICONINFORMATION);
}
else if (result->interactive)
MessageBoxW(hwnd, (L"Update failed.\n\n" + result->error).c_str(), L"KSwitcher Update", MB_OK | MB_ICONERROR);
else
diagnosticEvent(L"Startup update check failed: " + result->error);
return 0;
}
case WM_HOTKEY:
app->dispatch((int)wp);
return 0;
@@ -1078,10 +1293,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
app->captureHistory();
return 0;
case AutoCorrectMessage:
if (app->installingUpdate) return 0;
app->pendingAutoVersion = (uint64_t)wp;
SetTimer(hwnd, 3, 250, nullptr);
return 0;
case SnippetMessage:
if (app->installingUpdate) return 0;
app->transform(0, (int)wp);
return 0;
case HistoryMessage:
@@ -1104,6 +1321,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;
@@ -1113,6 +1335,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
return TRUE;
}
case WM_TIMER:
if (wp == 5)
{
KillTimer(hwnd, 5);
app->checkUpdate(false);
return 0;
}
if (wp == 4)
{
if (GetAsyncKeyState(VK_LBUTTON) & 0x8000)
@@ -1124,7 +1352,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
if (wp == 3)
{
KillTimer(hwnd, 3);
if (app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
app->transform(AutoCorrect);
return 0;
}
@@ -1141,6 +1369,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
}
return 0;
case WM_CLOSE:
if (app->updateRunning)
{
app->report(L"Wait for the update check or preparation to finish before exiting.");
return 0;
}
if (app->busy)
{
app->report(L"Wait for the current text operation to finish before exiting.");
@@ -1149,6 +1382,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:
@@ -1210,6 +1446,10 @@ int App::execute()
report(registrationWarning);
if (smoke)
SetTimer(window, 2, 10000, nullptr);
else
SetTimer(window, 5, 5000, nullptr);
if (keyboardHook && mouseHook)
confirmUpdatedStartup(updateStartupEvent);
if (startCommand)
PostMessageW(window, WM_HOTKEY, startCommand, 0);
MSG msg;
@@ -1225,6 +1465,8 @@ int App::execute()
continue;
if (snippetsWindow && IsDialogMessageW(snippetsWindow, &msg))
continue;
if (logWindow && IsDialogMessageW(logWindow, &msg))
continue;
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
+21 -3
View File
@@ -1,6 +1,7 @@
#pragma once
#include "autocorrect.h"
#include "storage.h"
#include "update.h"
#include <atomic>
#include <condition_variable>
#include <deque>
@@ -13,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
@@ -42,12 +43,15 @@ class App
std::atomic<ULONGLONG> pauseUntil{0};
HWND pasteTarget{};
HWND hotkeyCapture{};
DWORD hotkeyCaptureProcess{};
HWND logWindow{};
std::mutex stateMutex;
Run run;
std::optional<LastTransform> last;
std::wstring registrationWarning;
bool smoke{};
int startCommand{};
std::wstring updateStartupEvent;
App(HINSTANCE, bool = false, const std::filesystem::path &dataRoot = {});
~App();
int execute();
@@ -63,20 +67,32 @@ 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 &);
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
void resetInput();
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
static App *current;
private:
friend struct InputRegressionAccess;
HHOOK keyboardHook{}, mouseHook{};
std::mutex jobsMutex;
std::condition_variable jobsReady;
std::deque<std::function<void()>> jobs;
std::thread worker;
std::thread updateWorker;
bool updateRunning{}, installingUpdate{};
std::wstring availableUpdate;
bool stop{};
UINT taskbarCreated{};
uint64_t pendingAutoVersion{};
uint64_t transformAttempt{}, previousAttemptVersion{};
ULONGLONG previousAttemptAt{};
HWND previousAttemptWindow{};
int previousAttemptCommand{}, repeatAttempts{};
std::vector<std::pair<int, Hotkey>> hookBindings;
HotkeyHook hotkeyHook;
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
@@ -84,8 +100,10 @@ class App
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
};
void showHistory(App &);
void showScreenshot(App &);
void refreshHistory(App &);
void showSettings(App &);
void showSettings(App &, bool visible = true);
void showLogs(App &);
void showSnippets(App &);
void showShortcuts(App &);
void showEntry(App &, const std::wstring &);
+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 &);
}
+3 -1
View File
@@ -47,6 +47,7 @@ struct Settings
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
bool elevatedAutostart = false;
bool autoCorrect = false;
bool writeLog = true;
int autoMaxRank = 0;
int autoMinLength = 3;
std::wstring autoPrefixChars = L"([{«“‘\"";
@@ -58,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;
+6 -2
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;
@@ -67,7 +69,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
try
{
ks::App app(instance, arg == L"--smoke-test");
if (!arg.empty() && arg != L"--smoke-test")
if (arg.starts_with(L"--update-ready-event="))
app.updateStartupEvent = arg.substr(std::wstring(L"--update-ready-event=").size());
else if (!arg.empty() && arg != L"--smoke-test")
app.startCommand = cmd;
result = app.execute();
}
+121 -16
View File
@@ -347,15 +347,30 @@ size_t Clip::size() const
n += f.data.size();
return n;
}
ULONG_PTR inputMarker()
{
static const char marker{};
return reinterpret_cast<ULONG_PTR>(&marker);
}
bool ownKeyboardInput(const KBDLLHOOKSTRUCT &key)
{
return (key.flags & LLKHF_INJECTED) && key.dwExtraInfo == inputMarker();
}
INPUT keyboardInput(WORD key, DWORD flags)
{
INPUT input{};
input.type = INPUT_KEYBOARD;
input.ki.wVk = key;
input.ki.dwFlags = flags;
input.ki.dwExtraInfo = inputMarker();
return input;
}
bool chord(WORD key, UINT mods)
{
std::vector<INPUT> inputs;
auto add = [&](WORD k, bool up)
{
INPUT i{};
i.type = INPUT_KEYBOARD;
i.ki.wVk = k;
i.ki.dwFlags = up ? KEYEVENTF_KEYUP : 0;
INPUT i = keyboardInput(k, up ? KEYEVENTF_KEYUP : 0);
if (k == VK_LEFT || k == VK_RIGHT || k == VK_INSERT)
i.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
inputs.push_back(i);
@@ -406,10 +421,7 @@ bool selectPrevious(size_t count, const std::function<bool()> &valid)
std::vector<INPUT> inputs;
auto add = [&](WORD key, DWORD flags)
{
INPUT i{};
i.type = INPUT_KEYBOARD;
i.ki.wVk = key;
i.ki.dwFlags = flags;
INPUT i = keyboardInput(key, flags);
inputs.push_back(i);
};
add(VK_SHIFT, 0);
@@ -437,10 +449,7 @@ void releaseModifiers()
for (int k : {VK_LCONTROL, VK_RCONTROL, VK_LMENU, VK_RMENU, VK_LSHIFT, VK_RSHIFT, VK_LWIN, VK_RWIN})
if (GetAsyncKeyState(k) & 0x8000)
{
INPUT i{};
i.type = INPUT_KEYBOARD;
i.ki.wVk = (WORD)k;
i.ki.dwFlags = KEYEVENTF_KEYUP;
INPUT i = keyboardInput((WORD)k, KEYEVENTF_KEYUP);
inputs.push_back(i);
}
if (!inputs.empty())
@@ -527,16 +536,112 @@ std::wstring lastError(const std::wstring &context)
{
return context + L" (Windows error " + std::to_wstring(GetLastError()) + L")";
}
std::wstring diagnosticText(const std::wstring &text)
{
// Escape UTF-16 code units so invisible characters and malformed surrogate pairs
// can be reproduced without introducing fake log lines. Bound large selections.
std::wstring out = L"\"";
constexpr wchar_t hex[] = L"0123456789abcdef";
const auto count = std::min<size_t>(text.size(), 512);
for (size_t i = 0; i < count; ++i)
{
auto c = static_cast<unsigned short>(text[i]);
if (c == L'"' || c == L'\\')
{
out += L'\\';
out += c;
}
else if (c < 32 || c >= 127)
{
out += L"\\u";
for (int shift = 12; shift >= 0; shift -= 4)
out += hex[(c >> shift) & 15];
}
else
out += c;
}
return out + L"\" units=" + std::to_wstring(text.size()) +
L" truncated=" + std::to_wstring(count != text.size());
}
namespace
{
std::recursive_mutex logGate;
bool loggingEnabled = false;
DiagnosticBuffer diagnosticBuffer;
}
std::wstring DiagnosticBuffer::record(const std::wstring &message, bool problem, ULONGLONG now)
{
while (!entries.empty() && (entries.size() >= 40 || now - entries.front().first > 30000))
entries.pop_front();
entries.emplace_back(now, message);
if (!problem)
return {};
std::wstring out = L"Diagnostic incident (preceding context, up to 30 seconds):";
for (const auto &[at, entry] : entries)
out += L"\n ageMs=" + std::to_wstring(now - at) + L" " + entry;
entries.clear();
return out;
}
void setLoggingEnabled(bool enabled)
{
std::lock_guard lock(logGate);
loggingEnabled = enabled;
if (!enabled)
diagnosticBuffer.clear();
}
std::wstring readDiagnosticLog(bool previous)
{
std::lock_guard lock(logGate);
auto path = dataDirectory() / (previous ? L"kswitcher.log.1" : L"kswitcher.log");
if (!std::filesystem::exists(path))
return previous ? L"No previous log is available yet." : L"No log yet. Entries will appear when a problem is detected.";
auto bytes = readFile(path, 2 * 1024 * 1024);
auto text = wide(std::string(bytes.begin(), bytes.end()));
std::wstring display;
for (size_t i = 0; i < text.size(); ++i)
{
if (text[i] == L'\n' && (i == 0 || text[i - 1] != L'\r'))
display += L'\r';
display += text[i];
}
return display.empty() ? L"The log is empty." : display;
}
void clearDiagnosticLog(bool previous)
{
// Serialize clearing with writes and rotation. Never delete the other log.
std::lock_guard lock(logGate);
auto path = dataDirectory() / (previous ? L"kswitcher.log.1" : L"kswitcher.log");
std::filesystem::remove(path);
if (!previous)
diagnosticBuffer.clear();
}
void diagnosticEvent(const std::wstring &message, bool problem)
{
std::lock_guard lock(logGate);
if (!loggingEnabled)
return;
auto incident = diagnosticBuffer.record(message, problem, GetTickCount64());
if (!incident.empty())
log(incident);
}
void log(const std::wstring &message)
{
static std::mutex gate;
std::lock_guard lock(gate);
std::lock_guard lock(logGate);
if (!loggingEnabled)
return;
try
{
auto path = dataDirectory() / L"kswitcher.log";
std::error_code ec;
if (std::filesystem::file_size(path, ec) > 256 * 1024)
std::filesystem::remove(path, ec);
auto size = std::filesystem::file_size(path, ec);
if (!ec && size > 256 * 1024)
{
auto previous = path;
previous += L".1";
std::filesystem::remove(previous, ec);
ec.clear();
std::filesystem::rename(path, previous, ec);
}
std::ofstream f(path, std::ios::app);
SYSTEMTIME t;
GetLocalTime(&t);
+18
View File
@@ -1,6 +1,7 @@
#pragma once
#include "core.h"
#include <filesystem>
#include <deque>
#include <objbase.h>
#include <span>
#include <stdexcept>
@@ -35,6 +36,10 @@ Clip readClipboard(HWND, size_t limit = 32 * 1024 * 1024);
DWORD writeClipboard(HWND, const Clip &);
Clip textClip(const std::wstring &);
bool chord(WORD key, UINT modifiers = 0);
// Process-local marker, shared by every KSwitcher input producer (not an authentication token).
ULONG_PTR inputMarker();
bool ownKeyboardInput(const KBDLLHOOKSTRUCT &);
INPUT keyboardInput(WORD key, DWORD flags = 0);
bool needsSelectionDelay(HWND);
bool selectPrevious(size_t count, const std::function<bool()> &valid);
bool modifiersReleased();
@@ -50,4 +55,17 @@ void configureAutostart(int mode);
std::wstring executable();
std::wstring lastError(const std::wstring &context);
void log(const std::wstring &message);
std::wstring diagnosticText(const std::wstring &text);
// A bounded, short-lived context. A problem drains it, so old incidents are not repeated.
class DiagnosticBuffer
{
std::deque<std::pair<ULONGLONG, std::wstring>> entries;
public:
std::wstring record(const std::wstring &message, bool problem, ULONGLONG now);
void clear() { entries.clear(); }
};
void setLoggingEnabled(bool enabled);
void diagnosticEvent(const std::wstring &message, bool problem = false);
std::wstring readDiagnosticLog(bool previous);
void clearDiagnosticLog(bool previous);
} // namespace ks
+1534
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include "platform.h"
namespace ks
{
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
enum class LineEnds { End, Start, Both, None };
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse };
struct ScreenshotOffset { double x, y; };
struct ScreenshotMark {
POINT from, to; int number; int radius = 15; int thickness = 3;
int fromNumber = 0, toNumber = 0;
std::vector<POINT> points;
COLORREF color = RGB(225,42,50);
bool freehand = false;
LineStyle style = LineStyle::Solid;
LineEnds ends = LineEnds::End;
ScreenshotShape shape = ScreenshotShape::None;
std::wstring text, fontName=L"Segoe UI";
int fontSize=24, fontWeight=FW_NORMAL;
bool fontItalic=false;
double rotation=0;
std::wstring label;
ScreenshotOffset labelOffset{0,-24}; // Along the line and perpendicular to it, in image pixels.
bool labelRotationManual=false;
bool isText() const { return !text.empty(); }
bool isHighlight() const { return !points.empty() && !freehand; }
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
};
class ScreenshotHistory {
std::deque<std::vector<ScreenshotMark>> past, future;
public:
void remember(const std::vector<ScreenshotMark> &);
bool undo(std::vector<ScreenshotMark> &);
bool redo(std::vector<ScreenshotMark> &);
bool canUndo() const { return !past.empty(); }
bool canRedo() const { return !future.empty(); }
};
void resolveScreenshotLinks(std::vector<ScreenshotMark> &);
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
const std::vector<ScreenshotMark> &, double tolerance);
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label };
POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16);
ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height);
int nextScreenshotNumber(const std::vector<ScreenshotMark> &);
MarkPart hitScreenshotMark(const ScreenshotMark &, POINT, double tolerance);
ScreenshotMark dragScreenshotMark(const ScreenshotMark &, MarkPart, POINT origin, POINT position,
int width, int height, bool snapRotation=false);
POINT constrainScreenshotShape(ScreenshotShape, POINT anchor, POINT position, int width, int height);
RECT screenshotTextBounds(const ScreenshotMark &);
POINT screenshotTextRotationHandle(const ScreenshotMark &, double clearance=24);
ScreenshotMark screenshotArrowLabel(const ScreenshotMark &);
// Render at source resolution; never reads or writes the system clipboard.
Clip screenshotClip(HDC source, int width, int height, const std::vector<ScreenshotMark> &marks);
}
+17 -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,14 +115,15 @@ 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");
s.recent = j.value("convertLastWord", true);
s.switchLayout = j.value("switchSystemLayout", true);
s.writeLog = j.value("writeLog", true);
s.history = j.value("historyEnabled", true);
s.autostart = j.value("startWithSystem", false);
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
@@ -145,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();
@@ -176,12 +182,17 @@ 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"},
{"schemaVersion", 1},
{"convertLastWord", s.recent},
{"switchSystemLayout", s.switchLayout},
{"writeLog", s.writeLog},
{"autostartAsAdministrator", s.elevatedAutostart},
{"historyEnabled", s.history},
{"startWithSystem", s.autostart},
@@ -193,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));
@@ -219,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
+473 -34
View File
@@ -1,7 +1,9 @@
#include "../resources/resource.h"
#include "app.h"
#include "configuration.h"
#include "ocr.h"
#include "richtext.h"
#include "ui_geometry.h"
#include <algorithm>
#include <commctrl.h>
#include <commdlg.h>
@@ -26,6 +28,129 @@ HFONT font(HWND w)
OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH,
L"Segoe UI");
}
struct WindowLayout
{
int width{}, height{}, x{}, y{};
bool scrolling{}, arranging{};
int wheelRemainder{}, horizontalWheelRemainder{};
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
};
WindowLayout *windowLayoutState(HWND w)
{
return reinterpret_cast<WindowLayout *>(GetPropW(w, L"WindowLayout"));
}
RECT workArea(RECT rect)
{
MONITORINFO monitor{sizeof(monitor)};
GetMonitorInfoW(MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST), &monitor);
return monitor.rcWork;
}
void fitToMonitor(HWND w, RECT rect)
{
rect = fitWindowRect(rect, workArea(rect));
SetWindowPos(w, nullptr, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top,
SWP_NOACTIVATE | SWP_NOZORDER);
}
void arrangeWindow(HWND w)
{
auto *layout = windowLayoutState(w);
if (!layout || !layout->scrolling || layout->arranging) return;
layout->arranging = true;
// Showing either scrollbar can make the other necessary. Let the client size settle.
for (int pass = 0; pass < 3; ++pass)
{
RECT client{};
GetClientRect(w, &client);
SCROLLINFO horizontal{sizeof(horizontal), SIF_RANGE | SIF_PAGE | SIF_POS,
0, scale(w, layout->width) - 1, (UINT)client.right, layout->x};
SCROLLINFO vertical{sizeof(vertical), SIF_RANGE | SIF_PAGE | SIF_POS,
0, scale(w, layout->height) - 1, (UINT)client.bottom, layout->y};
layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE);
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
}
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);
layout->arranging = false;
InvalidateRect(w, nullptr, TRUE);
}
void revealControl(HWND child)
{
HWND parent = GetParent(child);
auto *layout = windowLayoutState(parent);
if (!layout || !layout->scrolling) return;
RECT r{}, client{};
GetWindowRect(child, &r);
MapWindowPoints(nullptr, parent, reinterpret_cast<POINT *>(&r), 2);
GetClientRect(parent, &client);
if (r.left < 0) layout->x += r.left;
else if (r.right > client.right) layout->x += r.right - client.right;
if (r.top < 0) layout->y += r.top;
else if (r.bottom > client.bottom) layout->y += r.bottom - client.bottom;
arrangeWindow(parent);
}
LRESULT CALLBACK scrollChildProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
{
if (msg == WM_SETFOCUS) revealControl(w);
if (msg == WM_NCDESTROY) RemoveWindowSubclass(w, scrollChildProc, 2);
return DefSubclassProc(w, msg, wp, lp);
}
void resizeForDpi(HWND, WPARAM, LPARAM);
LRESULT CALLBACK layoutProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
{
auto *layout = windowLayoutState(w);
if (!layout) return DefSubclassProc(w, msg, wp, lp);
if (msg == WM_DPICHANGED)
{
resizeForDpi(w, wp, lp);
return 0;
}
if (msg == WM_SIZE) arrangeWindow(w);
if (msg == WM_DISPLAYCHANGE || msg == WM_SETTINGCHANGE || (msg == WM_SHOWWINDOW && wp))
{
RECT rect{};
GetWindowRect(w, &rect);
fitToMonitor(w, rect);
arrangeWindow(w);
}
if (layout->scrolling && (msg == WM_VSCROLL || msg == WM_HSCROLL))
{
bool vertical = msg == WM_VSCROLL;
SCROLLINFO info{sizeof(info), SIF_ALL};
GetScrollInfo(w, vertical ? SB_VERT : SB_HORZ, &info);
int &position = vertical ? layout->y : layout->x;
switch (LOWORD(wp))
{
case SB_LINEUP: position -= scale(w, 32); break;
case SB_LINEDOWN: position += scale(w, 32); break;
case SB_PAGEUP: position -= (int)info.nPage; break;
case SB_PAGEDOWN: position += (int)info.nPage; break;
case SB_TOP: position = 0; break;
case SB_BOTTOM: position = info.nMax; break;
case SB_THUMBTRACK: case SB_THUMBPOSITION: position = info.nTrackPos; break;
}
arrangeWindow(w);
return 0;
}
if (layout->scrolling && (msg == WM_MOUSEWHEEL || msg == WM_MOUSEHWHEEL))
{
bool horizontal = msg == WM_MOUSEHWHEEL || (GET_KEYSTATE_WPARAM(wp) & MK_SHIFT);
int &remainder = horizontal ? layout->horizontalWheelRemainder : layout->wheelRemainder;
remainder += GET_WHEEL_DELTA_WPARAM(wp);
int steps = remainder / WHEEL_DELTA;
remainder %= WHEEL_DELTA;
(horizontal ? layout->x : layout->y) += steps * scale(w, 48) * (msg == WM_MOUSEHWHEEL ? 1 : -1);
arrangeWindow(w);
return 0;
}
if (msg == WM_NCDESTROY)
{
RemovePropW(w, L"WindowLayout");
RemoveWindowSubclass(w, layoutProc, 2);
delete layout;
}
return DefSubclassProc(w, msg, wp, lp);
}
HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style, int id, int x, int y,
int width, int height)
{
@@ -34,6 +159,12 @@ HWND control(HWND parent, const wchar_t *cls, const wchar_t *title, DWORD style,
scale(parent, width), scale(parent, height), parent, (HMENU)(INT_PTR)id,
GetModuleHandleW(nullptr), nullptr);
SendMessageW(w, WM_SETFONT, (WPARAM)GetPropW(parent, L"Font"), TRUE);
if (auto *layout = windowLayoutState(parent); layout && layout->scrolling)
{
layout->controls.push_back({w, {x, y, x + width, y + height}});
SetWindowSubclass(w, scrollChildProc, 2, 0);
arrangeWindow(parent);
}
return w;
}
void label(HWND w, const wchar_t *text, int x, int y, int width = 200)
@@ -55,13 +186,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)
{
@@ -88,13 +219,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);
}
@@ -111,11 +242,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);
}
@@ -124,7 +255,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;
@@ -137,12 +268,31 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
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 | extra;
if (resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX;
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
POINT cursor{};
GetCursorPos(&cursor);
MONITORINFO monitor{sizeof(monitor)};
GetMonitorInfoW(MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST), &monitor);
HWND w =
CreateWindowExW(WS_EX_APPWINDOW, cls, title, style, CW_USEDEFAULT, CW_USEDEFAULT,
CreateWindowExW(WS_EX_APPWINDOW, cls, title, style, monitor.rcWork.left, monitor.rcWork.top,
rect.right - rect.left, rect.bottom - rect.top, nullptr, nullptr, app.instance, &app);
if (!w)
throw std::runtime_error(utf8(lastError(L"Cannot create native window")));
// The system DPI may still describe the old primary display. The HWND tells
// us the actual destination DPI, and must size the frame AND its controls.
dpi = GetDpiForWindow(w);
rect = {0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
int outerWidth = rect.right - rect.left, outerHeight = rect.bottom - rect.top;
int left = monitor.rcWork.left + std::max(0L, (monitor.rcWork.right - monitor.rcWork.left - outerWidth) / 2);
int top = monitor.rcWork.top + std::max(0L, (monitor.rcWork.bottom - monitor.rcWork.top - outerHeight) / 2);
SetPropW(w, L"WindowLayout", new WindowLayout{width, height, 0, 0, !(extra & WS_THICKFRAME)});
SetWindowSubclass(w, layoutProc, 2, 0);
RECT initial = fitWindowRect({left, top, left + outerWidth, top + outerHeight}, monitor.rcWork);
SetWindowPos(w, nullptr, initial.left, initial.top, initial.right - initial.left, initial.bottom - initial.top,
SWP_NOACTIVATE | SWP_NOZORDER);
arrangeWindow(w);
SetWindowTextW(w, title);
auto smallIcon = (HICON)LoadImageW(app.instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
GetSystemMetricsForDpi(SM_CXSMICON, dpi),
@@ -154,6 +304,12 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
}
void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
{
// Creation may send WM_DPICHANGED before controls and the original font exist.
if (!GetPropW(w, L"Dpi"))
{
fitToMonitor(w, *(RECT *)lp);
return;
}
int previous = (int)(UINT_PTR)GetPropW(w, L"Dpi");
int next = LOWORD(wp);
if (!previous)
@@ -166,7 +322,8 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
int previous, next;
HFONT font;
} state{w, previous, next, replacement};
EnumChildWindows(
auto *layout = windowLayoutState(w);
if (!layout || !layout->scrolling) EnumChildWindows(
w,
[](HWND child, LPARAM data) -> BOOL
{
@@ -183,14 +340,21 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
return TRUE;
},
(LPARAM)&state);
else
{
layout->x = MulDiv(layout->x, next, previous);
layout->y = MulDiv(layout->y, next, previous);
for (auto &[child, rect] : layout->controls)
SendMessageW(child, WM_SETFONT, (WPARAM)replacement, TRUE);
}
SetPropW(w, L"Font", replacement);
SetPropW(w, L"Dpi", (HANDLE)(UINT_PTR)next);
if (old)
DeleteObject(old);
auto *r = (RECT *)lp;
SetWindowPos(w, nullptr, r->left, r->top, r->right - r->left, r->bottom - r->top, SWP_NOZORDER);
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)};
@@ -210,6 +374,7 @@ void disposeFont(HWND w)
DeleteObject(h);
}
#ifndef KS_CONFIG_APP
struct SearchResult
{
uint64_t generation;
@@ -593,6 +758,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{};
@@ -645,6 +812,7 @@ void fillSettings(HWND w, SettingsView *v)
auto &s = v->draft;
check(w, 201, s.recent);
check(w, 202, s.switchLayout);
check(w, 208, s.writeLog);
check(w, 203, s.history);
check(w, 204, s.autostart);
check(w, 206, s.elevatedAutostart);
@@ -655,6 +823,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));
@@ -693,6 +862,7 @@ Settings collectSettings(HWND w, SettingsView *v)
auto s = v->draft;
s.recent = checked(w, 201);
s.switchLayout = checked(w, 202);
s.writeLog = checked(w, 208);
s.history = checked(w, 203);
s.autostart = checked(w, 204);
s.elevatedAutostart = checked(w, 206);
@@ -703,6 +873,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);
@@ -908,6 +1079,11 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
layoutSelection(w, v);
return 0;
}
if (id == 256)
{
showLogs(*v->app);
return 0;
}
if (id == 230)
{
collectLayout(w, v);
@@ -977,11 +1153,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;
@@ -1125,6 +1301,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)
@@ -1136,7 +1314,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)
@@ -1195,7 +1375,254 @@ void showHistory(App &app)
SetFocus(GetDlgItem(app.historyWindow, 101));
refreshHistory(app);
}
void showSettings(App &app)
namespace
{
constexpr int LogChoice = 601, LogRows = 602, LogRefresh = 603, LogStatus = 604, LogCopy = 605,
LogClear = 606;
LRESULT CALLBACK logListProc(HWND w, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
{
// Header notifications go to the ListView, not its top-level parent.
if (msg == WM_NOTIFY && ((NMHDR *)lp)->hwndFrom == ListView_GetHeader(w) &&
((NMHDR *)lp)->code == NM_CUSTOMDRAW)
{
auto *draw = (NMCUSTOMDRAW *)lp;
if (draw->dwDrawStage == CDDS_PREPAINT)
{
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE));
return CDRF_NOTIFYITEMDRAW;
}
if (draw->dwDrawStage == CDDS_ITEMPREPAINT)
{
int saved = SaveDC(draw->hdc);
FillRect(draw->hdc, &draw->rc, GetSysColorBrush(COLOR_BTNFACE));
RECT edge = draw->rc;
edge.top = edge.bottom - 1;
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW));
edge = draw->rc;
edge.left = edge.right - 1;
FillRect(draw->hdc, &edge, GetSysColorBrush(COLOR_3DSHADOW));
wchar_t title[128]{};
HDITEMW item{};
item.mask = HDI_TEXT;
item.pszText = title;
item.cchTextMax = 128;
Header_GetItem(draw->hdr.hwndFrom, (int)draw->dwItemSpec, &item);
auto headerFont = (HFONT)SendMessageW(draw->hdr.hwndFrom, WM_GETFONT, 0, 0);
if (headerFont)
SelectObject(draw->hdc, headerFont);
SetBkMode(draw->hdc, TRANSPARENT);
SetTextColor(draw->hdc, GetSysColor(COLOR_BTNTEXT));
RECT text = draw->rc;
InflateRect(&text, -scale(w, 6), 0);
DrawTextW(draw->hdc, title, -1, &text, DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS | DT_NOPREFIX);
RestoreDC(draw->hdc, saved);
return CDRF_SKIPDEFAULT;
}
}
if (msg == WM_NCDESTROY)
RemoveWindowSubclass(w, logListProc, 1);
return DefSubclassProc(w, msg, wp, lp);
}
void copyLogRows(HWND w, App &app)
{
HWND list = GetDlgItem(w, LogRows);
std::wstring copied;
for (int row = ListView_GetNextItem(list, -1, LVNI_SELECTED); row >= 0;
row = ListView_GetNextItem(list, row, LVNI_SELECTED))
{
for (int column = 0; column < 4; ++column)
{
std::vector<wchar_t> text(32768);
ListView_GetItemText(list, row, column, text.data(), (int)text.size());
if (column)
copied += L'\t';
copied += text.data();
}
copied += L"\r\n";
}
if (!copied.empty())
app.ownSequence = writeClipboard(app.window, textClip(copied));
}
void refreshLogView(HWND w, App &app)
{
const bool previous = SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1;
std::wstring contents;
try
{
contents = readDiagnosticLog(previous);
}
catch (const std::exception &e)
{
contents = L"Could not read the log: " + wide(e.what());
}
HWND list = GetDlgItem(w, LogRows);
SendMessageW(list, WM_SETREDRAW, FALSE, 0);
ListView_DeleteAllItems(list);
std::wistringstream lines(contents);
std::wstring line, timestamp;
int row = 0;
while (std::getline(lines, line))
{
if (!line.empty() && line.back() == L'\r')
line.pop_back();
if (line.empty())
continue;
auto firstSpace = line.find(L' ');
auto secondSpace = firstSpace == line.npos ? line.npos : line.find(L' ', firstSpace + 1);
if (iswdigit(line.front()) && line.find(L'-') < firstSpace && secondSpace != line.npos)
{
timestamp = line.substr(0, secondSpace);
line.erase(0, secondSpace + 1);
}
auto id = line.find(L" id=");
std::wstring attempt;
if (id != line.npos)
{
auto end = line.find(L' ', id + 4);
attempt = line.substr(id + 4, end == line.npos ? end : end - id - 4);
}
std::wstring event = line;
if (event.starts_with(L" ageMs="))
{
auto end = event.find(L' ', 8);
if (end != event.npos)
event.erase(0, end + 1);
}
auto equals = event.find(L'=');
if (equals != event.npos)
{
auto key = event.rfind(L' ', equals);
if (key != event.npos)
event.resize(key);
}
LVITEMW item{};
item.mask = LVIF_TEXT;
item.iItem = row;
item.pszText = timestamp.data();
ListView_InsertItem(list, &item);
ListView_SetItemText(list, row, 1, attempt.data());
ListView_SetItemText(list, row, 2, event.data());
ListView_SetItemText(list, row, 3, line.data());
++row;
}
SendMessageW(list, WM_SETREDRAW, TRUE, 0);
InvalidateRect(list, nullptr, TRUE);
if (row)
ListView_EnsureVisible(list, row - 1, FALSE);
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)
{
auto *app = reinterpret_cast<App *>(GetWindowLongPtrW(w, GWLP_USERDATA));
if (msg == WM_NCCREATE)
{
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)((CREATESTRUCTW *)lp)->lpCreateParams);
return TRUE;
}
if (!app)
return DefWindowProcW(w, msg, wp, lp);
switch (msg)
{
case WM_COMMAND:
if ((LOWORD(wp) == LogRefresh && HIWORD(wp) == BN_CLICKED) ||
(LOWORD(wp) == LogChoice && HIWORD(wp) == CBN_SELCHANGE))
{
refreshLogView(w, *app);
return 0;
}
if (LOWORD(wp) == LogCopy && HIWORD(wp) == BN_CLICKED)
{
try { copyLogRows(w, *app); }
catch (const std::exception &e) { error(w, e); }
return 0;
}
if (LOWORD(wp) == LogClear && HIWORD(wp) == BN_CLICKED)
{
try
{
clearDiagnosticLog(SendDlgItemMessageW(w, LogChoice, CB_GETCURSEL, 0, 0) == 1);
refreshLogView(w, *app);
}
catch (const std::exception &e) { error(w, e); }
return 0;
}
if (LOWORD(wp) != IDCANCEL || HIWORD(wp) != BN_CLICKED)
break;
[[fallthrough]];
case WM_CLOSE:
DestroyWindow(w);
return 0;
case WM_NOTIFY:
if (((NMHDR *)lp)->idFrom == LogRows && ((NMHDR *)lp)->code == LVN_KEYDOWN)
{
auto key = ((NMLVKEYDOWN *)lp)->wVKey;
if (GetKeyState(VK_CONTROL) & 0x8000)
{
if (key == 'A')
ListView_SetItemState(GetDlgItem(w, LogRows), -1, LVIS_SELECTED, LVIS_SELECTED);
if (key == 'C')
{
try { copyLogRows(w, *app); }
catch (const std::exception &e) { error(w, e); }
}
}
}
return 0;
case WM_DPICHANGED:
resizeForDpi(w, wp, lp);
return 0;
case WM_NCDESTROY:
app->logWindow = nullptr;
disposeFont(w);
break;
}
return DefWindowProcW(w, msg, wp, lp);
}
}
void showLogs(App &app)
{
if (app.logWindow)
{
showRequestedWindow(app.logWindow);
activateWindow(app.logWindow);
return;
}
auto w = makeWindow(app, L"KSwitcher.Logs", L"KSwitcher log", logProc, 900, 590);
app.logWindow = w;
auto choice = control(w, L"COMBOBOX", L"", WS_TABSTOP | CBS_DROPDOWNLIST, LogChoice, 16, 16, 300, 140);
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Current log — kswitcher.log");
SendMessageW(choice, CB_ADDSTRING, 0, (LPARAM)L"Previous log — kswitcher.log.1");
SendMessageW(choice, CB_SETCURSEL, 0, 0);
button(w, L"Refresh", LogRefresh, 330, 14, 110);
button(w, L"Copy selected", LogCopy, 454, 14, 140);
button(w, L"Clear log", LogClear, 608, 14, 110);
control(w, L"STATIC", L"", 0, LogStatus, 16, 52, 860, 22);
INITCOMMONCONTROLSEX common{sizeof(common), ICC_LISTVIEW_CLASSES};
InitCommonControlsEx(&common);
auto list = control(w, WC_LISTVIEWW, L"", WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS,
LogRows, 16, 82, 868, 454);
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER);
SetWindowSubclass(list, logListProc, 1, 0);
const wchar_t *titles[] = {L"Time", L"Attempt", L"Event", L"Details"};
const int widths[] = {155, 65, 195, 900};
for (int i = 0; i < 4; ++i)
{
LVCOLUMNW column{};
column.mask = LVCF_TEXT | LVCF_WIDTH;
column.pszText = const_cast<wchar_t *>(titles[i]);
column.cx = scale(w, widths[i]);
ListView_InsertColumn(list, i, &column);
}
label(w, L"Select rows to copy. Ctrl+A selects all; Ctrl+C copies selected rows.", 16, 551, 720);
button(w, L"Close", IDCANCEL, 774, 548, 110);
refreshLogView(w, app);
showRequestedWindow(w);
activateWindow(w);
}
#endif
#ifndef KS_RESIDENT_APP
void showSettings(App &app, bool visible)
{
if (app.settingsWindow)
{
@@ -1206,34 +1633,35 @@ void showSettings(App &app)
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);
checkbox(w, L"Keep screen awake", 205, 18, 184);
checkbox(w, L"Keep screen awake", 205, 18, 184, 210);
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);
@@ -1245,14 +1673,18 @@ void showSettings(App &app)
label(w, L"Exclude applications (process names, comma-separated)", 18, 516, 700);
edit(w, 243, 18, 544, 742);
button(w, L"Auto-correction options…", 255, 18, 575, 240);
button(w, L"View log…", 256, 278, 575, 220);
button(w, L"Import settings…", 250, 18, 606, 145);
button(w, L"Export settings…", 251, 173, 606, 145);
button(w, L"Snippets…", 252, 328, 606, 110);
button(w, L"Save", IDOK, 548, 606, 100);
button(w, L"Cancel", IDCANCEL, 660, 606, 100);
fillSettings(w, (SettingsView *)GetWindowLongPtrW(w, GWLP_USERDATA));
showRequestedWindow(w);
activateWindow(w);
if (visible)
{
showRequestedWindow(w);
activateWindow(w);
}
}
void showSnippets(App &app)
{
@@ -1280,11 +1712,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];
@@ -1317,4 +1755,5 @@ void showEntry(App &app, const std::wstring &id)
error(app.historyWindow, e);
}
}
#endif
} // namespace ks
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include "core.h"
#include <algorithm>
namespace ks
{
inline RECT fitWindowRect(RECT desired, RECT work)
{
LONG width = std::clamp<LONG>(desired.right - desired.left, 1, std::max<LONG>(1, work.right - work.left));
LONG height = std::clamp<LONG>(desired.bottom - desired.top, 1, std::max<LONG>(1, work.bottom - work.top));
LONG x = std::clamp(desired.left, work.left, work.right - width);
LONG y = std::clamp(desired.top, work.top, work.bottom - height);
return {x, y, x + width, y + height};
}
}
+103
View File
@@ -0,0 +1,103 @@
#include "update.h"
#include "json.hpp"
namespace ks
{
namespace
{
struct Handle
{
HANDLE value{};
~Handle() { if (value) CloseHandle(value); }
};
std::wstring quoteArgument(const std::wstring &value)
{
// Windows argv escaping, including a path ending with a backslash.
std::wstring out = L"\"";
size_t slashes = 0;
for (auto ch : value)
{
if (ch == L'\\') { ++slashes; continue; }
out.append(ch == L'"' ? slashes * 2 + 1 : slashes, L'\\');
slashes = 0;
out += ch;
}
out.append(slashes * 2, L'\\');
return out + L'"';
}
}
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion)
{
UpdateResult result;
GUID guid{};
if (FAILED(CoCreateGuid(&guid))) throw std::runtime_error("Cannot create update identifier");
wchar_t id[40]{};
StringFromGUID2(guid, id, 40);
auto work = std::filesystem::temp_directory_path() / (L"KSwitcher-update-" + std::wstring(id));
std::filesystem::create_directory(work);
bool detached = false;
Handle child;
try
{
HRSRC resource = FindResourceW(nullptr, MAKEINTRESOURCEW(201), RT_RCDATA);
HGLOBAL loaded = resource ? LoadResource(nullptr, resource) : nullptr;
auto bytes = loaded ? static_cast<const unsigned char *>(LockResource(loaded)) : nullptr;
if (!bytes) throw std::runtime_error("The updater resource is missing");
atomicWrite(work / L"update.ps1", Bytes(bytes, bytes + SizeofResource(nullptr, resource)));
std::wstring readyName = L"Local\\KSwitcher.Update.Ready." + std::wstring(id);
Handle ready{CreateEventW(nullptr, TRUE, FALSE, readyName.c_str())};
if (!ready.value) throw std::runtime_error("Cannot create updater handshake");
wchar_t system[MAX_PATH]{};
if (!GetSystemDirectoryW(system, MAX_PATH)) throw std::runtime_error("Cannot locate Windows PowerShell");
auto powershell = std::filesystem::path(system) / L"WindowsPowerShell/v1.0/powershell.exe";
std::wstring command = quoteArgument(powershell.wstring()) + L" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " +
quoteArgument((work / L"update.ps1").wstring()) + L" -Mode " + (install ? L"Install" : L"Check") +
L" -CurrentVersion " + quoteArgument(AppVersion) + L" -WorkDirectory " + quoteArgument(work.wstring()) +
L" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) +
L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName);
if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
if (!CreateProcessW(powershell.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
nullptr, work.c_str(), &startup, &process))
throw std::runtime_error("Cannot start Windows PowerShell updater");
child.value = process.hProcess;
Handle thread{process.hThread};
HANDLE waits[]{ready.value, child.value};
DWORD waited = WaitForMultipleObjects(2, waits, FALSE, 240000);
if (waited != WAIT_OBJECT_0 && waited != WAIT_OBJECT_0 + 1)
{
// The helper cannot replace files while this process is alive.
TerminateProcess(child.value, 1);
WaitForSingleObject(child.value, 5000);
throw std::runtime_error("Update preparation timed out; the installed application was not changed");
}
auto data = readFile(work / L"result.json", 64 * 1024);
auto json = nlohmann::json::parse(data.begin(), data.end());
result.state = wide(json.at("state").get<std::string>());
result.version = wide(json.value("version", std::string{}));
result.error = wide(json.value("error", std::string{}));
detached = install && waited == WAIT_OBJECT_0 && result.state == L"prepared";
if (result.state == L"prepared" && !detached)
throw std::runtime_error("Update helper exited before installation");
if (!detached) std::filesystem::remove_all(work);
return result;
}
catch (...)
{
if (!detached && child.value && WaitForSingleObject(child.value, 0) == WAIT_TIMEOUT)
{
TerminateProcess(child.value, 1);
WaitForSingleObject(child.value, 5000);
}
if (!detached) { std::error_code ignored; std::filesystem::remove_all(work, ignored); }
throw;
}
}
void confirmUpdatedStartup(const std::wstring &eventName)
{
if (!eventName.starts_with(L"Local\\KSwitcher.Update.Started.")) return;
Handle event{OpenEventW(EVENT_MODIFY_STATE, FALSE, eventName.c_str())};
if (event.value) SetEvent(event.value);
}
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.4.2";
inline constexpr UINT UpdateMessage = WM_APP + 7;
struct UpdateResult
{
std::wstring state, version, error;
bool interactive{}, install{};
};
UpdateResult runUpdate(bool install, const std::wstring &expectedVersion);
void confirmUpdatedStartup(const std::wstring &eventName);
}
+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
}
+533
View File
@@ -0,0 +1,533 @@
#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'}}}; }
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
};
}
int main(int argc, char **argv) {
if (argc == 2 && std::string(argv[1]) == "--update-check") {
try {
auto result = ks::runUpdate(false, {});
std::cout << "Live update check: " << ks::utf8(result.state) << " " << ks::utf8(result.version)
<< " " << ks::utf8(result.error) << '\n';
return result.state == L"current" || result.state == L"available" ? 0 : 1;
} catch (const std::exception &e) { std::cerr << e.what() << '\n'; return 1; }
}
auto temp=std::filesystem::temp_directory_path()/(L"KSwitcher-input-tests-"+std::to_wstring(GetCurrentProcessId()));
int checks=0;
auto require=[&](bool ok,const char* text) { if(!ok) throw std::runtime_error(text); ++checks; };
try {
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_LISTVIEW_CLASSES};
InitCommonControlsEx(&controls);
{
ks::App app(GetModuleHandleW(nullptr),true,temp);
ks::InputRegressionAccess::setup(app);
// 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;
k.dwExtraInfo=own?ks::inputMarker():123; return k;
};
auto commands=[&] { MSG m{}; int n=0; while(PeekMessageW(&m,app.window,WM_HOTKEY,WM_HOTKEY,PM_REMOVE)) ++n; return n; };
auto f12=event(VK_F12);
require(!app.handleKeyboard(event(VK_LCONTROL),true,0),"remote modifier passes through");
require(app.handleKeyboard(f12,true,MOD_CONTROL),"remote Ctrl+F12 handled");
require(app.handleKeyboard(f12,true,MOD_CONTROL),"remote repeat suppressed");
require(commands()==1,"one command per press");
require(!app.handleKeyboard(event(VK_F12,0,true),false,0),"own key-up bypasses latch");
require(app.handleKeyboard(f12,true,MOD_CONTROL),"own key-up leaves remote latch intact");
require(commands()==0,"no repeat after own key-up");
require(app.handleKeyboard(f12,false,0),"remote release consumed after modifier release");
require(app.handleKeyboard(f12,true,MOD_CONTROL),"second remote press handled");
require(commands()==1,"second remote press dispatches");
app.handleKeyboard(f12,false,0);
require(app.handleKeyboard(event('U'),true,MOD_CONTROL|MOD_SHIFT),"combined modifiers");
app.handleKeyboard(event('U'),false,0);
require(!app.handleKeyboard(f12,true,MOD_CONTROL|MOD_SHIFT),"exact modifiers only");
app.resetInput();
app.handleKeyboard(event('A',0x1e),true,0);
require(app.run.keys==L"a"||app.run.keys==L"A","external scan recorded with current CapsLock");
auto keys=app.run.keys; auto version=app.inputVersion.load();
for(WORD key:{WORD('V'),WORD(VK_SHIFT),WORD(VK_LEFT),WORD(VK_F15),WORD(0xE8)})
for(bool up:{false,true}) {
auto i=ks::keyboardInput(key,up?KEYEVENTF_KEYUP:0);
require(i.ki.dwExtraInfo==ks::inputMarker(),"all generated events tagged");
require(!app.handleKeyboard(event(key,0,true),!up,MOD_CONTROL),"own events bypass handling");
}
require(app.run.keys==keys&&app.inputVersion==version,"own input preserves transaction and run");
app.handleKeyboard(event(VK_BACK),true,0);
require(app.run.keys.empty(),"remote backspace recorded");
app.handleKeyboard(event('B'),true,MOD_SHIFT);
require(!app.run.keys.empty(),"VK-only remote input resolves scan code");
require(app.inputVersion>version,"remote input invalidates pending transaction");
app.handleKeyboard(event(VK_PACKET,0x1e),true,0);
require(app.run.keys.empty(),"Unicode packet is not a physical scan code");
app.handleKeyboard(event('A',0x1e),true,0);
ks::InputRegressionAccess::click();
require(app.run.keys.empty(),"remote mouse resets recorded input");
auto physical=f12; physical.flags=0; physical.dwExtraInfo=ks::inputMarker();
require(app.handleKeyboard(physical,true,MOD_CONTROL),"physical hotkey still works");
app.handleKeyboard(physical,false,0);
auto fitted=ks::fitWindowRect({-2500,-800,-500,1200},{0,0,1200,800});
require(fitted.left==0&&fitted.top==0&&fitted.right==1200&&fitted.bottom==800,"oversized disconnected-monitor window fits work area");
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);
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)};
GetMonitorInfoW(MonitorFromWindow(settings,MONITOR_DEFAULTTONEAREST),&monitor);
require(frame.left>=monitor.rcWork.left&&frame.top>=monitor.rcWork.top&&frame.right<=monitor.rcWork.right&&frame.bottom<=monitor.rcWork.bottom,"initial settings frame fits active work area");
UINT dpi=GetDpiForWindow(settings);
std::cout << "Hidden Settings layout checked at " << dpi << " DPI\n";
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
require(cancel.right-cancel.left==MulDiv(100,dpi,96),"controls use actual window DPI");
SetWindowPos(settings,nullptr,0,0,480,360,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
SCROLLINFO scroll{sizeof(scroll),SIF_ALL};
GetScrollInfo(settings,SB_VERT,&scroll);
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has vertical scrolling");
GetScrollInfo(settings,SB_HORZ,&scroll);
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has horizontal scrolling");
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0);
SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0);
GetClientRect(settings,&client);
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
MapWindowPoints(nullptr,settings,(POINT*)&cancel,2);
require(cancel.left>=0&&cancel.top>=0&&cancel.right<=client.right&&cancel.bottom<=client.bottom,"bottom actions reachable by scrolling");
SendMessageW(settings,WM_VSCROLL,SB_TOP,0);
SendMessageW(settings,WM_HSCROLL,SB_LEFT,0);
SendMessageW(GetDlgItem(settings,IDCANCEL),WM_SETFOCUS,0,0);
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
MapWindowPoints(nullptr,settings,(POINT*)&cancel,2);
require(cancel.left>=0&&cancel.top>=0&&cancel.right<=client.right&&cancel.bottom<=client.bottom,"keyboard focus reveals off-screen action");
SetWindowPos(settings,nullptr,-20000,-20000,0,0,SWP_NOSIZE|SWP_NOZORDER|SWP_NOACTIVATE);
SendMessageW(settings,WM_DISPLAYCHANGE,0,0);
GetWindowRect(settings,&frame);
GetMonitorInfoW(MonitorFromWindow(settings,MONITOR_DEFAULTTONEAREST),&monitor);
require(frame.left>=monitor.rcWork.left&&frame.top>=monitor.rcWork.top&&frame.right<=monitor.rcWork.right&&frame.bottom<=monitor.rcWork.bottom,"display change recovers off-screen window");
DestroyWindow(settings);
DestroyWindow(app.window); app.window=nullptr;
}
std::filesystem::remove_all(temp);
std::cout<<"Passed "<<checks<<" input regression checks\n"; return 0;
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; return 1; }
}
+94 -1
View File
@@ -1,5 +1,7 @@
#include "app.h"
#include <iostream>
#include <fstream>
#include <commctrl.h>
namespace
{
@@ -14,6 +16,12 @@ const std::vector<std::pair<std::wstring, std::wstring>> punctuationCases{
{L"ljvf: ", L"дома: "}, {L"ljvf; ", L"дома; "}, {L"#ljvf%; ", L"#дома%; "}};
ULONGLONG deadline{};
std::wstring sentinel = L"KSwitcher integration clipboard sentinel";
std::string diagnosticLog()
{
std::ifstream file(ks::dataDirectory() / L"kswitcher.log");
return {std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()};
}
size_t diagnosticStart{};
void require(bool ok, const char *name)
{
if (!ok)
@@ -109,10 +117,28 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
require(text() == L"OLDпривет", "recent-input conversion preserves adjacent older text");
prepare(L"OLDabcdef", false);
record(L"ghbdtn");
diagnosticStart = diagnosticLog().size();
app->busy = true;
app->transform(ks::Direct + ru);
app->transform(ks::Direct + ru);
app->busy = false;
app->transform(ks::Direct + ru);
break;
case 6:
require(text() == L"OLDabcdef", "recent-input mismatch refuses replacement");
{
auto diagnostics = diagnosticLog();
diagnostics = diagnostics.substr(std::min(diagnosticStart, diagnostics.size()));
require(diagnostics.find("reason=busy") != std::string::npos,
"busy conversion attempt is logged");
require(diagnostics.find("repeat=3") != std::string::npos,
"three repeated conversion attempts are logged");
require(diagnostics.find("reason=recent-verification-mismatch") != std::string::npos,
"failed recent-word verification has explicit outcome");
require(diagnostics.find("copied=\"abcdef\"") != std::string::npos &&
diagnostics.find("physicalKeys=\"ghbdtn\"") != std::string::npos,
"conversion diagnostics contain actual text and physical input for reproduction");
}
app->settings.snippets = {{L";addr", L"LINE1\r\n{cursor}LINE2"}};
prepare(L"prefix ;addr", false);
record(L";addr");
@@ -237,7 +263,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
return DefWindowProcW(w, msg, wp, lp);
}
} // namespace
int main()
int main(int argc, char **argv)
{
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
auto root = std::filesystem::temp_directory_path() /
@@ -249,6 +275,73 @@ int main()
app = &instance;
app->settings.history = false;
app->settings.switchLayout = false;
if (argc > 1 && std::string(argv[1]) == "--logs-only")
{
ks::showLogs(instance);
HWND viewer = instance.logWindow;
HWND rows = GetDlgItem(viewer, 602);
require(rows && (GetWindowLongPtrW(rows, GWL_STYLE) & LVS_TYPEMASK) == LVS_REPORT,
"log viewer uses report ListView");
require(Header_GetItemCount(ListView_GetHeader(rows)) == 4, "log viewer has four columns");
require(GetDlgItem(viewer, IDCANCEL) != rows, "log rows and Close have different IDs");
SetFocus(rows);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, EN_SETFOCUS), (LPARAM)rows);
NMHDR clicked{rows, 602, NM_CLICK};
SendMessageW(viewer, WM_NOTIFY, 602, (LPARAM)&clicked);
require(IsWindow(viewer), "focus and click notifications do not close log viewer");
ks::showLogs(instance);
require(instance.logWindow == viewer, "opening logs reuses the existing window");
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 603));
require(IsWindow(viewer) && ListView_GetItemCount(rows) > 0, "refresh populates log rows");
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 1, 0);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(601, CBN_SELCHANGE), (LPARAM)GetDlgItem(viewer, 601));
require(IsWindow(viewer) && ListView_GetItemCount(rows) > 0, "previous log or empty state is displayed");
if (argc > 2 && std::string(argv[2]) == "--clear-test")
{
require(std::filesystem::path(ks::executable()).parent_path().filename() == L"log-clear-test",
"clear test runs in isolated executable directory");
const auto logRoot = ks::dataDirectory();
require(logRoot == std::filesystem::path(ks::executable()).parent_path() / L"data",
"clear test uses isolated portable data");
std::ofstream(logRoot / L"kswitcher.log") << "current-marker\n";
std::ofstream(logRoot / L"kswitcher.log.1") << "previous-marker\n";
ks::diagnosticEvent(L"buffered-before-clear");
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(606, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 606));
require(!std::filesystem::exists(logRoot / L"kswitcher.log.1") &&
ks::readDiagnosticLog(false).find(L"current-marker") != std::wstring::npos,
"Clear log removes selected previous log only");
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 0, 0);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(606, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 606));
require(!std::filesystem::exists(logRoot / L"kswitcher.log") && IsWindow(viewer),
"Clear log clears current file without closing viewer");
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(606, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 606));
ks::diagnosticEvent(L"new-problem", true);
auto newLog = ks::readDiagnosticLog(false);
require(newLog.find(L"new-problem") != std::wstring::npos &&
newLog.find(L"buffered-before-clear") == std::wstring::npos,
"logging resumes after clear without stale context");
}
auto savedClipboard = ks::readClipboard(viewer);
instance.window = viewer;
try
{
ListView_SetItemState(rows, 0, LVIS_SELECTED, LVIS_SELECTED);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(605, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 605));
require(ks::readClipboard(viewer).text().find(L'\t') != std::wstring::npos,
"selected log row copies as tab-separated columns");
}
catch (...)
{
ks::writeClipboard(viewer, savedClipboard);
throw;
}
ks::writeClipboard(viewer, savedClipboard);
instance.window = nullptr;
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, BN_CLICKED), (LPARAM)GetDlgItem(viewer, IDCANCEL));
require(!IsWindow(viewer) && !instance.logWindow, "Close button closes and releases viewer");
CoUninitialize();
return 0;
}
for (int i = 0; i < (int)app->layouts.size(); ++i)
{
auto language = LOWORD((uintptr_t)app->layouts[i].handle);
+156
View File
@@ -0,0 +1,156 @@
// Exercise the actual editor controls without capturing the desktop or changing the clipboard.
// Including the implementation lets this integration test drive private editor state directly.
#include "../src/screenshot.cpp"
#include <iostream>
int main()
{
auto temp=std::filesystem::temp_directory_path()/(L"KSwitcher-editor-test-"+std::to_wstring(GetCurrentProcessId()));
try {
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls);
ks::App app(GetModuleHandleW(nullptr),true,temp);
app.window=CreateWindowW(L"STATIC",L"",0,0,0,0,0,HWND_MESSAGE,nullptr,app.instance,nullptr);
ks::Capture c(app);
c.image=std::make_unique<ks::Surface>(320,240);
PatBlt(c.image->dc,0,0,320,240,WHITENESS); c.start={0,0}; c.end={320,240};
WNDCLASSW cls{}; cls.hInstance=app.instance; cls.lpfnWndProc=ks::captureProc;
cls.lpszClassName=L"KSwitcher.Editor.Integration"; RegisterClassW(&cls);
auto window=CreateWindowW(cls.lpszClassName,L"Editor test",WS_POPUP,0,0,320,240,app.window,nullptr,app.instance,&c);
auto require=[](bool ok,const char *message){ if(!ok) throw std::runtime_error(message); };
require(window!=nullptr,"editor creation");
ks::edit(window,c); ShowWindow(window,SW_HIDE);
require(GetDlgItem(window,50)!=nullptr && c.statusHeight>0,"status bar is created");
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
for(int size:{32,48,64,96}) {
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
ICONINFO info{};
require(cursor && GetIconInfo(cursor,&info) && !info.fIcon,"pencil cursor supports standard and high DPI");
require(info.xHotspot<UINT(size/3) && info.yHotspot>UINT(size*2/3),"pencil cursor draws from its tip");
if(info.hbmMask) DeleteObject(info.hbmMask);
if(info.hbmColor) DeleteObject(info.hbmColor);
DestroyCursor(cursor);
}
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
require(std::wstring(windowTitle)==L"KSwitcher — Screenshot","title has no usage hints");
RECT client{}; GetClientRect(window,&client);
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
ks::updateStatus(c);
require(c.statusText.find(L"Freehand")!=std::wstring::npos,"default tool hint is shown");
SendMessageW(GetDlgItem(window,13),WM_MOUSEMOVE,0,0);
require(c.statusText.find(L"Ctrl+C")!=std::wstring::npos,"button hover shows action shortcut");
SendMessageW(GetDlgItem(window,13),WM_MOUSELEAVE,0,0);
require(c.statusText.find(L"Freehand")!=std::wstring::npos,"button leave restores contextual hint");
for(int id:ks::ToolIds) require(GetDlgItem(window,id)!=nullptr,"all toolbar buttons created");
require(GetDlgItem(window,34)!=nullptr && c.toolIcons[0][14]!=nullptr,"font icon created and loaded");
ks::Mark text{{20,20},{20,20},0}; text.fontSize=18;
ks::beginInlineText(window,c,text,-1);
require(c.inlineText!=nullptr,"inline text control opens");
ks::updateStatus(c);
require(c.statusText.find(L"Ctrl+Enter")!=std::wstring::npos,"inline editing hint shows commit and cancel keys");
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"First line\r\nДругий рядок");
require(c.textDraft.text==L"First line\r\nДругий рядок","inline text changes update draft");
SendMessageW(window,ks::FinishTextMessage,1,(LPARAM)c.inlineText);
require(!c.inlineText && c.marks.size()==1 && c.marks[0].text==text.text+L"First line\r\nДругий рядок","commit adds multiline text");
ks::beginInlineText(window,c,c.marks[0],0);
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Cancelled");
SendMessageW(window,ks::FinishTextMessage,2,(LPARAM)c.inlineText);
require(c.marks[0].text!=L"Cancelled","Escape cancels changes");
SendMessageW(window,WM_KEYDOWN,VK_F2,0);
require(c.inlineText!=nullptr,"F2 edits inline");
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Edited"); ks::finishInlineText(window,c,true);
require(c.marks[0].text==L"Edited","existing text edit commits");
SendMessageW(window,WM_COMMAND,12,0);
require(c.marks[0].text!=L"Edited","undo restores original text");
SendMessageW(window,WM_COMMAND,18,0);
require(c.marks[0].text==L"Edited","redo restores text edit");
ks::beginInlineText(window,c,c.marks[0],0);
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L""); ks::finishInlineText(window,c,true);
require(c.marks.empty(),"committing empty text deletes object");
c.marks.push_back({{30,150},{260,150},0}); c.selected=0;
SendMessageW(window,WM_COMMAND,35,0);
require(c.inlineText && c.editingLabel,"label button opens inline editor");
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Arrow caption");
ks::finishInlineText(window,c,true);
require(c.marks.size()==1 && c.marks[0].label==L"Arrow caption" && c.marks[0].isLine(),"label attaches to arrow");
SendMessageW(window,WM_KEYDOWN,VK_F2,0);
require(c.inlineText && c.editingLabel,"F2 edits arrow caption");
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L""); ks::finishInlineText(window,c,true);
require(c.marks.size()==1 && c.marks[0].label.empty(),"empty label removes caption without deleting arrow");
SendMessageW(window,WM_COMMAND,12,0);
require(c.marks[0].label==L"Arrow caption","undo restores arrow caption");
auto caption=ks::screenshotArrowLabel(c.marks[0]);
auto captionGrip=ks::screenshotTextRotationHandle(caption,double(c.unit*0.6)*c.image->width/(c.view.right-c.view.left));
c.selected=0;
require(ks::markAt(c,captionGrip).second==ks::MarkPart::Rotation,"caption rotation handle is interactive");
c.marks[0]=ks::dragScreenshotMark(c.marks[0],ks::MarkPart::Rotation,captionGrip,{captionGrip.x+40,captionGrip.y+20},320,240,true);
auto angle=c.marks[0].rotation;
SendMessageW(window,WM_KEYDOWN,VK_F2,0);
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Rotated caption"); ks::finishInlineText(window,c,true);
require(c.marks[0].rotation==angle && c.marks[0].label==L"Rotated caption","editing caption preserves its rotation");
auto rotating=text; rotating.text=L"Rotate"; c.marks.push_back(rotating); c.selected=1;
auto handle=ks::screenshotTextRotationHandle(rotating,double(c.unit*0.6)*c.image->width/(c.view.right-c.view.left));
require(ks::markAt(c,handle).second==ks::MarkPart::Rotation,"rotation handle is interactive");
c.hoveredPart=ks::MarkPart::Rotation; ks::updateStatus(c);
require(c.statusText.find(L"15-degree")!=std::wstring::npos,"rotation hint explains Shift snapping");
c.hoveredPart=ks::MarkPart::None;
std::vector<ks::Mark> nudgeMarks;
nudgeMarks.push_back({{60,60},{60,60},1});
nudgeMarks.push_back({{60,60},{120,100},0});
auto nudgeText=text; nudgeText.text=L"Move"; nudgeText.from=nudgeText.to={60,60}; nudgeMarks.push_back(nudgeText);
for(auto shape:{ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse}) {
ks::Mark mark{{60,60},{100,100},0}; mark.shape=shape; nudgeMarks.push_back(mark);
}
for(bool freehand:{false,true}) {
ks::Mark mark{{60,60},{120,100},0}; mark.points={{60,60},{90,80},{120,100}}; mark.freehand=freehand;
nudgeMarks.push_back(mark);
}
for(auto mark:nudgeMarks) {
c.marks={mark}; c.selected=0; c.selectedLabel=false;
SendMessageW(window,WM_KEYDOWN,VK_RIGHT,0); SendMessageW(window,WM_KEYDOWN,VK_DOWN,0);
require(c.marks[0].from.x==61 && c.marks[0].from.y==61,"every object nudges one pixel per key");
if(!mark.points.empty()) require(c.marks[0].points[1].x==91 && c.marks[0].points[1].y==81,"nudge moves entire stroke");
SendMessageW(window,WM_KEYDOWN,VK_LEFT,0); SendMessageW(window,WM_KEYDOWN,VK_UP,0);
require(c.marks[0].from.x==60 && c.marks[0].from.y==60,"opposite keys restore original position");
}
ks::Mark nudgeArrow{{60,60},{140,140},0}; nudgeArrow.label=L"Caption"; nudgeArrow.fontSize=12;
c.marks={nudgeArrow}; c.selected=0; c.selectedLabel=true;
auto beforeNudge=ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0]));
for(int i=0;i<5;++i) SendMessageW(window,WM_KEYDOWN,VK_RIGHT,0);
auto afterNudge=ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0]));
require(afterNudge.left==beforeNudge.left+5 && afterNudge.top==beforeNudge.top,
"caption nudges exact screen pixels at a diagonal angle");
require(c.marks[0].from.x==60 && !c.marks[0].labelRotationManual,"caption nudge leaves arrow and automatic angle intact");
SendMessageW(window,WM_COMMAND,12,0);
require(ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0])).left==beforeNudge.left+4,"undo reverses one nudge");
SendMessageW(window,WM_COMMAND,18,0);
require(ks::screenshotTextBounds(ks::screenshotArrowLabel(c.marks[0])).left==beforeNudge.left+5,"redo restores one nudge");
SetWindowPos(window,nullptr,80,60,720,500,SWP_NOZORDER|SWP_SHOWWINDOW);
SetPropW(window,L"ConfirmationCentered",nullptr);
SetTimer(window,987,10,[](HWND owner,UINT,UINT_PTR id,DWORD) {
EnumThreadWindows(GetCurrentThreadId(),[](HWND popup,LPARAM parent)->BOOL {
wchar_t className[64]{}; GetClassNameW(popup,className,64);
if(GetWindow(popup,GW_OWNER)==(HWND)parent && std::wstring(className)==L"#32770") {
RECT ownerRect{},popupRect{};
GetWindowRect((HWND)parent,&ownerRect); GetWindowRect(popup,&popupRect);
if(std::abs(ownerRect.left+ownerRect.right-popupRect.left-popupRect.right)<=4 &&
std::abs(ownerRect.top+ownerRect.bottom-popupRect.top-popupRect.bottom)<=4)
SetPropW((HWND)parent,L"ConfirmationCentered",(HANDLE)1);
PostMessageW(popup,TDM_CLICK_BUTTON,IDNO,0);
}
return TRUE;
},(LPARAM)owner);
KillTimer(owner,id);
});
SendMessageW(window,WM_CLOSE,0,0);
require(IsWindow(window),"declining close confirmation keeps the screenshot open");
require(GetPropW(window,L"ConfirmationCentered")!=nullptr,"confirmation is centered on editor instead of screen");
RemovePropW(window,L"ConfirmationCentered");
c.copied=true;
SendMessageW(window,WM_KEYDOWN,VK_ESCAPE,0);
require(IsWindow(window),"Escape does not close the screenshot editor");
SendMessageW(window,WM_CLOSE,0,0);
require(!IsWindow(window),"copied screenshot closes without confirmation");
DestroyWindow(app.window); app.window=nullptr;
std::filesystem::remove_all(temp);
std::cout<<"Passed screenshot editor UI integration checks\n";
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; return 1; }
}
+48
View File
@@ -44,7 +44,37 @@ int main(int argc, char **argv)
}
try
{
require(ks::diagnosticText(L"word") == L"\"word\" units=4 truncated=0",
"diagnostic word preserved");
require(ks::diagnosticText(L"\"\\\n\r\t") ==
L"\"\\\"\\\\\\u000a\\u000d\\u0009\" units=5 truncated=0",
"diagnostic control characters cannot create log lines");
require(ks::diagnosticText(L"\u0457\u200b") == L"\"\\u0457\\u200b\" units=2 truncated=0",
"diagnostic unicode and invisible characters preserved exactly");
require(ks::diagnosticText(std::wstring(513, L'a')).find(L"units=513 truncated=1") != std::wstring::npos,
"large diagnostic selections are explicitly truncated");
STARTUPINFOW startup{sizeof(startup)};
ks::DiagnosticBuffer diagnostics;
require(diagnostics.record(L"successful-attempt", false, 100).empty(),
"successful diagnostics stay in memory");
auto incident = diagnostics.record(L"failed-attempt", true, 200);
require(incident.find(L"successful-attempt") != std::wstring::npos &&
incident.find(L"failed-attempt") != std::wstring::npos,
"incident includes preceding successful context");
require(diagnostics.record(L"next-failure", true, 300).find(L"successful-attempt") == std::wstring::npos,
"flushed context is not duplicated");
diagnostics.record(L"expired-context", false, 400);
require(diagnostics.record(L"later-failure", true, 30500).find(L"expired-context") == std::wstring::npos,
"context expires after thirty seconds");
diagnostics.record(L"oldest-context", false, 30600);
for (int i = 0; i < 40; ++i)
diagnostics.record(L"bounded-context", false, 30600 + i);
require(diagnostics.record(L"bounded-failure", true, 30700).find(L"oldest-context") == std::wstring::npos,
"context buffer has a fixed size");
diagnostics.record(L"disabled-context", false, 30800);
diagnostics.clear();
require(diagnostics.record(L"new-failure", true, 30900).find(L"disabled-context") == std::wstring::npos,
"clearing diagnostics discards sensitive context");
startup.dwFlags = STARTF_USESHOWWINDOW;
startup.wShowWindow = SW_HIDE;
PROCESS_INFORMATION child{};
@@ -346,6 +376,7 @@ int main(int argc, char **argv)
require(history.entries().size() == 2, "a new screenshot is retained");
}
ks::Settings settings;
settings.writeLog = false;
settings.elevatedAutostart = true;
settings.autoCorrect = true;
settings.autoMaxRank = 10000;
@@ -358,6 +389,13 @@ int main(int argc, char **argv)
settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}};
ks::saveSettings(temp / L"settings.json", settings);
auto restored = ks::loadSettings(temp / L"settings.json", {});
require(ks::encodeSettings(ks::decodeSettings(ks::encodeSettings(settings), {})) == ks::encodeSettings(settings),
"configurator snapshot survives serialization without changing the concurrency token");
rejects([&] { ks::decodeSettings(ks::Bytes{'{', '}'}, {}); },
"configurator rejects missing settings format");
require(!restored.writeLog, "logging setting persists when disabled");
require(ks::loadSettings(temp / L"defaults.json", {}).writeLog,
"logging defaults on for existing diagnostic installations");
require(restored.snippets[0].text == settings.snippets[0].text, "settings snippet roundtrip");
require(restored.cycle == settings.cycle, "layout order roundtrip");
require(restored.elevatedAutostart, "elevated autostart setting roundtrip");
@@ -390,6 +428,16 @@ int main(int argc, char **argv)
require(!ks::loadSettings(temp / L"defaults.json", {}).elevatedAutostart,
"elevated autostart is opt-in for existing and default settings");
auto invalid = settings;
auto captureSettings = settings;
captureSettings.screenshot = L"Ctrl+Shift+F9";
require(ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshot == captureSettings.screenshot,
"screen capture shortcut roundtrip");
captureSettings.screenshot = captureSettings.clipboard;
rejects([&] { ks::validateSettings(captureSettings); }, "screen capture shortcut duplicates rejected");
captureSettings.screenshot.clear();
ks::validateSettings(captureSettings);
require(ks::loadSettings(temp / L"defaults.json", {}).screenshot == L"Win+Shift+S",
"screen capture default for existing settings");
invalid.undo = invalid.toggle;
rejects([&] { ks::validateSettings(invalid); }, "duplicate shortcut rejected");
invalid = settings;
+132
View File
@@ -0,0 +1,132 @@
param([string]$Executable = (Join-Path $PSScriptRoot '../build/KSwitcher.exe'), [switch]$Network)
$ErrorActionPreference='Stop'
. (Join-Path $PSScriptRoot '../resources/update.ps1') -Library
$script:checks=0
function Assert($Value,[string]$Name) { if (!$Value) { throw $Name }; ++$script:checks }
function Reject([scriptblock]$Action,[string]$Name) { $rejected=$false; try { & $Action } catch { $rejected=$true }; Assert $rejected $Name }
$root=Join-Path ([IO.Path]::GetTempPath()) ('KSwitcher-update-tests-'+[guid]::NewGuid().ToString('N'))
[IO.Directory]::CreateDirectory($root) | Out-Null
try {
$binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path)
$binaryVersion = Get-StableVersion $binaryInfo.FileVersion
Assert ($null -ne $binaryVersion) 'executable has a stable version'
Assert ($binaryInfo.FileDescription -ceq ('KSwitcher ' + [char]0x2014 + ' native keyboard and clipboard utility')) 'executable description preserves Unicode em dash'
Assert ((Get-StableVersion 'v0.10.0') -gt (Get-StableVersion 'v0.2.9')) 'numeric versions'
Assert ($null -eq (Get-StableVersion 'v1.0.0-beta')) 'prerelease rejected'
Assert ($null -eq (Get-StableVersion 'v1.0.0;whoami')) 'invalid version rejected'
$assets=@(
@{name='KSwitcher-win-x64.zip';browser_download_url='https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/v0.2.1/KSwitcher-win-x64.zip'},
@{name='SHA256SUMS.txt';browser_download_url='https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/v0.2.1/SHA256SUMS.txt'}
)
$release=@{draft=$false;prerelease=$false;tag_name='v0.2.1';assets=$assets}
Assert ((Select-Release @($release) ([version]'0.2.0')).version -eq 'v0.2.1') 'newer release'
Assert ($null -eq (Select-Release @($release) ([version]'0.2.1'))) 'same version'
Assert ($null -eq (Select-Release @($release) ([version]'0.3.0'))) 'no downgrade'
Assert ((Get-StableVersion 'release-v0.5.0') -eq [version]'0.5.0') 'modern stable release tag'
$modern=@{draft=$false;prerelease=$false;tag_name='release-v0.5.0';assets=$assets}
Assert ((Select-Release @($release,$modern) ([version]'0.4.2')).version -eq 'release-v0.5.0') 'bridge discovers modern channel'
$draft=@{draft=$true;prerelease=$false;tag_name='v9.0.0';assets=$assets}
Assert ((Select-Release @($draft,$release) ([version]'0.2.0')).version -eq 'v0.2.1') 'draft ignored'
Reject { Select-Release @(@{draft=$false;prerelease=$false;tag_name='v2.0.0';assets=@()}) ([version]'0.2.0') } 'incomplete release rejected'
foreach ($url in @('http://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases/download/v1/a','https://evil.example/a','https://gitea.hrynco.com/other/a','https://gitea.hrynco.com:8443/hrynco/hrynco-kswitcher/releases/download/v1/a')) {
Reject { Assert-ReleaseUrl $url } 'untrusted URL rejected'
}
$source=Join-Path $root 'source'; [IO.Directory]::CreateDirectory($source) | Out-Null
Copy-Item -LiteralPath $Executable -Destination (Join-Path $source 'KSwitcher.exe')
$configurator = Join-Path (Split-Path $Executable) 'KSwitcher.Config.exe'
if (Test-Path -LiteralPath $configurator) {
Copy-Item -LiteralPath $configurator -Destination (Join-Path $source 'KSwitcher.Config.exe')
}
$screenshot = Join-Path (Split-Path $Executable) 'KSwitcher.Screenshot.exe'
if (Test-Path -LiteralPath $screenshot) { Copy-Item -LiteralPath $screenshot -Destination (Join-Path $source 'KSwitcher.Screenshot.exe') }
foreach ($name in @('LICENSE','json-LICENSE.txt','README.md')) { [IO.File]::WriteAllText((Join-Path $source $name),'fixture') }
[IO.Directory]::CreateDirectory((Join-Path $source 'dictionaries')) | Out-Null
[IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary')
function Write-TestManifest($Folder) {
$entries=@(Get-ChildItem -LiteralPath $Folder -File -Recurse | Where-Object Name -ne 'release-files.json' | ForEach-Object {
@{path=$_.FullName.Substring($Folder.Length+1).Replace('\','/');size=$_.Length;sha256=(Get-FileHash -LiteralPath $_.FullName).Hash}
})
@{schema=1;version=$binaryVersion.ToString();files=$entries} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $Folder 'release-files.json') -Encoding utf8
}
[IO.File]::WriteAllText((Join-Path $source 'future-tool.exe'),'future companion fixture')
Write-TestManifest $source
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive=Join-Path $root 'release.zip'
[IO.Compression.ZipFile]::CreateFromDirectory($source,$archive)
$sum=Join-Path $root 'SHA256SUMS.txt'
((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash+' KSwitcher-win-x64.zip') | Set-Content -LiteralPath $sum
Assert-Checksum $archive $sum; ++$script:checks
& {
function Get-FileHash { throw 'Get-FileHash unavailable in inherited module environment' }
Assert-Checksum $archive $sum
}
++$script:checks
$stage=Join-Path $root 'stage'
Expand-Release $archive $stage $binaryVersion; ++$script:checks
Reject { Expand-Release $archive (Join-Path $root 'wrong-version') ([version]'9.0.0') } 'version mismatch rejected'
('0'*64+' KSwitcher-win-x64.zip') | Set-Content -LiteralPath $sum
Reject { Assert-Checksum $archive $sum } 'corrupt package rejected'
$bad=Join-Path $root 'bad.zip'
$z=[IO.Compression.ZipFile]::Open($bad,[IO.Compression.ZipArchiveMode]::Create)
$null=$z.CreateEntry('../escape.exe'); $z.Dispose()
Reject { Expand-Release $bad (Join-Path $root 'badstage') ([version]'0.2.1') } 'zip traversal rejected'
foreach ($unsafe in @('../escape','C:/escape','data/settings.json','DATA/history.bin','folder/../escape','folder/file:stream','CON.txt','folder/file.','/absolute','folder\file')) {
Reject { Assert-PackagePath $unsafe } 'unsafe or protected manifest path rejected'
}
$corrupt=Join-Path $root 'corrupt.zip'
Copy-Item -LiteralPath $archive -Destination $corrupt
$z=[IO.Compression.ZipFile]::Open($corrupt,[IO.Compression.ZipArchiveMode]::Update)
$z.GetEntry('future-tool.exe').Delete()
$writer=[IO.StreamWriter]::new($z.CreateEntry('future-tool.exe').Open()); $writer.Write('tampered'); $writer.Dispose(); $z.Dispose()
Reject { Expand-Release $corrupt (Join-Path $root 'corruptstage') $binaryVersion } 'manifest detects tampered companion'
$extra=Join-Path $root 'extra.zip'; Copy-Item -LiteralPath $archive -Destination $extra
$z=[IO.Compression.ZipFile]::Open($extra,[IO.Compression.ZipArchiveMode]::Update)
$null=$z.CreateEntry('undeclared.dll'); $z.Dispose()
Reject { Expand-Release $extra (Join-Path $root 'extrastage') $binaryVersion } 'undeclared file rejected'
$target=Join-Path $root 'install'; $backup=Join-Path $root 'backup'
[IO.Directory]::CreateDirectory((Join-Path $target 'data')) | Out-Null
[IO.Directory]::CreateDirectory((Join-Path $target 'dictionaries')) | Out-Null
[IO.File]::WriteAllText((Join-Path $target 'data/settings.json'),'personal settings')
[IO.File]::WriteAllText((Join-Path $target 'dictionaries/en.txt'),'personal dictionary')
[IO.File]::WriteAllText((Join-Path $target 'KSwitcher.exe'),'old binary fixture')
$changed=[Collections.Generic.List[string]]::new()
Install-Files $stage $target $backup $changed
Assert ((Get-FileHash -LiteralPath (Join-Path $target 'KSwitcher.exe')).Hash -eq (Get-FileHash -LiteralPath $Executable).Hash) 'replacement installed'
if (Test-Path -LiteralPath $configurator) {
Assert ((Get-FileHash -LiteralPath (Join-Path $target 'KSwitcher.Config.exe')).Hash -eq (Get-FileHash -LiteralPath $configurator).Hash) 'configurator installed'
}
Assert ([IO.File]::ReadAllText((Join-Path $target 'future-tool.exe')) -eq 'future companion fixture') 'new companion name installs without changing updater'
Assert ([IO.File]::ReadAllText((Join-Path $target 'data/settings.json')) -eq 'personal settings') 'settings preserved'
Assert ([IO.File]::ReadAllText((Join-Path $target 'dictionaries/en.txt')) -eq 'personal dictionary') 'dictionary preserved'
Undo-Files $target $backup $changed
Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'rollback restores original'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'README.md'))) 'rollback removes newly introduced files'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Config.exe'))) 'rollback removes newly introduced configurator'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Screenshot.exe'))) 'rollback removes newly introduced screenshot editor'
# Fail after the original exe has moved to backup, as if the staged file vanished.
$stage2=Join-Path $root 'stage2'; [IO.Directory]::CreateDirectory($stage2) | Out-Null
[IO.File]::WriteAllText((Join-Path $stage2 'KSwitcher.exe'),'new binary fixture')
$locked=[IO.File]::Open((Join-Path $stage2 'KSwitcher.exe'),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None)
$changed2=[Collections.Generic.List[string]]::new()
try { Reject { Install-Files $stage2 $target $backup $changed2 } 'locked staged file fails safely' } finally { $locked.Dispose() }
Undo-Files $target $backup $changed2
Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'partial install rollback'
if ($Network) {
$metadata=Join-Path $root 'live-releases.json'
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' $metadata 2MB
$live=Select-Release (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json) ([version]'0.0.0')
Assert ($null -ne $live) 'live repository exposes a complete stable release'
$liveZip=Join-Path $root 'live.zip'; $liveSum=Join-Path $root 'live-sums.txt'
Receive-File $live.zip $liveZip 50MB
Receive-File $live.checksum $liveSum 64KB
Assert-Checksum $liveZip $liveSum; ++$script:checks
Expand-Release $liveZip (Join-Path $root 'live-stage') (Get-StableVersion $live.version); ++$script:checks
Reject { Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/kswitcher-updater-missing-test-repository' (Join-Path $root 'missing') 2MB } 'server failure reported'
Write-Output "Downloaded and validated $($live.version) in a disposable test directory; no application was updated."
}
Write-Output "Passed $script:checks updater regression checks"
} finally {
if ([IO.Path]::GetDirectoryName($root) -eq [IO.Path]::GetTempPath().TrimEnd('\') -and (Split-Path -Leaf $root) -like 'KSwitcher-update-tests-*') {
Remove-Item -LiteralPath $root -Recurse -Force
}
}

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