Compare commits

...

20 Commits

Author SHA1 Message Date
agrynco 77f27ea087 Release v0.6.12: improve continuous correction and resize log viewer 2026-10-30 00:15:09 +02:00
agrynco c326db7dc5 Release v0.6.11: show branded update progress and completion 2026-10-29 23:03:06 +02:00
agrynco 8b5f632ad7 Release v0.6.10: preserve typing during correction and add personal dictionaries 2026-10-29 22:44:08 +02:00
agrynco 5c786e12a9 Release v0.6.9: notify about updates and fix Settings scroll flicker 2026-10-29 22:15:30 +02:00
agrynco 107d2f8852 Release v0.6.8: add background image OCR and optional Tesseract 2026-10-29 21:40:29 +02:00
agrynco 95c7199027 Release v0.6.7: add DPI-aware cursors for screenshot tools 2026-10-29 18:17:54 +02:00
agrynco 8fedb54dfe Release v0.6.6: add pointer tool and isolate text rotation defaults 2026-10-29 17:57:28 +02:00
agrynco bbf20e8270 Release v0.6.5: preserve caption links and connect arrows to other objects 2026-10-29 17:15:47 +02:00
agrynco b8bb14a985 Release v0.6.4: preserve connector links when dragging numbered markers 2026-10-29 15:14:44 +02:00
agrynco d28e28c03c Release v0.6.3: fix capture banner ordering and check updates periodically 2026-10-29 13:06:58 +02:00
agrynco 6b180798f5 Release v0.6.2: configurable screenshot editor and DPI fixes 2026-10-29 12:41:48 +02:00
agrynco 21a09e33be Release v0.6.1 with Win+V default and bilingual wiki links 2026-10-29 00:34:27 +02:00
agrynco 3dbd7317f1 Style development support with branded donation cards 2026-10-29 00:19:34 +02:00
agrynco 510d0ed211 Add Monobank support link to README 2026-10-29 00:16:36 +02:00
agrynco dd9629f8fd Release v0.6.0: screenshot notifications with previews and responsive clipboard history 2026-10-29 00:01:04 +02:00
agrynco ccaa8dad3f Release v0.5.0: isolate screenshot editor and support manifest-based updates 2026-10-28 22:52:52 +02:00
agrynco d1eabfca88 Release v0.4.1: harden update checksum and fix Settings overlap 2026-10-28 22:22:08 +02:00
agrynco dc615d0223 Release v0.4.0: add screen capture and annotation editor 2026-10-28 22:15:59 +02:00
agrynco e5ff9495e4 Release v0.3.0: move settings into an on-demand single-instance configurator 2026-10-28 19:51:18 +02:00
agrynco eb30701e07 Release v0.2.3: fix Settings overlap and repeated-conversion logging 2026-10-28 13:36:42 +02:00
122 changed files with 6491 additions and 242 deletions
+32 -8
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.2.2 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.12 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
@@ -8,23 +8,46 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp)
target_include_directories(kscore PUBLIC src PRIVATE vendor)
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/ui.cpp resources/app.rc)
target_link_libraries(kscore PUBLIC user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
target_link_libraries(KSwitcher PRIVATE kscore)
add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
set_target_properties(KSwitcherConfig PROPERTIES OUTPUT_NAME "KSwitcher.Config")
target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
target_link_libraries(KSwitcherConfig PRIVATE kscore)
add_executable(KSwitcherTests tests/tests.cpp)
target_link_libraries(KSwitcherTests PRIVATE kscore)
add_executable(KSwitcherIntegration tests/integration.cpp src/app.cpp src/ui.cpp resources/app.rc)
add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
add_executable(KSwitcherInputTests tests/input.cpp src/app.cpp src/ui.cpp resources/app.rc)
add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherInputTests PRIVATE kscore)
add_executable(KSwitcherOcrTests tests/ocr.cpp)
target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES)
add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp resources/app.rc)
target_link_libraries(KSwitcherScreenshotUITests PRIVATE kscore)
target_compile_definitions(KSwitcherScreenshotUITests PRIVATE KS_SCREENSHOT_RESOURCES)
add_executable(KSwitcherScreenshot WIN32 src/screenshot_main.cpp src/screenshot_notifications.cpp src/screenshot_activation.cpp src/screenshot.cpp resources/app.rc)
set_target_properties(KSwitcherScreenshot PROPERTIES OUTPUT_NAME "KSwitcher.Screenshot")
target_compile_definitions(KSwitcherScreenshot PRIVATE KS_SCREENSHOT_RESOURCES)
target_link_libraries(KSwitcherScreenshot PRIVATE kscore)
if(MSVC)
target_compile_options(KSwitcherScreenshotUITests PRIVATE /utf-8)
set_property(TARGET KSwitcherScreenshotUITests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
else()
target_link_options(KSwitcherScreenshotUITests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
add_executable(KSwitcherOcrTests tests/ocr.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
if(MSVC)
set_property(TARGET kscore KSwitcher KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
target_compile_options(kscore PRIVATE /W4 /utf-8)
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
target_compile_options(KSwitcherScreenshot PRIVATE /W4 /utf-8)
else()
target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherScreenshot PRIVATE -municode -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
@@ -33,9 +56,10 @@ endif()
enable_testing()
add_test(NAME native-regressions COMMAND KSwitcherTests)
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
install(TARGETS KSwitcher RUNTIME DESTINATION .)
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .)
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
+47
View File
@@ -19,3 +19,50 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Third-party toolbar icons: Lucide (https://lucide.dev)
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+209 -17
View File
@@ -4,7 +4,11 @@ Native C++20 / Win32 rewrite of the Windows functionality of [Typlo](https://git
No .NET, Avalonia, WebView, Qt, external graphics engine, or separately installed C++ runtime.
The original [Typlo](https://github.com/bitcodepro/Typlo) project and its data remain separate.
**[User guide / Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki)** — setup, features, shortcuts, dictionaries, screenshots and troubleshooting (Ukrainian).
**[User guide / Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki)** — setup, features, shortcuts, dictionaries, screenshots and troubleshooting in **English** and **[Українська](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Home-uk)**. Each wiki page links to its translation.
[![Support KSwitcher development — buy the developer a coffee](resources/support/donate-en.png)](https://send.monobank.ua/jar/88h2TddKfS)
[Donate via Monobank](https://send.monobank.ua/jar/88h2TddKfS) · Thank you for supporting KSwitcher.
## Run
@@ -12,6 +16,29 @@ Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the e
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
Double-click the tray icon to open Settings, the bold default menu item.
Settings, the snippet editor and log viewer run on demand in **KSwitcher.Config.exe**, beside the main executable.
Repeated launches activate the existing configurator; closing its last window exits that process.
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
load clipboard history, dictionaries or global input hooks. Clipboard history remains
in the resident app. Screen capture and all screenshot editors run on demand in
**KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same
process. Closing the last editor (or cancelling the only selection) exits that process. It does not
load resident settings, dictionaries, clipboard history or keyboard hooks. Editor windows have
stable numbered titles within that process. Keep all three executables together when copying or
updating the application. Close screenshot editors before installing an update.
If settings change in the tray while the configurator is open, a conflicting Save is rejected; reopen
the configurator to load the latest settings.
Updates from v0.3.x–v0.4.1 first install the compatible **v0.4.2 bridge** through
Update in the tray menu. Select Update again to install the full current release;
manual extraction is not required. Future full releases use stable `release-vX.Y.Z`
tags, which the bridge and newer clients recognize. Older clients ignore those tags
and continue to find the bridge instead of skipping it. Versions v0.2.3 and earlier
still require the previously documented initial configurator migration.
Modern ZIPs contain `release-files.json` (schema, version, file paths, sizes and SHA-256
hashes). The updater validates that every packaged file is declared, all hashes match,
and paths stay within the installation directory. `data/` is protected; existing
dictionaries are preserved. Adding a new program file no longer needs a new updater.
In Settings, enable **Start with Windows** and **Start with Windows as administrator**
to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing
the elevated task requests UAC approval if the app is not already elevated. No password is stored;
@@ -43,12 +70,129 @@ keys cannot trigger recursive commands or overwrite the recorded typing run. Oth
is accepted, including key-up events and VK-only keyboard events. Unicode packets do not provide
physical key positions and therefore reset recent-word tracking; selection conversion still works.
### Screen capture and annotations
Press **Win+Shift+S**, choose **Capture screen region…** in the tray menu, or run
`KSwitcher.exe --capture`. Drag a rectangle on the dimmed, frozen desktop; the selected area stays
at normal brightness. Escape cancels. Selection and annotation frames are buffered to avoid flicker.
The selected area is copied immediately, the fullscreen selection is hidden, and a Windows notification
is sent. Click the notification to open the editor. Enable **Open editor after screen capture** in settings
to open it automatically; this option is off by default. An immediate notification error still opens the editor.
Existing saved preferences are preserved. Windows Do Not Disturb can send notifications directly to the
notification center without showing a banner.
The preference applies to each new capture, including while other editors are open.
KSwitcher also sends a notification with a thumbnail. Notification identity and activation are registered
at resident startup and refreshed before sending; registration/submission errors are written to the log.
Click the notification to reopen the original screenshot, including after the screenshot process has exited.
Originals in the local notification cache are DPAPI-encrypted. Thumbnail PNGs live in a separate
per-user cache under ProgramData, readable only by that user and SYSTEM, so the Windows notification
broker can load them even when the profile directory is inaccessible to it.
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
The editor starts with **Pencil / Freehand (F)** for drawing. Choose **Pointer (V)** to select objects, move them, and edit geometry, thickness and
other properties using handles and controls. Clicking empty space clears selection; dragging empty
space adds nothing. Choose a drawing tool to create objects, then return to **V** to edit them.
The cursor identifies the active tool: pointer, text caret, pencil, or a crosshair with the drawing
tool symbol. Move/resize/thickness/rotation handles override it with their action cursor. Custom
cursors scale with display DPI and use a contrasting outline for light and dark images.
To annotate: **N** places sequential numbered red markers with a click, and **A** draws
red arrows by dragging from the tail to the tip. With **Pointer (V)**, click an existing marker or arrow to select it;
drag its body to move it. Drag the square handle on a number's right edge to change its diameter
(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.
Arrow and line endpoints can attach to numbered markers, rectangles, circles, ellipses, text blocks,
and other arrows or strokes. In Arrow or Straight line mode, hold **Ctrl** and drag from one object
to another to draw over them and automatically attach both endpoints. Without Ctrl, existing objects
can still be selected and edited as before. With Pointer, drag an existing endpoint onto a target to attach it. Attached endpoints follow movement and resizing; text attachments
also follow rotation. Arrow-to-arrow attachments retain their relative position along the target line.
Clicking a connector or adding/editing its caption preserves its links. Moving the whole connector
intentionally detaches its endpoints; moving one endpoint detaches only that end. Deleting a target
leaves connected endpoints at their last position. Self-links and circular dependency chains are rejected.
The compact toolbar uses [Lucide](https://lucide.dev) icons with hover tooltips showing each action
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.
New standalone text starts horizontally, even after editing an angled arrow caption. Font styling
is reused; rotation belongs to each individual object.
Text has a rotation handle above its selection. Drag to rotate; hold Shift to snap to 15-degree steps.
Double-click an arrow, press F2, or use the label icon to add/edit its caption. Captions follow
the arrow when moved or resized; drag the caption to adjust its offset. By default, the caption
angle follows the line (or middle curve segment) and stays upright. Rotating its handle manually
fixes its angle; Shift snaps to 15-degree steps. Undo restores automatic mode. The Aa icon changes
the selected caption font, and the mouse wheel over it changes its size. Captions and rotation
are included in clipboard export, cloning and Undo/Redo.
**Straight line (L)** creates a line without arrowheads. **Rectangle (R)**, **Circle (O)** and
**Ellipse (E)** create transparent shapes with colored outlines. Drag the two corner handles to
resize; circles keep equal width and height. Move shapes by their outlines, adjust thickness with
the mouse wheel or side handle, recolor, clone with Ctrl+D, and undo/redo as with other objects.
Hold Ctrl to start drawing over an existing object. Line style applies to outlines; Ends is disabled
for shapes.
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
move it, change its color or thickness, clone it, or drag its endpoints. Hold Ctrl to start a new
stroke over an existing object. The **Line style** selector supports Solid, Dotted, Dashed and
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
The window close button asks for confirmation before discarding an uncopied screenshot.
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy keeps the editor open and publishes the image
at its original pixel size, ready to paste. The preview fits the window without changing export size.
The shortcut is configurable in Settings under **Screen capture**.
Each capture opens an independent editor available through Alt+Tab; use the shortcut again to take another screenshot while
keeping earlier editors and their annotations open.
Captures use ordinary clipboard history settings and limits. Protected video and secure desktops cannot be captured.
### Updates
Select **Update** in the tray menu to check the stable Releases of
[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
A background check runs shortly after startup and every 6 hours while KSwitcher is running.
It reports each available version once per session and never installs automatically.
Update availability uses the same Windows toast notification channel as screenshot notifications.
Click the notification or **Review update** to check the latest release and confirm installation;
**Later** dismisses it. Windows Do Not Disturb may send it directly to Notification Center.
Failed notification submissions are logged and retried after five minutes instead of being marked
as delivered. Check results and successful submissions are recorded in the log when logging is enabled.
Network failures stay in diagnostics; the next scheduled check retries. You can also check manually at any time.
Confirm the offered version to download and install it. A progress window shows
download percentage and received/total size, package verification,
installation and restart. After the new process confirms startup, that window shows the installed
version and a Done button; it does not send a separate completion notification. If installation
fails after KSwitcher closes, the same window shows recovery and the error. Background checks
remain silent unless a new version is available. This progress UI is supplied by the updater in
the currently installed build, so it becomes available for subsequent updates after installing it.
The updater uses the existing
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
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;
@@ -62,9 +206,12 @@ intact and is reported when Update is selected. Startup network failures remain
The helper uses Windows' built-in Windows PowerShell 5.1; no additional runtime is installed.
Settings and the other utility windows use the destination monitor's DPI for their initial frame,
controls and fonts. They can be resized, stay within a remaining monitor's work area after display
changes, and provide scrollbars when their content does not fit. Tab navigation scrolls the focused
controls and fonts. Settings has a fixed form size, without manual resizing or maximization; other
utility windows remain resizable. Windows stay within a remaining monitor's work area after display
changes and provide scrollbars when their content does not fit. Tab navigation scrolls the focused
field into view; Shift+wheel scrolls horizontally.
Clipboard history also updates its preview row heights when DPI changes, keeping text,
thumbnails and source labels aligned with the scaled fonts and spacing.
## Implemented
@@ -89,16 +236,33 @@ field into view; Shift+wheel scrolls horizontally.
stylesheet rules are not reproduced; preview text uses the list's colors for readability.
- Consecutive screenshot copies are compared by image content, ignoring changing OLE transport
metadata (including Snipping Tool's repeated clipboard publication).
- Windows OCR, entirely native and offline. Right-click an image in history and select
**Copy text from image**. Requires a Windows OCR language installed for the user profile.
- New images are recognized automatically in a separate background worker using native, offline
Windows OCR. Recognized text is saved in a per-user DPAPI-encrypted sidecar and included in search,
without changing the image or clipboard. Completed results (including empty text) are reused;
pending work resumes after restart. Older images without OCR are processed once in the background,
starting with the newest entries.
Right-click an image and select **Copy text from image** to copy the saved text or explicitly
retry recognition. Requires a Windows OCR language installed for the user profile; automatic
failures are logged quietly. OCR sidecars count toward the history size limit and are removed
with their images.
- Optional Tesseract 5 support is available in **Settings → OCR options…**. Windows OCR remains
the default and has no Tesseract dependency. Install the 64-bit Tesseract 5 Windows package
separately, then enable it and choose language codes such as `ukr+eng+rus`. Leave the executable
path blank to detect a standard installation, or select `tesseract.exe`; its neighboring
`libtesseract-5.dll`, dependencies and `tessdata` language files are required. **Check installation**
validates the selected models. **Download Tesseract…** opens the Windows installer page in your
browser. No packages or models are downloaded automatically.
Tesseract processes screenshots locally in memory with a 30-second recognition deadline.
Changing engine, executable path or languages refreshes cached image text in the background.
- History exclusion flags and excluded processes, pause/resume, count/size limits.
- Per-user Windows DPAPI encryption for both history payloads and the preview index.
- Autostart, keep-screen-awake, idle-only F15 activity signal, settings import/export.
- Single instance and `--history`, `--convert`, `--case`, `--transliterate`, `--settings`,
`--snippets`, `--exit` commands. `--convert` cycles layouts.
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).
Default text shortcuts match [Typlo](https://github.com/bitcodepro/Typlo): Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
Z (undo), and 1/2/3 (first layouts). Clipboard history defaults to **Win+V** for new settings;
existing saved shortcuts are preserved. While KSwitcher runs, this binding opens its history instead of Windows clipboard history.
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
@@ -112,7 +276,7 @@ only bounded previews/metadata stay in memory. Image data is loaded on demand an
the history popup is hidden. Settings **Write log** (`writeLog`, enabled by default) controls all
log writes. Disabling it also clears diagnostic context from memory; existing log files remain.
Successful operations stay in a memory buffer of at most 40 diagnostic events for 30 seconds.
Only failures or suspected repeated commands flush this context to `kswitcher.log`; flushed
Only failures flush this context to `kswitcher.log`; flushed
context is discarded. Ordinary application errors are also logged when logging is enabled.
Incident context contains tracked physical input, the expected word, copied text, conversion result,
and layout IDs, correlated by request ID even when verification fails. Text fields use quoted,
@@ -121,12 +285,14 @@ original length and a truncation flag; each field is limited to 512 UTF-16 code
Manual conversion attempts in `kswitcher.log` have request IDs and explicit outcomes, including
busy, empty copy, verification mismatch, unchanged conversion, and cancelled paste. Requests
record the process, window, input version, and repeated attempts within ten seconds without new
input. A second repeated manual command triggers a suspicion, not proof of failure; intentional
layout cycling can also trigger it. An unchanged result containing no letters, or a direct command
input. Successful repeated commands do not flush the log; intentional layout cycling is normal.
An unchanged result containing no letters, or a direct command
to the detected current layout, is treated as expected. `paste-sent-unverified` means Windows
accepted the paste shortcut; it does not prove that the editor applied the replacement.
The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates.
Open **View log…** from Settings or the tray menu to read logs inside KSwitcher.
The viewer runs in the configurator process so log loading, painting and pointer interaction do not
block the resident keyboard/mouse hook thread. Clear requests are still serialized by the resident.
Choose the current or previous log and press **Refresh** to reload. The ListView shows Time,
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.
@@ -187,15 +353,27 @@ after Space, not immediately after a punctuation key. Save Settings to apply cha
**Frequency rank limit** defaults to **0 (entire dictionary)**. A positive value allows corrections
only to words with a rank at or below that number (for example, 10000). All dictionary words still
protect already valid input, and ambiguous alternatives remain unchanged regardless of this limit.
**My dictionary** provides separate English, Ukrainian and Russian fields for correct words missing
from the bundled dictionaries. Enter words separated by commas or new lines, then click OK and Save
in Settings. Case and duplicates are normalized automatically; no file editing or ranks are needed.
These entries are stored in settings and survive dictionary updates. They protect correct input and
are eligible as correction targets at any frequency limit. The language and target layout must still
be enabled. **Never correct these words** is a separate exception list and takes precedence.
**Open dictionary folder** opens the files beside the running executable. After editing, click OK
in the options window and Save in Settings to validate and reload the dictionaries without restarting.
Use UTF-8, one lowercase word followed by a Tab and a positive rank per line; keep words sorted and
unique. For a personal addition, rank 1 makes it eligible at every positive limit. Back up your edited
Use UTF-8, one lowercase word followed by a Tab and a positive rank per line. Entries can be appended;
loading sorts them and merges duplicates using the lowest rank. Back up your edited
files before replacing dictionaries with a new distribution.
After Space and a 250 ms typing pause, KSwitcher checks the last physically typed word against local
dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type cancels pending work.
It only corrects unknown words when exactly one alternative is a dictionary word.
On Space, KSwitcher checks the last physically typed word against preloaded local dictionaries.
For example, `ghbdsn ` can become `привіт `. A confirmed candidate immediately queues subsequent
key presses and releases; no typing pause is required. Queued input is replayed after
clipboard restoration and layout switching, preserving typing order, modifiers and Backspace.
If the active window changes, queued typing is not sent to another window and a warning is shown.
It only corrects unknown words when the dictionary alternatives agree on the resulting text.
Identical Ukrainian/Russian spellings such as `ntcn` → `тест` are accepted, using the first matching
language in the configured dictionary order (the UI uses English, Ukrainian, Russian).
Different valid spellings remain ambiguous and are left unchanged.
**Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation
does not count toward this minimum; the maximum word length is 32 letters.
Known words, ambiguous alternatives, ALL CAPS, mixed-case identifiers, numbers, URLs and email
@@ -227,6 +405,7 @@ Run `./package.ps1 -PublishRelease` after building and testing to refresh the tr
.\build.ps1 -Test # Pure/native regression checks; does not touch the user's clipboard
.\build.ps1 -Ocr # Render a synthetic image, decode it with WIC and recognize it with Windows OCR
.\build.ps1 -Integration # Interactive, isolated test editor; avoid input for about 10 seconds
.\tests\configuration.ps1 # Separate-process settings tests; close KSwitcher first
```
`-Test` also checks remote/own input separation, hidden Settings layout and scrolling, and updater
@@ -255,9 +434,22 @@ It still uses the normal instance lock; close a running KSwitcher first if measu
- `platform.*`: Win32 layouts, clipboard, input, ownership, DPAPI and file primitives.
- `storage.*`: settings and encrypted, disk-backed history.
- `app.*`: message loop, hooks, background work and guarded text transactions.
- `config_main.cpp`, `configuration.*`: single-instance configurator and validated settings IPC.
- `ui.cpp`: native windows and shortcut recording.
- `ocr.*`: Windows WIC and OCR via the native Windows Runtime ABI.
- `tests/`: regression checks, real editor integration and OCR fixture.
Application code is MIT licensed; preserves the original [Typlo](https://github.com/bitcodepro/Typlo) attribution in `LICENSE`.
Dictionary data has its own CC BY-SA 4.0 license as described above.
The screenshot process lifecycle can be checked interactively with `tests/screenshot_process.ps1`
against a staged build. It briefly opens real selection/editor windows without changing the clipboard.
### Release channel rule
Keep `v0.4.2` as the last legacy `vX.Y.Z` release. Publish full packages as
`release-vX.Y.Z` (stable, not prerelease), including `release-files.json`,
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt`. Never publish a manifest package under a
legacy version tag: clients that have not installed the bridge would select it.
Keep the compatibility bridge in the first 100 releases returned by Gitea while
supporting old clients; their immutable update code only reads that page.
Binary file not shown.
+14 -18
View File
@@ -1,31 +1,27 @@
# Latest Windows release
## v0.2.2 — 2026-10-28
## v0.6.12 — 2026-10-30
- Fix the garbled dash in the executable description shown by Task Manager and Windows file properties.
- Explicitly compile the UTF-8 Windows resource source as UTF-8, both in the resource file and the portable build command.
- Add regression checks against the compiled executable's version and exact Unicode description.
## Changes
- Start automatic layout correction immediately after Space and preserve subsequent typing while the correction runs.
- Accept identical corrected words shared by multiple selected languages; use the selected language order to choose the target layout. Different valid spellings remain ambiguous.
- Run the log viewer in the configurator process, separate from the resident input hooks. Clearing logs remains coordinated with the resident process.
- Keep the native four-column ListView. Resize the table with its window, anchor the footer and Close button, and make Details fill the remaining width.
- Exclude copied diagnostic rows from clipboard history.
Verified: 168 native checks, 51 input/layout checks, and 26 updater/metadata checks.
The executable description is `KSwitcher — native keyboard and clipboard utility`.
## Validation
Native and input regression suites, screenshot UI and updater checks, log-viewer integration, continuous-typing integration, and isolated configurator/log process checks. Manual development-build checks covered window dragging and automatic Details column width.
Download **KSwitcher-win-x64.zip**, extract the entire archive to a writable folder,
and launch `KSwitcher.exe`. Keep `dictionaries/` next to the executable.
The archive contains the application, offline dictionaries, documentation and license notices.
It does not contain personal settings or clipboard history.
`SHA256SUMS.txt` contains the archive checksum.
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
From version 0.2.1 onward, use **Update** in the tray for future releases. Startup checks notify;
installation starts only after confirming Update. Older versions need this one manual upgrade:
exit KSwitcher and replace the application files, retaining `data/` and any customized dictionaries.
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, Tesseract installation or language models.
This folder holds the latest compiled version; older versions remain in Git history and Releases.
To refresh the tracked package from source:
Build and package:
```powershell
.\build.ps1 -Stage -Test
.\package.ps1 -PublishRelease
```
Commit the ZIP and checksum together with their source changes. Publish both files as assets of
one stable release tagged with the executable version, so the updater can verify it.
Commit the archive, checksum and sources together. Publish both assets on a stable `release-vX.Y.Z` tag matching the executable version. Keep `v0.4.2` as the last legacy tag.
+1 -1
View File
@@ -1 +1 @@
9705cdb0c0a732eeae10b88e42101f9ff6714399ea8779e154fecc79269059a0 KSwitcher-win-x64.zip
866de2d1d6fe121a7845760519f934bf0bd7820bbd11734a14c454b69a6bb8ad KSwitcher-win-x64.zip
+34 -6
View File
@@ -13,37 +13,65 @@ New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
Push-Location $root
try {
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
$libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','app','ui','main')) {
$libs = @('-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
}
& $compiler @common -DKS_RESIDENT_APP -c src/ui.cpp -o build/ui-resident.o
if ($LASTEXITCODE) { throw 'Resident UI compilation failed' }
& $compiler @common -DKS_CONFIG_APP -c src/ui.cpp -o build/ui-config.o
if ($LASTEXITCODE) { throw 'Configurator UI compilation failed' }
& windres --codepage=65001 resources/app.rc -O coff -o build/app-res.o
if ($LASTEXITCODE) { throw 'Resource compilation failed' }
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
New-Item -ItemType Directory -Force build/release | Out-Null
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/app.o build/ui.o build/main.o build/app-res.o @libs -o $executablePath
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
if ($LASTEXITCODE) { throw 'Link failed' }
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
if ($LASTEXITCODE) { throw 'Configurator link failed' }
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
if ($Test) {
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
if ($LASTEXITCODE) { throw 'Test build failed' }
& .\build\KSwitcherTests.exe
if ($LASTEXITCODE) { throw 'Tests failed' }
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/app.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherInputTests.exe
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input test build failed' }
& .\build\KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input tests failed' }
& $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
& .\build\KSwitcherScreenshotUITests.exe
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
try {
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
if ($LASTEXITCODE) { throw 'Screenshot registration test build failed' }
& $registrationTest
if ($LASTEXITCODE) { throw 'Screenshot registration tests failed' }
} finally { Remove-Item -LiteralPath $registrationTest -ErrorAction SilentlyContinue }
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
if ($LASTEXITCODE) { throw 'Updater tests failed' }
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update_progress.ps1
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
}
if ($Integration) {
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/app.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherIntegration.exe
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration test build failed' }
& .\build\KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration tests failed' }
}
if ($Ocr) {
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/ocr.o @libs -static -s -o build/KSwitcherOcrTests.exe
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherOcrTests.exe
if ($LASTEXITCODE) { throw 'OCR test build failed' }
& .\build\KSwitcherOcrTests.exe
if ($LASTEXITCODE) { throw 'OCR tests failed' }
+12 -2
View File
@@ -17,9 +17,19 @@ replace counts with original frequency ranks, sort lexically. Reproduce with
hashes. The lists contain word forms from subtitles, not a complete spelling
or grammar dictionary. Recognition can miss names, jargon and uncommon forms.
File format: UTF-8 word, TAB, positive original frequency rank, newline.
Supplemental Ukrainian vocabulary: `реліз`. These additions use rank 50001,
after the upstream 50k list, rather than claiming a measured corpus frequency.
They are included with the default unlimited rank setting and recorded in sources.json.
File format: UTF-8 word, TAB, positive frequency rank (or supplemental rank), newline.
All entries protect known words. The frequency rank limit in Auto-correction options defaults to 0
(entire dictionary); a positive limit only permits correction candidates at or below that rank.
Personal words may use rank 1. Keep entries lowercase, unique and sorted, with a Tab separator.
Prefer Settings > Auto-correction options > My dictionary for personal words.
Enter correct spellings in the matching language field, separated by commas or new lines.
These words are stored in settings, survive dictionary updates, and need no ranks or sorting.
Click OK, then Save in Settings to apply them.
For advanced file edits, personal words may use rank 1. Keep entries lowercase with a Tab separator.
Words may be appended: loading sorts entries and merges duplicates using the lowest rank.
Save the UTF-8 file, then click OK in Auto-correction options and Save in Settings to reload it.
Back up edited files before replacing dictionaries with a new distribution.
+6 -3
View File
@@ -9,9 +9,12 @@
"uk": {
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
"words": 42549,
"bytes": 898210,
"sha256": "b4104898dcccbc1331ec01e9cd690e30285fe58481460e47438e973964d09802"
"words": 42550,
"bytes": 898227,
"sha256": "c94605e6be676d2d64f89a3bc16f92b9128f9977d226a97b103cf22a3e6835e3",
"supplementalWords": {
"\u0440\u0435\u043b\u0456\u0437": 50001
}
},
"ru": {
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/ru/ru_50k.txt",
+1
View File
@@ -31207,6 +31207,7 @@
релігію 26138
релігія 22202
релігії 23427
реліз 50001
рембо 28603
ремень 23872
ремені 28385
+12 -1
View File
@@ -4,12 +4,23 @@ $root = $PSScriptRoot
$destination = Join-Path $root 'dist\KSwitcher'
New-Item -ItemType Directory -Force $destination | Out-Null
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Screenshot.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
# Enumerate the distributable files explicitly; do not include a user's data directory.
$files = @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
$entries = foreach ($path in $files) {
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
}
}
$version=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $destination 'KSwitcher.exe')).FileVersion
$manifest=Join-Path $destination 'release-files.json'
@{schema=1; version=$version; files=@($entries)} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifest -Encoding utf8
$files += $manifest
$archive = Join-Path $root 'dist\KSwitcher-win-x64.zip'
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
Get-FileHash -LiteralPath $archive -Algorithm SHA256
+58 -3
View File
@@ -2,11 +2,15 @@
#include <windows.h>
#include "resource.h"
IDI_KSWITCHER ICON "kswitcher.ico"
#ifdef KS_SCREENSHOT_RESOURCES
IDI_SCREENSHOT_QUESTION ICON "icons/question.ico"
IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
#endif
1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1"
1 VERSIONINFO
FILEVERSION 0,2,2,0
PRODUCTVERSION 0,2,2,0
FILEVERSION 0,6,12,0
PRODUCTVERSION 0,6,12,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
@@ -16,7 +20,7 @@ BEGIN
BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.2.2"
VALUE "FileVersion", "0.6.12"
END
END
BLOCK "VarFileInfo"
@@ -24,3 +28,54 @@ BEGIN
VALUE "Translation", 0x409, 1200
END
END
#ifdef KS_SCREENSHOT_RESOURCES
300 ICON "icons/lucide/circle-dot-normal.ico"
310 ICON "icons/lucide/circle-dot-active.ico"
320 ICON "icons/lucide/circle-dot-disabled.ico"
301 ICON "icons/lucide/arrow-up-right-normal.ico"
311 ICON "icons/lucide/arrow-up-right-active.ico"
321 ICON "icons/lucide/arrow-up-right-disabled.ico"
302 ICON "icons/lucide/undo-2-normal.ico"
312 ICON "icons/lucide/undo-2-active.ico"
322 ICON "icons/lucide/undo-2-disabled.ico"
303 ICON "icons/lucide/copy-normal.ico"
313 ICON "icons/lucide/copy-active.ico"
323 ICON "icons/lucide/copy-disabled.ico"
304 ICON "icons/lucide/x-normal.ico"
314 ICON "icons/lucide/x-active.ico"
324 ICON "icons/lucide/x-disabled.ico"
305 ICON "icons/lucide/redo-2-normal.ico"
315 ICON "icons/lucide/redo-2-active.ico"
325 ICON "icons/lucide/redo-2-disabled.ico"
306 ICON "icons/lucide/highlighter-normal.ico"
316 ICON "icons/lucide/highlighter-active.ico"
326 ICON "icons/lucide/highlighter-disabled.ico"
307 ICON "icons/lucide/palette-normal.ico"
317 ICON "icons/lucide/palette-active.ico"
327 ICON "icons/lucide/palette-disabled.ico"
308 ICON "icons/lucide/pencil-normal.ico"
318 ICON "icons/lucide/pencil-active.ico"
328 ICON "icons/lucide/pencil-disabled.ico"
400 ICON "icons/lucide/slash-normal.ico"
410 ICON "icons/lucide/slash-active.ico"
420 ICON "icons/lucide/slash-disabled.ico"
401 ICON "icons/lucide/square-normal.ico"
411 ICON "icons/lucide/square-active.ico"
421 ICON "icons/lucide/square-disabled.ico"
402 ICON "icons/lucide/circle-normal.ico"
412 ICON "icons/lucide/circle-active.ico"
422 ICON "icons/lucide/circle-disabled.ico"
403 ICON "icons/lucide/ellipse-normal.ico"
413 ICON "icons/lucide/ellipse-active.ico"
423 ICON "icons/lucide/ellipse-disabled.ico"
404 ICON "icons/lucide/type-normal.ico"
414 ICON "icons/lucide/type-active.ico"
424 ICON "icons/lucide/type-disabled.ico"
405 ICON "icons/lucide/a-large-small-normal.ico"
415 ICON "icons/lucide/a-large-small-active.ico"
425 ICON "icons/lucide/a-large-small-disabled.ico"
#endif
+43
View File
@@ -0,0 +1,43 @@
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5
View File
@@ -0,0 +1,5 @@
Lucide https://github.com/lucide-icons/lucide
Commit: be09619c1c1124ad09c19fc3e87ee3b939409bbd
SVG originals retained. ICO variants generated with tools/generate-toolbar-icons.py.
Ellipse is adapted from the pinned Lucide circle SVG (r replaced with rx/ry).
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

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

After

Width:  |  Height:  |  Size: 391 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

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

After

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

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

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

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

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

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

After

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

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

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

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

After

Width:  |  Height:  |  Size: 317 B

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

After

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

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

After

Width:  |  Height:  |  Size: 571 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

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

After

Width:  |  Height:  |  Size: 377 B

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

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

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

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

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

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

After

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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

After

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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

After

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

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

After

Width:  |  Height:  |  Size: 711 B

+2
View File
@@ -1,2 +1,4 @@
#pragma once
#define IDI_KSWITCHER 101
#define IDI_SCREENSHOT_QUESTION 102
#define IDC_SCREENSHOT_PENCIL 500
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="330" viewBox="0 0 960 330">
<rect x="1" y="1" width="958" height="328" rx="24" fill="#F5F3FF" stroke="#DDD6FE" stroke-width="2"/>
<g font-family="Segoe UI" fill="#241B46">
<text x="40" y="58" font-size="20" font-weight="600" fill="#4F46E5">KSwitcher / Support development</text>
<text x="40" y="115" font-size="35" font-weight="700">KSwitcher part of your day?</text>
<text x="40" y="159" font-size="35" font-weight="700">Buy the developer a coffee.</text>
<text x="40" y="202" font-size="20" fill="#655B7C">Your donation helps the tool keep growing.</text>
<rect x="40" y="238" width="350" height="54" rx="12" fill="#4F46E5"/>
<text x="215" y="272" text-anchor="middle" font-size="20" font-weight="600" fill="white">Support via monobank</text>
</g>
<g transform="translate(759 48) rotate(10 66 66)">
<rect y="8" width="132" height="132" rx="26" fill="#C4B5FD"/>
<rect width="132" height="132" rx="26" fill="#4F46E5"/>
<g transform="translate(14 14) scale(3.25)"><path d="M8 7H12V25H8ZM11 15L22 6L25 9L14 18Z" fill="white"/><path d="M11 14L25 24L22 27L10 18Z" fill="#5EEAD4"/></g>
</g>
<g transform="translate(699 172) rotate(-8 67 60)">
<rect y="7" width="142" height="115" rx="22" fill="#C4B5FD"/>
<rect width="142" height="115" rx="22" fill="white" stroke="#DDD6FE" stroke-width="2"/>
<g stroke="#4F46E5" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M43 41H92V66Q92 83 75 83H60Q43 83 43 66Z"/>
<path d="M92 46H99Q115 46 111 58Q108 67 92 66M37 92H100M56 28V18M69 28V18M82 28V18"/>
</g></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="330" viewBox="0 0 960 330">
<rect x="1" y="1" width="958" height="328" rx="24" fill="#F5F3FF" stroke="#DDD6FE" stroke-width="2"/>
<g font-family="Segoe UI" fill="#241B46">
<text x="40" y="58" font-size="20" font-weight="600" fill="#4F46E5">KSwitcher / Підтримати розробку</text>
<text x="40" y="115" font-size="35" font-weight="700">KSwitcher щодня під рукою?</text>
<text x="40" y="159" font-size="35" font-weight="700">Пригостіть розробника кавою.</text>
<text x="40" y="202" font-size="20" fill="#655B7C">Ваш донат допомагає розвивати інструмент.</text>
<rect x="40" y="238" width="350" height="54" rx="12" fill="#4F46E5"/>
<text x="215" y="272" text-anchor="middle" font-size="20" font-weight="600" fill="white">Підтримати через monobank</text>
</g>
<g transform="translate(759 48) rotate(10 66 66)">
<rect y="8" width="132" height="132" rx="26" fill="#C4B5FD"/>
<rect width="132" height="132" rx="26" fill="#4F46E5"/>
<g transform="translate(14 14) scale(3.25)"><path d="M8 7H12V25H8ZM11 15L22 6L25 9L14 18Z" fill="white"/><path d="M11 14L25 24L22 27L10 18Z" fill="#5EEAD4"/></g>
</g>
<g transform="translate(699 172) rotate(-8 67 60)">
<rect y="7" width="142" height="115" rx="22" fill="#C4B5FD"/>
<rect width="142" height="115" rx="22" fill="white" stroke="#DDD6FE" stroke-width="2"/>
<g stroke="#4F46E5" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M43 41H92V66Q92 83 75 83H60Q43 83 43 66Z"/>
<path d="M92 46H99Q115 46 111 58Q108 67 92 66M37 92H100M56 28V18M69 28V18M82 28V18"/>
</g></g></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+305 -12
View File
@@ -3,14 +3,196 @@
[string]$CurrentVersion, [string]$ExpectedVersion,
[string]$InstallDirectory, [string]$WorkDirectory,
[int]$ParentProcessId, [string]$ReadyEvent,
[switch]$ShowProgress,
[switch]$Library
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$script:progressWindow = $null
function Start-UpdateProgress {
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
if (!('KSwitcherUpdateProgress' -as [type])) {
Add-Type -ReferencedAssemblies System.Windows.Forms,System.Drawing -TypeDefinition @'
using System;
using System.Drawing;
using System.Threading;
using System.Windows.Forms;
using System.Drawing.Drawing2D;
using System.Runtime.InteropServices;
public sealed class UpdateMeter : Control {
public int Percent = -1;
private int position;
private readonly System.Windows.Forms.Timer timer = new System.Windows.Forms.Timer { Interval = 35 };
public UpdateMeter() {
DoubleBuffered = true;
timer.Tick += delegate { if (Percent < 0) { position = (position + 5) % Math.Max(1,Width); Invalidate(); } };
timer.Start();
}
protected override void OnPaint(PaintEventArgs e) {
using(var track = new SolidBrush(Color.FromArgb(232,234,241))) e.Graphics.FillRectangle(track,0,0,Width,Height);
using(var fill = new SolidBrush(Color.FromArgb(79,70,229))) {
if(Percent < 0) e.Graphics.FillRectangle(fill,position-Width/4,0,Width/4,Height);
else e.Graphics.FillRectangle(fill,0,0,Width*Percent/100,Height);
}
}
protected override void Dispose(bool disposing) { if(disposing) timer.Dispose(); base.Dispose(disposing); }
}
public sealed class UpdateSteps : Control {
public int Current = -1;
public bool Complete;
public UpdateSteps() { DoubleBuffered = true; }
protected override void OnPaint(PaintEventArgs e) {
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
string[] names = {"Download","Verify","Install","Restart"};
for(int i=0;i<4;i++) {
int x = i*(Width-80)/3;
Color color = Complete || i<=Current ? Color.FromArgb(79,70,229) : Color.FromArgb(135,141,158);
using(var pen = new Pen(Color.FromArgb(231,233,240)))
if(i<3) e.Graphics.DrawLine(pen,x+22,8,x+(Width-80)/3-10,8);
using(var brush = new SolidBrush(color)) {
if(Complete || i<Current) {
e.Graphics.FillEllipse(brush,x,0,16,16);
using(var pen = new Pen(Color.White,1.6f)) e.Graphics.DrawLines(pen,new Point[]{new Point(x+4,8),new Point(x+7,11),new Point(x+12,5)});
} else {
using(var pen = new Pen(color,i==Current?2:1)) e.Graphics.DrawEllipse(pen,x+1,1,14,14);
if(i==Current) e.Graphics.FillEllipse(brush,x+5,5,6,6);
}
e.Graphics.DrawString(names[i],Font,brush,x,26);
}
}
}
}
public sealed class KSwitcherUpdateProgress : IDisposable {
private Form form;
private Label stage, detail, percentage, footer;
private UpdateMeter progress;
private UpdateSteps steps;
private Button done, close;
private bool closing;
private Exception failure;
private readonly ManualResetEvent ready = new ManualResetEvent(false);
private readonly Thread thread;
[DllImport("user32.dll")] private static extern bool ReleaseCapture();
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr h,int m,IntPtr w,IntPtr l);
[DllImport("dwmapi.dll")] private static extern int DwmSetWindowAttribute(IntPtr h,int a,ref int v,int size);
private void Drag(object sender,MouseEventArgs e) { if(e.Button==MouseButtons.Left) { ReleaseCapture(); SendMessage(form.Handle,0xA1,(IntPtr)2,IntPtr.Zero); } }
public KSwitcherUpdateProgress() {
thread = new Thread(delegate() {
try {
Application.EnableVisualStyles();
form = new Form { Text = "KSwitcher Update", ClientSize = new Size(540,350), BackColor = Color.White,
ForeColor = Color.FromArgb(32,36,48), StartPosition = FormStartPosition.CenterScreen,
FormBorderStyle = FormBorderStyle.None, Font = new Font("Segoe UI",10), AutoScaleMode = AutoScaleMode.Dpi };
var logo = new Panel { Location = new Point(28,24), Size = new Size(34,34), BackColor = Color.FromArgb(79,70,229) };
logo.Paint += delegate(object sender,PaintEventArgs e) {
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
using(var pen = new Pen(Color.White,4)) { e.Graphics.DrawLine(pen,10,8,10,26); e.Graphics.DrawLine(pen,11,17,25,7); }
using(var pen = new Pen(Color.FromArgb(94,234,212),4)) e.Graphics.DrawLine(pen,12,17,25,27);
};
var brand = new Label { Text = "KSwitcher", Location = new Point(74,22), Size = new Size(260,24), Font = new Font("Segoe UI Semibold",11) };
var caption = new Label { Text = "Application update", Location = new Point(74,45), Size = new Size(260,20), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
var minimize = new Button { Text = "\u2013", AccessibleName = "Minimize", Location = new Point(452,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, TabStop = false };
minimize.FlatAppearance.BorderSize = 0; minimize.Click += delegate { form.WindowState = FormWindowState.Minimized; };
close = new Button { Text = "\u00d7", AccessibleName = "Close", Location = new Point(490,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, Enabled = false };
close.FlatAppearance.BorderSize = 0; close.Click += delegate { form.Close(); };
stage = new Label { Text = "Checking for updates", Location = new Point(28,98), Size = new Size(414,36), Font = new Font("Segoe UI Semibold",20) };
percentage = new Label { Location = new Point(440,104), Size = new Size(72,30), TextAlign = ContentAlignment.MiddleRight, ForeColor = Color.FromArgb(79,70,229), Font = new Font("Segoe UI Semibold",16) };
detail = new Label { Text = "Connecting to the release server", Location = new Point(28,148), Size = new Size(484,42), ForeColor = Color.FromArgb(111,117,134) };
progress = new UpdateMeter { Location = new Point(28,202), Size = new Size(484,6), AccessibleName = "Update progress" };
steps = new UpdateSteps { Location = new Point(28,230), Size = new Size(484,48), Font = new Font("Segoe UI",9) };
var bottom = new Panel { Location = new Point(1,296), Size = new Size(538,53), BackColor = Color.FromArgb(247,248,251) };
footer = new Label { Text = "Your settings and history stay with you.", Location = new Point(27,18), Size = new Size(350,22), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
done = new Button { Text = "Done", Location = new Point(410,9), Size = new Size(100,34), BackColor = Color.FromArgb(79,70,229), ForeColor = Color.White, FlatStyle = FlatStyle.Flat, Visible = false };
done.FlatAppearance.BorderSize = 0; done.Click += delegate { form.Close(); };
bottom.Controls.AddRange(new Control[]{footer,done});
form.Controls.AddRange(new Control[]{logo,brand,caption,minimize,close,stage,percentage,detail,progress,steps,bottom});
form.MouseDown += Drag; logo.MouseDown += Drag; brand.MouseDown += Drag; caption.MouseDown += Drag;
form.Paint += delegate(object sender,PaintEventArgs e) { using(var pen = new Pen(Color.FromArgb(220,223,232))) e.Graphics.DrawRectangle(pen,0,0,form.ClientSize.Width-1,form.ClientSize.Height-1); };
form.FormClosing += delegate(object sender, FormClosingEventArgs e) { if (!closing) e.Cancel = true; };
form.Shown += delegate { int rounded=2; try { DwmSetWindowAttribute(form.Handle,33,ref rounded,4); } catch { } ready.Set(); };
Application.Run(form);
} catch(Exception e) { failure = e; ready.Set(); }
});
thread.IsBackground = true;
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
if (!ready.WaitOne(10000)) throw new Exception("Update progress window did not start.");
if (failure != null) throw new Exception("Cannot display update progress.",failure);
}
public void Report(string title, string text, int percent) {
if (form == null || form.IsDisposed) return;
form.BeginInvoke((Action)delegate {
stage.Text = title; detail.Text = text;
progress.Percent = percent < 0 ? -1 : Math.Max(0,Math.Min(100,percent));
percentage.Text = percent < 0 ? "" : progress.Percent+"%";
int separator = text.IndexOf("% - ");
if(separator>=0) detail.Text = text.Substring(separator+4)+" downloaded";
if(title.StartsWith("Downloading")) steps.Current=0;
else if(title.StartsWith("Verifying") || title.StartsWith("Preparing")) steps.Current=1;
else if(title.StartsWith("Installing") || title.StartsWith("Closing")) steps.Current=2;
else if(title.StartsWith("Restarting")) steps.Current=3;
progress.Invalidate(); steps.Invalidate();
});
}
public void SavePreview(string path) {
form.Invoke((Action)delegate {
using(var bitmap = new Bitmap(form.Width,form.Height)) {
form.DrawToBitmap(bitmap,new Rectangle(0,0,bitmap.Width,bitmap.Height));
bitmap.Save(path,System.Drawing.Imaging.ImageFormat.Png);
}
});
}
public string Status { get { return (string)form.Invoke((Func<string>)delegate { return stage.Text; }); } }
public int Percent { get { return (int)form.Invoke((Func<int>)delegate { return progress.Percent; }); } }
public void Finish(string title, string text, bool success) {
form.Invoke((Action)delegate {
closing = true;
stage.Text = title; detail.Text = text;
progress.Percent = success ? 100 : 0; percentage.Text = success ? "100%" : "";
steps.Complete = success;
if(!success) {
detail.Visible = false; steps.Visible = false;
var result = new TextBox { Text = text, Location = new Point(28,148), Size = new Size(484,126),
Multiline = true, ReadOnly = true, BorderStyle = BorderStyle.None,
BackColor = Color.White, ScrollBars = ScrollBars.Vertical };
form.Controls.Add(result); result.BringToFront(); progress.Visible = false;
}
footer.Text = success ? "Your settings and history are preserved." : "Review the details above.";
close.Enabled = true; done.Visible = true;
form.AcceptButton = done; form.CancelButton = done;
progress.Invalidate(); steps.Invalidate();
});
}
public void WaitForClose() { thread.Join(); }
public void Dispose() {
if (form != null && !form.IsDisposed)
form.BeginInvoke((Action)delegate { closing = true; form.Close(); });
thread.Join(3000);
}
}
'@
}
$script:progressWindow = New-Object KSwitcherUpdateProgress
}
function Show-UpdateProgress([string]$Stage,[string]$Detail,[int]$Percent = -1) {
if ($script:progressWindow) {
try { $script:progressWindow.Report($Stage,$Detail,$Percent) }
catch { Write-Warning 'Could not refresh update progress.' }
}
}
function Complete-UpdateProgress([string]$Title,[string]$Detail,[bool]$Success) {
if ($script:progressWindow) {
try { $script:progressWindow.Finish($Title,$Detail,$Success); return $true }
catch { Write-Warning 'Could not display the update result.' }
}
return $false
}
function Get-StableVersion([string]$Tag) {
if ($Tag -notmatch '^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
try { return [version]($Tag -replace '^v','') } catch { return $null }
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
try { return [version]($Tag -replace '^(?:release-)?v','') } catch { return $null }
}
function Assert-ReleaseUrl([string]$Url) {
$uri = [uri]$Url
@@ -20,7 +202,7 @@ function Assert-ReleaseUrl([string]$Url) {
throw 'Release asset URL is outside the KSwitcher repository.'
}
}
function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
function Receive-File([string]$Url,[string]$Path,[long]$Limit,[string]$ProgressLabel = '') {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$request = [Net.HttpWebRequest]::Create($Url)
$request.AllowAutoRedirect = $false
@@ -36,11 +218,20 @@ function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
$buffer = New-Object byte[] 65536
$total = 0L
$deadline = [DateTime]::UtcNow.AddSeconds(60)
$lastProgress = [DateTime]::MinValue
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)
if ($ProgressLabel -and ([DateTime]::UtcNow - $lastProgress).TotalMilliseconds -ge 100) {
$percent = if ($response.ContentLength -gt 0) { [int][Math]::Min(100,100.0*$total/$response.ContentLength) } else { -1 }
$detail = if ($percent -ge 0) { '{0}% - {1:N1} / {2:N1} MB' -f $percent,($total/1MB),($response.ContentLength/1MB) } else { '{0:N1} MB received' -f ($total/1MB) }
Show-UpdateProgress $ProgressLabel $detail $percent
$lastProgress = [DateTime]::UtcNow
}
}
if ($response.ContentLength -ge 0 -and $total -ne $response.ContentLength) { throw 'The download ended before the complete file was received.' }
if ($ProgressLabel) { Show-UpdateProgress $ProgressLabel ('Downloaded {0:N1} MB' -f ($total/1MB)) 100 }
} finally {
if ($outputStream) { $outputStream.Dispose() }
if ($inputStream) { $inputStream.Dispose() }
@@ -63,9 +254,15 @@ function Assert-Checksum([string]$Archive,[string]$Checksum) {
$matchesFound = @([IO.File]::ReadAllLines($Checksum) | Where-Object { $_ -cmatch '^([a-fA-F0-9]{64})\s+\*?KSwitcher-win-x64\.zip$' })
if ($matchesFound.Count -ne 1) { throw 'Missing or ambiguous archive checksum.' }
$expected = ($matchesFound[0] -split '\s+')[0]
if ((Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash -ne $expected) { throw 'Release checksum verification failed.' }
# Do not depend on PowerShell module autoloading: hosts can inherit a
# PowerShell 7 PSModulePath while this helper runs in Windows PowerShell 5.1.
$stream = [IO.File]::OpenRead($Archive)
$sha = [Security.Cryptography.SHA256]::Create()
try { $actual = [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','') }
finally { $sha.Dispose(); $stream.Dispose() }
if ($actual -ne $expected) { throw 'Release checksum verification failed.' }
}
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
function Expand-LegacyRelease([string]$Archive,[string]$Destination,[version]$Expected) {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
try {
@@ -74,7 +271,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
foreach ($entry in $zip.Entries) {
$name = $entry.FullName.Replace('\','/')
if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue }
if ($name -cnotmatch '^(KSwitcher\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or
if ($name -cnotmatch '^(KSwitcher(?:\.(?:Config|Screenshot))?\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or
$name.EndsWith('/.') -or $name.EndsWith('/..') -or $name.EndsWith('.') -or
(($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($name)) {
throw "Unexpected or unsafe archive entry: $name"
@@ -88,8 +285,13 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
foreach ($required in @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md')) {
if (!$seen.Contains($required)) { throw "Incomplete release: $required" }
}
if ($Expected -gt [version]'0.2.3' -and !$seen.Contains('KSwitcher.Config.exe')) {
throw 'Incomplete release: KSwitcher.Config.exe'
}
} finally { $zip.Dispose() }
$exe = Join-Path $Destination 'KSwitcher.exe'
foreach ($binary in @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe')) {
$exe = Join-Path $Destination $binary
if (!(Test-Path -LiteralPath $exe)) { continue }
$version = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe)
if ($version.ProductName -ne 'KSwitcher' -or (Get-StableVersion $version.FileVersion) -ne $Expected) {
throw 'Executable identity/version does not match the release.'
@@ -99,6 +301,76 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
$pe = [BitConverter]::ToInt32($bytes,60)
if ($pe -lt 64 -or $pe -gt $bytes.Length-6 -or [BitConverter]::ToUInt32($bytes,$pe) -ne 0x4550 -or
[BitConverter]::ToUInt16($bytes,$pe+4) -ne 0x8664) { throw 'Release is not a Windows x64 executable.' }
}
}
function Get-Sha256([IO.Stream]$Stream) {
$sha=[Security.Cryptography.SHA256]::Create()
try { return [BitConverter]::ToString($sha.ComputeHash($Stream)).Replace('-','').ToLowerInvariant() }
finally { $sha.Dispose() }
}
function Assert-PackagePath([string]$Name) {
if (!$Name -or $Name.Length -gt 220 -or $Name -match '[\\:<>"|?*]' -or $Name.StartsWith('/')) { throw "Unsafe package path: $Name" }
$parts=$Name.Split('/')
foreach ($part in $parts) {
if (!$part -or $part -in @('.','..') -or $part -match '[. ]$|[\x00-\x1f]' -or
$part -match '^(?i:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\.|$)') { throw "Unsafe package path: $Name" }
}
if ($parts[0] -ieq 'data' -or $parts[0] -like '.kswitcher-*') { throw "Protected user path: $Name" }
}
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip=[IO.Compression.ZipFile]::OpenRead($Archive)
try {
$manifests=@($zip.Entries | Where-Object FullName -CEQ 'release-files.json')
if (!$manifests.Count) {
if ($Expected -gt [version]'0.4.2') { throw 'Release manifest is missing.' }
$zip.Dispose(); $zip=$null
Expand-LegacyRelease $Archive $Destination $Expected
return
}
if ($manifests.Count -ne 1 -or $manifests[0].Length -gt 256KB) { throw 'Invalid release manifest.' }
$reader=[IO.StreamReader]::new($manifests[0].Open())
try { $manifest=$reader.ReadToEnd() | ConvertFrom-Json } finally { $reader.Dispose() }
if ($manifest.schema -ne 1 -or (Get-StableVersion $manifest.version) -ne $Expected -or
@($manifest.files).Count -lt 1 -or @($manifest.files).Count -gt 1000) { throw 'Unsupported or mismatched release manifest.' }
$declared=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($file in $manifest.files) {
Assert-PackagePath $file.path
if ($file.path -ieq 'release-files.json' -or $declared.ContainsKey($file.path) -or
$file.sha256 -notmatch '^[a-fA-F0-9]{64}$' -or $file.size -lt 0 -or $file.size -gt 100MB) { throw 'Invalid manifest file entry.' }
$declared.Add($file.path,$file)
}
if (!$declared.ContainsKey('KSwitcher.exe')) { throw 'Main executable is missing from the manifest.' }
$seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$total=0L
foreach ($entry in $zip.Entries) {
$name=$entry.FullName.Replace('\','/')
if ($name.EndsWith('/')) { Assert-PackagePath $name.TrimEnd('/'); continue }
Assert-PackagePath $name
if (!$seen.Add($name) -or (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000) { throw 'Duplicate or linked archive entry.' }
if ($name -ceq 'release-files.json') { continue }
if (!$declared.ContainsKey($name)) { throw "Undeclared package file: $name" }
$file=$declared[$name]
if ($entry.Length -ne $file.size) { throw "Package file size mismatch: $name" }
$total+=$entry.Length
if ($total -gt 100MB -or $zip.Entries.Count -gt 1100) { throw 'Release expands beyond allowed limits.' }
$stream=$entry.Open()
try { $hash=Get-Sha256 $stream } finally { $stream.Dispose() }
if ($hash -ne $file.sha256) { throw "Package file checksum mismatch: $name" }
}
if ($seen.Count -ne $declared.Count+1) { throw 'Archive does not contain every manifest file.' }
[IO.Directory]::CreateDirectory($Destination) | Out-Null
Assert-PlainDirectory $Destination
foreach ($entry in $zip.Entries) {
if ($entry.FullName.EndsWith('/')) { continue }
$target=Join-Path $Destination $entry.FullName
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($target)) | Out-Null
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($target))
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry,$target,$false)
}
$info=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $Destination 'KSwitcher.exe'))
if ($info.ProductName -ne 'KSwitcher' -or (Get-StableVersion $info.FileVersion) -ne $Expected) { throw 'Main executable identity/version mismatch.' }
} finally { if ($zip) { $zip.Dispose() } }
}
function Assert-PlainDirectory([string]$Directory) {
$item = Get-Item -LiteralPath $Directory -Force
@@ -112,7 +384,7 @@ function Install-Files([string]$Stage,[string]$Target,[string]$Backup,[Collectio
foreach ($file in @(Get-ChildItem -LiteralPath $Stage -File -Recurse)) {
$relative = $file.FullName.Substring($Stage.Length+1)
$destination = Join-Path $Target $relative
if ($relative.StartsWith('dictionaries\') -and (Test-Path -LiteralPath $destination)) { continue }
if ($relative.StartsWith('dictionaries\',[StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $destination)) { continue }
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($destination))
if (Test-Path -LiteralPath $destination) {
@@ -139,8 +411,9 @@ function Write-Result($Value) {
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
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null; $uiFinished = $false
try {
if ($ShowProgress -or $Mode -eq 'Install') { Start-UpdateProgress }
$current = Get-StableVersion $CurrentVersion
if (!$current) { throw 'Invalid installed version.' }
if ($Mode -eq 'Install') {
@@ -161,23 +434,35 @@ try {
$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
Show-UpdateProgress 'Downloading update' $release.version 0
Receive-File $release.zip $archive 50MB 'Downloading update'
Show-UpdateProgress 'Verifying download' 'Checking the archive SHA-256 checksum'
Receive-File $release.checksum $checksum 64KB
Assert-Checksum $archive $checksum
Show-UpdateProgress 'Preparing installation' 'Validating and unpacking the release files'
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}
Show-UpdateProgress 'Closing KSwitcher' 'Waiting for the running application to exit safely'
$ready.Set() | Out-Null
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
$parentExited = $true
Show-UpdateProgress 'Installing update' 'Replacing application files; keeping your personal data'
Install-Files $stage $InstallDirectory $backup $changed
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
Show-UpdateProgress 'Restarting KSwitcher' 'Waiting for the updated application to confirm startup'
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
$success = $true
if ($script:progressWindow) {
$uiFinished = Complete-UpdateProgress 'Update completed' "KSwitcher $(Get-StableVersion $release.version) is now running." $true
}
} catch {
$message = $_.Exception.Message
if ($parentExited) {
Show-UpdateProgress 'Restoring previous version' 'The update failed; recovering the previous installation'
try {
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
Undo-Files $InstallDirectory $backup $changed
@@ -190,8 +475,12 @@ try {
}
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
if ($script:progressWindow) {
$uiFinished = Complete-UpdateProgress 'Update failed' $message $false
} else {
Add-Type -AssemblyName System.Windows.Forms
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
}
}
exit 1
} finally {
@@ -208,4 +497,8 @@ try {
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
}
}
if ($script:progressWindow) {
if ($uiFinished) { $script:progressWindow.WaitForClose() }
$script:progressWindow.Dispose(); $script:progressWindow = $null
}
}
+305 -42
View File
@@ -1,6 +1,8 @@
#include "app.h"
#include "../resources/resource.h"
#include "ocr.h"
#include "configuration.h"
#include "screenshot_notifications.h"
#include <algorithm>
#include <commctrl.h>
#include <set>
@@ -16,7 +18,8 @@ std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
{Case, s.casing},
{Translit, s.translit},
{Undo, s.undo},
{ShowHistory, s.clipboard}};
{ShowHistory, s.clipboard},
{Screenshot, s.screenshot}};
for (size_t i = 0; i < s.direct.size(); ++i)
out.emplace_back(Direct + (int)i, s.direct[i]);
return out;
@@ -230,6 +233,7 @@ class Transaction
} // namespace
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
{
updateNotifier = notifyUpdateAvailable;
current = this;
data = dataRoot.empty() ? dataDirectory() : dataRoot;
std::filesystem::create_directories(data);
@@ -244,6 +248,13 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
registrationWarning = L"Settings were not loaded: " + wide(e.what());
}
setLoggingEnabled(settings.writeLog);
if (settings.autoCorrect)
try { autoPredictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", settings.autoLanguages); }
catch (const std::exception &e)
{
settings.autoCorrect = false;
registrationWarning += L"\nAutomatic correction unavailable: " + wide(e.what());
}
try
{
history = std::make_unique<History>(data / L"history");
@@ -266,8 +277,15 @@ App::~App()
std::lock_guard lock(jobsMutex);
stop = true;
jobs.clear();
pendingHistorySearch = {};
}
jobsReady.notify_all();
historySearchReady.notify_all();
historyOcrReady.notify_all();
if (historyOcrWorker.joinable())
historyOcrWorker.join();
if (historySearchWorker.joinable())
historySearchWorker.join();
if (worker.joinable())
worker.join();
RemoveClipboardFormatListener(window);
@@ -288,6 +306,83 @@ void App::enqueue(std::function<void()> job)
}
jobsReady.notify_one();
}
void App::enqueueHistorySearch(std::function<void()> job)
{
{
std::lock_guard lock(jobsMutex);
if (stop) return;
// Only the latest query matters. Clipboard capture and OCR must not delay it.
pendingHistorySearch = std::move(job);
if (!historySearchWorker.joinable())
historySearchWorker = std::thread([this]
{
for (;;)
{
std::function<void()> searchJob;
{
std::unique_lock lock(jobsMutex);
historySearchReady.wait(lock, [&] { return stop || bool(pendingHistorySearch); });
if (stop) return;
searchJob = std::move(pendingHistorySearch);
pendingHistorySearch = {};
}
try { searchJob(); }
catch (const std::exception &e) { report(wide(e.what())); }
}
});
}
historySearchReady.notify_one();
}
void App::scheduleHistoryOcr(const Settings &config)
{
{
std::lock_guard lock(jobsMutex);
if (stop || !history || !config.history) return;
historyOcrSettings = config;
historyOcrRequested = true;
if (!historyOcrWorker.joinable())
historyOcrWorker = std::thread([this]
{
for (;;)
{
Settings config;
{
std::unique_lock lock(jobsMutex);
historyOcrReady.wait(lock, [&] { return stop || historyOcrRequested; });
if (stop) return;
historyOcrRequested = false;
config = historyOcrSettings;
}
for (const auto &entry : history->entries())
{
{ std::lock_guard lock(jobsMutex); if (stop) return; }
auto profile = ocrProfile(config);
if (!entry.image || (entry.ocrState != 1 && entry.ocrProfile == profile)) continue;
try
{
auto text = recognizeText(history->load(entry.id), config);
std::lock_guard lock(jobsMutex);
if (stop || ocrProfile(historyOcrSettings) != profile) continue;
if (history->saveOcr(entry.id, text, config))
PostMessageW(window, HistoryMessage, 0, 0);
}
catch (const std::exception &e)
{
// Automatic OCR never changes the clipboard or opens error dialogs.
log(L"History OCR: " + wide(e.what()));
try {
std::lock_guard lock(jobsMutex);
if (!stop && ocrProfile(historyOcrSettings) == profile)
history->saveOcr(entry.id, std::nullopt, config);
}
catch (const std::exception &saveError) { log(L"History OCR save: " + wide(saveError.what())); }
}
}
}
});
}
historyOcrReady.notify_one();
}
void App::report(const std::wstring &text)
{
log(text);
@@ -329,11 +424,12 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
void App::applySettings(const Settings &s)
{
bool autostartChanged = false;
AutoCorrector predictor;
validateSettings(s);
validateTesseract(s);
if (s.autoCorrect)
{
AutoCorrector probe;
probe.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
predictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
}
if (!registerHotkeys(s))
{
@@ -365,6 +461,7 @@ void App::applySettings(const Settings &s)
{
std::lock_guard lock(stateMutex);
settings = s;
autoPredictor = std::move(predictor);
setLoggingEnabled(s.writeLog);
run.reset();
last.reset();
@@ -372,6 +469,7 @@ void App::applySettings(const Settings &s)
}
// Clear on the worker so the next check reloads edits to dictionary files.
enqueue([this] { autoCorrector.configure({}, {}); });
scheduleHistoryOcr(s);
SetThreadExecutionState(ES_CONTINUOUS |
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
}
@@ -398,6 +496,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))
@@ -415,6 +525,7 @@ void App::resetInput()
run.reset();
last.reset();
}
void App::clearLog(bool previous) { clearDiagnosticLog(previous); }
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
{
if (ownKeyboardInput(k))
@@ -455,9 +566,32 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
return;
}
run.record(ch, fg, layout, GetTickCount64());
if (settings.autoCorrect && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 &&
if (settings.autoCorrect && mods == 0 && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 &&
run.keys.size() <= 41 && !busy && !run.overflow)
PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0);
{
for (int source = 0; source < (int)layouts.size(); ++source)
if (layouts[source].handle == layout)
{
std::wstring predicted;
for (wchar_t key : run.keys)
predicted += key == L' ' ? L' ' : layouts[source].at(physicalLayout().code(key));
if (autoPredictor.suggest(predicted, source, layouts, settings))
{
// Reserve the transaction before the next physical key arrives.
// The delimiter itself passes through; subsequent input is queued.
typingTarget = fg;
autoPending = true;
busy = true;
if (!PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0))
{
typingTarget = nullptr;
autoPending = false;
busy = false;
}
}
break;
}
}
if (busy || settings.snippets.empty())
return;
auto keys = lower(run.keys);
@@ -496,6 +630,31 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
// down AND up events use exactly the same path as physical keyboard input.
if (ownKeyboardInput(key) || installingUpdate)
return false;
{
std::lock_guard lock(stateMutex);
if (typingTarget && GetForegroundWindow() == typingTarget)
{
INPUT input{};
input.type = INPUT_KEYBOARD;
input.ki.wVk = static_cast<WORD>(key.vkCode);
input.ki.wScan = static_cast<WORD>(key.scanCode);
input.ki.dwFlags = (down ? 0 : KEYEVENTF_KEYUP) |
((key.flags & LLKHF_EXTENDED) ? KEYEVENTF_EXTENDEDKEY : 0);
if (key.vkCode == VK_PACKET)
{
input.ki.wVk = 0;
input.ki.dwFlags |= KEYEVENTF_UNICODE;
}
// Replay as ordinary input so tracking and shortcuts see it once,
// after the replacement and layout switch have finished.
deferredTyping.push_back(input);
return true;
}
}
DWORD captureProcess{};
if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess);
if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess))
setHotkeyCapture(nullptr);
if (hotkeyCapture && GetForegroundWindow() == GetAncestor(hotkeyCapture, GA_ROOT))
{
bool modifier = key.vkCode == VK_CONTROL || key.vkCode == VK_SHIFT || key.vkCode == VK_MENU ||
@@ -534,6 +693,42 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
return true;
return false;
}
bool App::beginTypingDeferral(HWND target, uint64_t version)
{
std::lock_guard lock(stateMutex);
if (inputVersion != version || GetForegroundWindow() != target || !modifiersReleased())
return false;
typingTarget = target;
return true;
}
void App::replayTyping()
{
std::vector<INPUT> inputs;
HWND target;
{
std::lock_guard lock(stateMutex);
target = typingTarget;
typingTarget = nullptr;
autoPending = false;
inputs.swap(deferredTyping);
}
busy = false;
if (inputs.empty())
return;
if (GetForegroundWindow() != target)
{
resetInput();
report(L"Typing during correction was not replayed because the active window changed.");
return;
}
// Runs on the hook thread: no physical key can pass between releasing the
// gate and submitting the entire ordered batch to the Windows input queue.
if (SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT)) != inputs.size())
{
resetInput();
report(L"Windows could not replay all typing buffered during correction.");
}
}
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
{
if (code == HC_ACTION && current &&
@@ -569,6 +764,12 @@ void App::dispatch(int command)
return;
try
{
if (command == 23) { checkUpdate(true); return; }
if (command == Screenshot)
{
if (!busy) { resetInput(); launchScreenshot(*this); }
return;
}
if (command == ShowHistory)
{
showHistory(*this);
@@ -579,6 +780,11 @@ void App::dispatch(int command)
showSettings(*this);
return;
}
if (command == 22)
{
showLogs(*this);
return;
}
if (command == 21)
{
showSnippets(*this);
@@ -619,16 +825,15 @@ void App::transform(int command, int snippet)
L" window=" + std::to_wstring(reinterpret_cast<uintptr_t>(target)) +
L" version=" + std::to_wstring(currentVersion) +
L" repeat=" + std::to_wstring(repeatAttempts));
if (repeatAttempts >= 2)
diagnosticEvent(L"Transform suspicion id=" + std::to_wstring(attempt) +
L" reason=repeated-command-without-input (may be intentional cycling)");
}
if (busy.exchange(true))
const bool preDeferred = command == AutoCorrect && autoPending;
if (busy.exchange(true) && !preDeferred)
{
if (manual)
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true);
return;
}
if (preDeferred) autoPending = false;
Run recent;
std::optional<LastTransform> previous;
Settings config;
@@ -643,17 +848,21 @@ void App::transform(int command, int snippet)
version = inputVersion;
}
enqueue(
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt,
suspicious = manual && repeatAttempts >= 2]
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt, preDeferred]
{
struct BusyReset
{
std::atomic<bool> &b;
HWND window;
bool deferred{};
~BusyReset()
{
b = false;
if (deferred)
PostMessageW(window, ReplayTypingMessage, 0, 0);
else
b = false;
}
} reset{busy};
} reset{busy, window, preDeferred};
auto start = GetTickCount64();
std::wstring reason = L"cancelled-before-selection";
std::wstring text, result, expected;
@@ -679,7 +888,6 @@ void App::transform(int command, int snippet)
uint64_t version, attempt;
ULONGLONG start;
bool enabled;
bool suspicious;
std::wstring &reason;
std::wstring &text, &result, &expected;
int &hint, &to;
@@ -693,10 +901,10 @@ void App::transform(int command, int snippet)
L" source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
L" expected=" + diagnosticText(expected) + L" copied=" + diagnosticText(text) +
L" result=" + diagnosticText(result),
suspicious || (reason != L"paste-sent-unverified" && reason != L"layout-only" &&
reason != L"unchanged-expected"));
reason != L"paste-sent-unverified" && reason != L"layout-only" &&
reason != L"unchanged-expected");
}
} audit{*this, target, version, attempt, requestedAt, manual, suspicious, reason, text, result, expected, hint, to};
} audit{*this, target, version, attempt, requestedAt, manual, reason, text, result, expected, hint, to};
try
{
auto valid = [&]
@@ -737,6 +945,21 @@ void App::transform(int command, int snippet)
return;
}
reason = L"clipboard-snapshot";
if (command == AutoCorrect)
{
if (!beginTypingDeferral(target, version))
return;
reset.deferred = true;
}
// Collapse a temporary selection on every failed auto-correction,
// including clipboard exceptions, before queued typing is replayed.
bool autoSelected = false;
struct SelectionReset
{
bool &selected;
std::function<bool()> valid;
~SelectionReset() { if (selected && valid()) chord(VK_RIGHT); }
} selectionReset{autoSelected, valid};
Transaction tx(*this, target, version);
reason = L"selection-unavailable";
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
@@ -753,6 +976,7 @@ void App::transform(int command, int snippet)
// conversion, automatic correction must never fall back to a suffix.
if (!tx.valid() || !chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
return;
autoSelected = true;
Sleep(25);
text = tx.copy();
// Some editors first select only ") ". Extend by whole-word
@@ -769,7 +993,10 @@ void App::transform(int command, int snippet)
if (!suggestion || !tx.valid() || !autoCorrectionAllowed(target, config))
{
if (tx.valid())
{
chord(VK_RIGHT);
autoSelected = false;
}
return;
}
to = suggestion->target;
@@ -906,6 +1133,8 @@ void App::transform(int command, int snippet)
sent = tx.paste(textClip(result));
else if (automatic && tx.valid())
chord(VK_RIGHT);
if (sent || result == text)
autoSelected = false;
reason = result == text ? L"conversion-unchanged" :
sent ? L"paste-sent-unverified" : L"paste-cancelled";
if (result == text &&
@@ -1027,6 +1256,7 @@ void App::captureHistory()
!f.data[1] && !f.data[2] && !f.data[3])
return;
history->add(clip, app, config);
scheduleHistoryOcr(config);
PostMessageW(window, HistoryMessage, 0, 0);
}
catch (const std::exception &e)
@@ -1054,7 +1284,9 @@ void App::recognizeEntry(std::wstring id)
} reset{busy};
try
{
auto text = recognizeText(history->load(id));
auto cached = history->ocrText(id, ocrProfile(config));
auto text = cached ? *cached : recognizeText(history->load(id), config);
if (!cached) history->saveOcr(id, text, config);
if (text.empty())
{
report(L"No text was found in the image.");
@@ -1079,6 +1311,7 @@ void App::trayMenu()
{
HMENU menu = CreatePopupMenu();
AppendMenuW(menu, MF_STRING, 10, L"Clipboard history");
AppendMenuW(menu, MF_STRING, 24, L"Capture screen region…");
AppendMenuW(menu, MF_STRING, 11, L"Settings…");
SetMenuDefaultItem(menu, 11, FALSE);
AppendMenuW(menu, MF_STRING, 12, L"Snippets…");
@@ -1110,6 +1343,9 @@ void App::trayMenu()
{
switch (cmd)
{
case 24:
dispatch(Screenshot);
break;
case 10:
showHistory(*this);
break;
@@ -1134,9 +1370,9 @@ void App::trayMenu()
break;
case 16:
MessageBoxW(window,
L"KSwitcher 0.2.2\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on "
(std::wstring(L"KSwitcher ") + AppVersion + L"\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on "
L"Typlo by bitcodepro. MIT license.\nHistory is encrypted for your Windows "
L"account.\nNo .NET, WebView or graphics framework.",
L"account.\nNo .NET, WebView or graphics framework.").c_str(),
L"About KSwitcher", MB_OK);
break;
case 17:
@@ -1170,6 +1406,11 @@ void App::trayMenu()
void App::checkUpdate(bool interactive, bool install, const std::wstring &version)
{
if (updateRunning) return;
if (install && FindWindowW(L"KSwitcher.Config", nullptr))
{
report(L"Close the configurator before installing an update.");
return;
}
if (install && busy)
{
report(L"Wait for the current text operation to finish, then select Update again.");
@@ -1181,7 +1422,7 @@ void App::checkUpdate(bool interactive, bool install, const std::wstring &versio
updateWorker = std::thread([this, interactive, install, version]
{
auto result = std::make_unique<UpdateResult>();
try { *result = runUpdate(install, version); }
try { *result = runUpdate(install, version, interactive); }
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
result->interactive = interactive;
result->install = install;
@@ -1206,6 +1447,8 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
if (app->updateWorker.joinable()) app->updateWorker.join();
app->updateRunning = false;
app->installingUpdate = false;
log(L"Update check: " + result->state + (result->version.empty() ? L"" : L" " + result->version) +
(result->error.empty() ? L"" : L"; " + result->error));
if (result->state == L"prepared")
{
// The helper has verified and staged the package; it waits for process exit.
@@ -1221,14 +1464,18 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
if (MessageBoxW(hwnd, prompt.c_str(), L"KSwitcher Update", MB_YESNO | MB_ICONQUESTION) == IDYES)
app->checkUpdate(true, true, result->version);
}
else
else if (app->notifiedUpdate != result->version)
{
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);
if (app->updateNotifier(result->version))
{
app->notifiedUpdate = result->version;
log(L"Update notification submitted to Windows: " + result->version);
}
else
{
log(L"Update notification failed; retrying in 5 minutes: " + result->version);
SetTimer(hwnd, 5, UpdateNotificationRetryMs, nullptr);
}
}
}
else if (result->state == L"current")
@@ -1241,12 +1488,15 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
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);
diagnosticEvent(L"Background update check failed: " + result->error);
return 0;
}
case WM_HOTKEY:
app->dispatch((int)wp);
return 0;
case ReplayTypingMessage:
app->replayTyping();
return 0;
case TrayMessage:
if (LOWORD(lp) == WM_CONTEXTMENU)
app->trayMenu();
@@ -1261,9 +1511,13 @@ 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);
if (app->autoPending)
{
if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == (uint64_t)wp)
app->transform(AutoCorrect);
else
app->replayTyping();
}
return 0;
case SnippetMessage:
if (app->installingUpdate) return 0;
@@ -1289,6 +1543,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 < ConfigEnd)
{
if (app->installingUpdate) return FALSE;
return configurationRequest(*app, (HWND)wp, *data);
}
if (data && data->dwData == 1 && data->cbData == sizeof(int))
{
int cmd;
@@ -1300,7 +1559,9 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
case WM_TIMER:
if (wp == 5)
{
KillTimer(hwnd, 5);
// After the initial 5-second check, repeat while the resident stays open.
// Rearm even when a check is already running or the previous one failed.
SetTimer(hwnd, 5, UpdateCheckIntervalMs, nullptr);
app->checkUpdate(false);
return 0;
}
@@ -1312,13 +1573,6 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
app->dispatch(20);
return 0;
}
if (wp == 3)
{
KillTimer(hwnd, 3);
if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
app->transform(AutoCorrect);
return 0;
}
if (wp == 2)
{
PostMessageW(hwnd, WM_CLOSE, 0, 0);
@@ -1345,6 +1599,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:
@@ -1398,6 +1655,8 @@ int App::execute()
if (!keyboardHook || !mouseHook)
report(L"Input tracking could not start. Automatic recent-text conversion is unavailable.");
AddClipboardFormatListener(window);
// Resume pending OCR, including older images without a saved OCR state.
scheduleHistoryOcr(settings);
addTray();
SetTimer(window, 1, 60000, nullptr);
SetThreadExecutionState(ES_CONTINUOUS |
@@ -1408,10 +1667,13 @@ int App::execute()
SetTimer(window, 2, 10000, nullptr);
else
SetTimer(window, 5, 5000, nullptr);
if (keyboardHook && mouseHook)
confirmUpdatedStartup(updateStartupEvent);
if (keyboardHook && mouseHook && confirmUpdatedStartup(updateStartupEvent))
log(std::wstring(L"Update completed; running version ") + AppVersion);
if (startCommand)
PostMessageW(window, WM_HOTKEY, startCommand, 0);
try { registerScreenshotNotifications(); }
catch(const std::exception &e) { log(L"Screenshot notification registration: "+wide(e.what())); }
auto screenshotActivation=startScreenshotActivationListener();
MSG msg;
while (GetMessageW(&msg, nullptr, 0, 0) > 0)
{
@@ -1430,6 +1692,7 @@ int App::execute()
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
if(screenshotActivation) CoRevokeClassObject(screenshotActivation);
return (int)msg.wParam;
}
} // namespace ks
+25 -4
View File
@@ -14,8 +14,9 @@ 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 ReplayTypingMessage = WM_APP + 30;
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
struct LastTransform
{
@@ -37,12 +38,14 @@ class App
Settings settings;
std::unique_ptr<History> history;
AutoCorrector autoCorrector;
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
std::atomic<bool> busy{false}, suspended{false};
std::atomic<DWORD> ownSequence{0};
std::atomic<uint64_t> inputVersion{0};
std::atomic<ULONGLONG> pauseUntil{0};
HWND pasteTarget{};
HWND hotkeyCapture{};
DWORD hotkeyCaptureProcess{};
HWND logWindow{};
std::mutex stateMutex;
Run run;
@@ -60,12 +63,16 @@ class App
void recognizeEntry(std::wstring);
void captureHistory();
void enqueue(std::function<void()>);
void enqueueHistorySearch(std::function<void()>);
void report(const std::wstring &);
void trayMenu();
void addTray();
bool registerHotkeys(const Settings &, bool rollback = false);
void applySettings(const Settings &);
void reloadLayouts();
void clearHistory();
void clearLog(bool previous);
void setHotkeyCapture(HWND);
bool isHotkey(DWORD, UINT);
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
@@ -80,23 +87,37 @@ class App
std::condition_variable jobsReady;
std::deque<std::function<void()>> jobs;
std::thread worker;
std::thread historySearchWorker;
std::condition_variable historySearchReady;
std::function<void()> pendingHistorySearch;
std::thread historyOcrWorker;
std::condition_variable historyOcrReady;
bool historyOcrRequested{};
Settings historyOcrSettings;
void scheduleHistoryOcr(const Settings &);
std::thread updateWorker;
bool updateRunning{}, installingUpdate{};
std::wstring availableUpdate;
std::wstring availableUpdate, notifiedUpdate;
bool (*updateNotifier)(const std::wstring &){};
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;
HWND typingTarget{};
bool autoPending{};
std::vector<INPUT> deferredTyping;
bool beginTypingDeferral(HWND, uint64_t);
void replayTyping();
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
};
void showHistory(App &);
void showHistory(App &, bool visible = true);
void launchScreenshot(App &);
void refreshHistory(App &);
void showSettings(App &, bool visible = true);
void showLogs(App &);
+31 -8
View File
@@ -70,9 +70,18 @@ AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path)
}
if (offsets.empty())
throw std::runtime_error("Empty auto-correction dictionary");
for (size_t i = 1; i < offsets.size(); ++i)
if (word(i - 1) >= word(i))
throw std::runtime_error("Dictionary must be uniquely sorted");
auto entry = [&](uint32_t offset) { return std::string_view(bytes).substr(offset, bytes.find('\t', offset) - offset); };
auto frequency = [&](uint32_t offset)
{
int rank{};
auto start = offset + entry(offset).size() + 1;
std::from_chars(bytes.data() + start, bytes.data() + bytes.size(), rank);
return rank;
};
std::sort(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
{ return entry(a) == entry(b) ? frequency(a) < frequency(b) : entry(a) < entry(b); });
offsets.erase(std::unique(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
{ return entry(a) == entry(b); }), offsets.end());
offsets.shrink_to_fit();
bytes.shrink_to_fit();
}
@@ -151,12 +160,24 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
auto sourceLanguage = layoutLanguage(layouts[source]);
if (!dictionaries.contains(sourceLanguage))
return {};
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
{
auto found = settings.autoWords.find(language);
return found != settings.autoWords.end() &&
std::any_of(found->second.begin(), found->second.end(), [&](const std::wstring &known)
{ return lower(known) == lower(candidate); });
};
// Valid words from either selected language are never changed.
for (auto &[language, d] : dictionaries)
if (d.rank(word))
if (d.rank(word) || personal(language, word))
return {};
std::optional<AutoSuggestion> candidate;
int matches = 0;
std::optional<std::wstring> matchedText;
auto priority = [&](int layout)
{
auto language = layoutLanguage(layouts[layout]);
return std::find(settings.autoLanguages.begin(), settings.autoLanguages.end(), language) - settings.autoLanguages.begin();
};
for (int i = 0; i < (int)layouts.size(); ++i)
{
if (i == source || !layouts[i].convertible ||
@@ -168,12 +189,14 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
auto converted = convert(word, layouts[source], layouts[i], true);
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
continue;
int rank = dictionary->second.rank(converted);
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
if (!rank)
continue;
if (++matches > 1)
if (matchedText && *matchedText != converted)
return {};
if (settings.autoMaxRank == 0 || rank <= settings.autoMaxRank)
matchedText = converted;
if ((settings.autoMaxRank == 0 || rank <= settings.autoMaxRank) &&
(!candidate || priority(i) < priority(candidate->target)))
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
}
return candidate;
+211
View File
@@ -0,0 +1,211 @@
#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 if (wp == 22) showLogs(*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);
if (app->logWindow) DestroyWindow(app->logWindow);
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 (logWindow) DestroyWindow(logWindow);
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 App::clearLog(bool previous) { request(*this, ConfigClearLog, Bytes{static_cast<unsigned char>(previous)}); }
int runConfigurator(HINSTANCE instance, bool snippets, bool logs)
{
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, logs ? 22 : 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);
setLoggingEnabled(app.settings.writeLog);
if (logs) showLogs(app); else if (snippets) showSnippets(app); else showSettings(app);
MSG message;
while ((app.settingsWindow || app.snippetsWindow || app.logWindow) && 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;
if (app.logWindow && IsDialogMessageW(app.logWindow, &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";
bool logs = count > 1 && std::wstring(args[1]) == L"--logs";
LocalFree(args);
result = ks::runConfigurator(instance, snippets, logs);
}
catch (const std::exception &e)
{
MessageBoxW(nullptr, ks::wide(e.what()).c_str(), L"KSwitcher configurator", MB_OK | MB_ICONERROR);
}
CoUninitialize();
return result;
}
+103
View File
@@ -0,0 +1,103 @@
#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, bool logs)
{
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"\"" + (logs ? L" --logs" : 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;
case ConfigClearLog:
if (message.cbData != 1 || *static_cast<const unsigned char *>(message.lpData) > 1)
throw std::runtime_error("Invalid log selection.");
app.clearLog(*static_cast<const unsigned char *>(message.lpData) != 0);
break;
default: return FALSE;
}
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, ConfigClearLog = ConfigRead + 7, ConfigEnd = ConfigRead + 8;
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
bool configurationPeer(HWND, const wchar_t *filename);
void launchConfiguration(App &, bool snippets = false, bool logs = false);
LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
}
+8 -1
View File
@@ -1,6 +1,7 @@
#pragma once
#include <cstdint>
#include <functional>
#include <map>
#include <optional>
#include <string>
#include <vector>
@@ -48,18 +49,24 @@ struct Settings
bool elevatedAutostart = false;
bool autoCorrect = false;
bool writeLog = true;
bool screenshotAutoOpenEditor = false;
bool useTesseract = false;
std::wstring tesseractPath;
std::wstring tesseractLanguages = L"ukr+eng+rus";
int autoMaxRank = 0;
int autoMinLength = 3;
std::wstring autoPrefixChars = L"([{«“‘\"";
std::wstring autoSuffixChars = L")]}»”’\".,!?:;";
std::vector<std::wstring> autoLanguages{L"en", L"uk"};
std::vector<std::wstring> autoNever;
std::map<std::wstring, std::vector<std::wstring>> autoWords;
std::vector<std::wstring> autoExcluded{
L"Code", L"rider64", L"devenv", L"WindowsTerminal", L"cmd", L"powershell", L"pwsh",
L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
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"Win+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 -1
View File
@@ -31,7 +31,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
HANDLE mutex = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Native.Singleton");
bool existing = GetLastError() == ERROR_ALREADY_EXISTS;
int cmd = ks::ShowHistory;
if (arg == L"--convert")
if (arg == L"--capture")
cmd = ks::Screenshot;
else if (arg == L"--convert")
cmd = ks::Toggle;
else if (arg == L"--case")
cmd = ks::Case;
@@ -43,12 +45,15 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
cmd = 21;
else if (arg == L"--exit")
cmd = 99;
else if (arg == L"--check-update")
cmd = 23;
LocalFree(argv);
if (existing)
{
HWND window = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
if (window)
{
if (cmd == 23) { DWORD pid{}; GetWindowThreadProcessId(window, &pid); AllowSetForegroundWindow(pid); }
COPYDATASTRUCT data{1, sizeof(cmd), &cmd};
DWORD_PTR result{};
SendMessageTimeoutW(window, WM_COPYDATA, 0, (LPARAM)&data, SMTO_ABORTIFHUNG, 1000, &result);
+115
View File
@@ -271,4 +271,119 @@ std::wstring recognizeText(const Clip &clip)
WindowsDeleteString(text);
return out;
}
std::filesystem::path tesseractExecutable(const Settings &s)
{
if (!s.tesseractPath.empty())
{
std::filesystem::path path(s.tesseractPath);
if (!path.is_absolute()) throw std::runtime_error("Select an absolute path to tesseract.exe");
return path;
}
for (auto variable : {L"ProgramW6432", L"ProgramFiles", L"LOCALAPPDATA"})
{
wchar_t base[32768]{};
auto length = GetEnvironmentVariableW(variable, base, std::size(base));
if (!length || length >= std::size(base)) continue;
auto root = std::filesystem::path(base);
for (auto relative : {L"Tesseract-OCR/tesseract.exe", L"Programs/Tesseract-OCR/tesseract.exe"})
if (std::filesystem::is_regular_file(root / relative)) return root / relative;
}
throw std::runtime_error("Tesseract was not found. Install the optional 64-bit Tesseract 5 package or select tesseract.exe in OCR options.");
}
std::wstring ocrProfile(const Settings &s)
{
return s.useTesseract ? L"tesseract:" + lower(s.tesseractPath) + L":" + s.tesseractLanguages : L"windows";
}
namespace
{
struct TessLibrary
{
HMODULE module{};
explicit TessLibrary(const Settings &s)
{
auto exe = tesseractExecutable(s);
if (!std::filesystem::is_regular_file(exe)) throw std::runtime_error("Tesseract executable does not exist");
auto library = exe.parent_path() / L"libtesseract-5.dll";
module = LoadLibraryExW(library.c_str(), nullptr,
LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
if (!module) throw std::runtime_error("Cannot load Tesseract 5. Select a complete 64-bit installation with libtesseract-5.dll and its dependencies.");
}
~TessLibrary() { if (module) FreeLibrary(module); }
template<class T> T symbol(const char *name)
{
auto address = GetProcAddress(module, name);
if (!address) throw std::runtime_error(std::string("Unsupported Tesseract library: missing ") + name);
T result; static_assert(sizeof(result) == sizeof(address));
memcpy(&result, &address, sizeof(result));
return result;
}
};
struct TessEngine
{
TessLibrary library;
void *handle{};
void (*destroy)(void *){};
explicit TessEngine(const Settings &s) : library(s)
{
auto folder = tesseractExecutable(s).parent_path() / L"tessdata";
size_t start = 0;
do
{
auto end = s.tesseractLanguages.find(L'+', start);
auto language = s.tesseractLanguages.substr(start, end == std::wstring::npos ? end : end - start);
if (!std::filesystem::is_regular_file(folder / (language + L".traineddata")))
throw std::runtime_error("Tesseract language is not installed: " + utf8(language));
if (end == std::wstring::npos) break;
start = end + 1;
} while (true);
auto create = library.symbol<void *(*)()>("TessBaseAPICreate");
destroy = library.symbol<void (*)(void *)>("TessBaseAPIDelete");
auto init = library.symbol<int (*)(void *, const char *, const char *)>("TessBaseAPIInit3");
handle = create();
if (!handle) throw std::runtime_error("Cannot create Tesseract engine");
if (init(handle, utf8(folder.wstring()).c_str(), utf8(s.tesseractLanguages).c_str()) != 0)
{
destroy(handle); handle = nullptr;
throw std::runtime_error("Cannot initialize the selected Tesseract languages");
}
}
~TessEngine() { if (handle) destroy(handle); }
};
}
void validateTesseract(const Settings &s)
{
if (s.useTesseract) { TessEngine engine(s); }
}
std::wstring recognizeText(const Clip &clip, const Settings &s)
{
if (!s.useTesseract) return recognizeText(clip);
auto apartmentResult = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
if (FAILED(apartmentResult) && apartmentResult != RPC_E_CHANGED_MODE) check(apartmentResult);
struct Apartment { bool owned; ~Apartment() { if (owned) CoUninitialize(); } } apartment{SUCCEEDED(apartmentResult)};
// Load the optional C API dynamically: Windows OCR users need no Tesseract DLLs.
TessEngine engine(s);
auto &lib = engine.library;
auto image = lib.symbol<void (*)(void *, const unsigned char *, int, int, int, int)>("TessBaseAPISetImage");
auto mode = lib.symbol<void (*)(void *, int)>("TessBaseAPISetPageSegMode");
auto recognize = lib.symbol<int (*)(void *, void *)>("TessBaseAPIRecognize");
auto getText = lib.symbol<char *(*)(void *)>("TessBaseAPIGetUTF8Text");
auto freeText = lib.symbol<void (*)(const char *)>("TessDeleteText");
auto createMonitor = lib.symbol<void *(*)()>("TessMonitorCreate");
auto freeMonitor = lib.symbol<void (*)(void *)>("TessMonitorDelete");
auto deadline = lib.symbol<void (*)(void *, int)>("TessMonitorSetDeadlineMSecs");
std::unique_ptr<void, void (*)(void *)> monitor(createMonitor(), freeMonitor);
if (!monitor) throw std::runtime_error("Cannot create Tesseract progress monitor");
auto pixels = decodeImage(clip);
Bytes gray(size_t(pixels.width) * pixels.height);
for (size_t i = 0; i < gray.size(); ++i)
gray[i] = BYTE((29 * pixels.bgra[4*i] + 150 * pixels.bgra[4*i+1] + 77 * pixels.bgra[4*i+2]) >> 8);
image(engine.handle, gray.data(), pixels.width, pixels.height, 1, pixels.width);
mode(engine.handle, 11); // Sparse text: screenshots often contain separate labels and paragraphs.
deadline(monitor.get(), 30000);
if (recognize(engine.handle, monitor.get()) != 0)
throw std::runtime_error("Tesseract recognition failed or exceeded 30 seconds");
std::unique_ptr<char, void (*)(const char *)> text(getText(engine.handle), freeText);
if (!text) throw std::runtime_error("Tesseract returned no result");
return wide(text.get());
}
} // namespace ks
+4
View File
@@ -10,4 +10,8 @@ struct Pixels
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
Format imageDib(const Pixels &);
std::wstring recognizeText(const Clip &);
std::wstring recognizeText(const Clip &, const Settings &);
std::filesystem::path tesseractExecutable(const Settings &);
void validateTesseract(const Settings &);
std::wstring ocrProfile(const Settings &);
} // namespace ks
+1769
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
#pragma once
#include "platform.h"
namespace ks
{
// Only the on-demand screenshot process owns this state; no resident App dependency.
struct ScreenshotHost {
HINSTANCE instance{};
HWND window{};
unsigned windows{};
bool (*notify)(const Clip &){};
bool autoOpenEditor = false;
};
inline constexpr UINT ScreenshotRequest=WM_APP+71, ScreenshotIdle=WM_APP+72;
void showScreenshot(ScreenshotHost &);
void showScreenshotEditor(ScreenshotHost &,const Clip &);
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
enum class LineEnds { End, Start, Both, None };
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse };
struct ScreenshotOffset { double x, y; };
struct ScreenshotLink {
unsigned long long target{};
double u{}, v{}; // Relative boundary position, or fractional position along a path.
};
struct ScreenshotMark {
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;
unsigned long long id{};
ScreenshotLink fromLink, toLink;
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,
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);
}
+149
View File
@@ -0,0 +1,149 @@
#include "screenshot_notifications.h"
#include <atomic>
#include <shlobj.h>
#include <propkey.h>
#include <propvarutil.h>
#include <wrl/client.h>
namespace ks {
namespace {
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
constexpr GUID ActivatorId{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}};
}
namespace {
using Microsoft::WRL::ComPtr;
void check(HRESULT result) {
if(FAILED(result)) throw std::runtime_error("Screenshot registration failed: " + std::to_string((unsigned long)result));
}
void registryString(const std::wstring &path,const wchar_t *name,const std::wstring &value)
{
HKEY key{};
if(RegCreateKeyExW(HKEY_CURRENT_USER,path.c_str(),0,nullptr,0,KEY_SET_VALUE,nullptr,&key,nullptr)!=ERROR_SUCCESS)
throw std::runtime_error("Cannot register screenshot notifications");
auto result=RegSetValueExW(key,name,0,REG_SZ,(const BYTE *)value.c_str(),DWORD((value.size()+1)*sizeof(wchar_t)));
RegCloseKey(key);
if(result!=ERROR_SUCCESS) throw std::runtime_error("Cannot register screenshot notification activation");
}
}
void registerScreenshotNotifications()
{
auto exe=(std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe").wstring();
if(!std::filesystem::exists(exe)) throw std::runtime_error("Screenshot helper is missing");
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",nullptr,L"\""+exe+L"\" --toast-activated");
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",L"ServerExecutable",exe);
auto identity=std::wstring(L"Software\\Classes\\AppUserModelId\\")+AppId;
registryString(identity,L"DisplayName",L"KSwitcher Screenshot");
registryString(identity,L"IconUri",exe);
registryString(identity,L"CustomActivator",L"{e3f78047-f0ad-49b6-9074-411931706167}");
// A shortcut supplies desktop notification identity and its COM activation callback.
ComPtr<IShellLinkW> link; check(CoCreateInstance(CLSID_ShellLink,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&link)));
check(link->SetPath(exe.c_str())); check(link->SetIconLocation(exe.c_str(),0));
ComPtr<IPropertyStore> properties; check(link.As(&properties));
PROPVARIANT id{}; check(InitPropVariantFromString(AppId,&id));
auto result=properties->SetValue(PKEY_AppUserModel_ID,id); PropVariantClear(&id); check(result);
GUID activator=ActivatorId;
PROPVARIANT clsid{}; clsid.vt=VT_CLSID; clsid.puuid=&activator;
check(properties->SetValue(PKEY_AppUserModel_ToastActivatorCLSID,clsid));
check(properties->Commit());
PWSTR programs{}; check(SHGetKnownFolderPath(FOLDERID_Programs,0,nullptr,&programs));
auto shortcut=std::filesystem::path(programs)/L"KSwitcher Screenshot.lnk"; CoTaskMemFree(programs);
ComPtr<IPersistFile> file; check(link.As(&file)); check(file->Save(shortcut.c_str(),TRUE));
// Notify the shell that notification identity/activation registration changed.
SHChangeNotify(SHCNE_ASSOCCHANGED,SHCNF_IDLIST,nullptr,nullptr);
}
bool validScreenshotToken(const std::wstring &id)
{
if(id.size()!=36) return false;
for(size_t i=0;i<id.size();++i) {
if(i==8 || i==13 || i==18 || i==23) { if(id[i]!=L'-') return false; }
else if(!((id[i]>=L'0' && id[i]<=L'9') || (id[i]>=L'a' && id[i]<=L'f') || (id[i]>=L'A' && id[i]<=L'F'))) return false;
}
return true;
}
namespace {
// Windows SDK INotificationActivationCallback ABI (notificationactivationcallback.h).
constexpr GUID CallbackId{0x53e31837,0x6600,0x4a81,{0x93,0x95,0x75,0xcf,0xfe,0x74,0x6f,0x94}};
struct NotificationInput { LPCWSTR key,value; };
struct NotificationCallback : IUnknown {
virtual HRESULT STDMETHODCALLTYPE Activate(LPCWSTR,LPCWSTR,const NotificationInput *,ULONG)=0;
};
class Activator final : public NotificationCallback {
std::atomic<ULONG> references{1};
DWORD thread=GetCurrentThreadId();
std::wstring target;
bool quit;
public:
Activator(std::wstring path,bool shouldQuit):target(std::move(path)),quit(shouldQuit) {}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID id,void **out) override {
if(!out) return E_POINTER;
*out=nullptr;
if(id!=IID_IUnknown && id!=CallbackId) return E_NOINTERFACE;
*out=static_cast<NotificationCallback *>(this); AddRef(); return S_OK;
}
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
HRESULT STDMETHODCALLTYPE Activate(LPCWSTR app,LPCWSTR args,const NotificationInput *,ULONG) override {
if(!app || std::wstring(app)!=AppId || !args) return E_INVALIDARG;
const bool update=std::wstring(args)==L"check-update";
if(!update && !validScreenshotToken(args)) return E_INVALIDARG;
auto path=update ? (std::filesystem::path(target).parent_path()/L"KSwitcher.exe").wstring() : target;
auto command=update ? L"\""+path+L"\" --check-update" :
L"\""+path+L"\" --open \"kswitcher-screenshot:"+args+L"\"";
STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{};
auto ok=CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,nullptr,nullptr,&startup,&process);
auto result=ok?S_OK:HRESULT_FROM_WIN32(GetLastError());
if(ok) {
AllowSetForegroundWindow(process.dwProcessId); ResumeThread(process.hThread);
CloseHandle(process.hThread); CloseHandle(process.hProcess);
}
if(quit) PostThreadMessageW(thread,WM_QUIT,0,0);
return result;
}
};
class ActivatorFactory final : public IClassFactory {
std::atomic<ULONG> references{1};
std::wstring target;
bool quit;
public:
ActivatorFactory(std::wstring path,bool shouldQuit):target(std::move(path)),quit(shouldQuit) {}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID id,void **out) override {
if(!out) return E_POINTER;
*out=nullptr;
if(id!=IID_IUnknown && id!=IID_IClassFactory) return E_NOINTERFACE;
*out=static_cast<IClassFactory *>(this); AddRef(); return S_OK;
}
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
HRESULT STDMETHODCALLTYPE CreateInstance(IUnknown *outer,REFIID id,void **out) override {
if(outer) return CLASS_E_NOAGGREGATION;
auto object=new Activator(target,quit); auto result=object->QueryInterface(id,out); object->Release(); return result;
}
HRESULT STDMETHODCALLTYPE LockServer(BOOL) override { return S_OK; }
};
}
int runScreenshotToastActivator()
{
if(FAILED(CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED))) return 1;
MSG message{}; PeekMessageW(&message,nullptr,0,0,PM_NOREMOVE);
auto factory=new ActivatorFactory(executable(),true); DWORD registration{};
auto result=CoRegisterClassObject(ActivatorId,factory,CLSCTX_LOCAL_SERVER,REGCLS_MULTIPLEUSE,&registration);
factory->Release();
if(SUCCEEDED(result)) {
auto timer=SetTimer(nullptr,0,60000,[](HWND,UINT,UINT_PTR,DWORD){PostQuitMessage(0);});
while(GetMessageW(&message,nullptr,0,0)>0) { TranslateMessage(&message); DispatchMessageW(&message); }
if(timer) KillTimer(nullptr,timer);
CoRevokeClassObject(registration);
}
CoUninitialize(); return FAILED(result)?1:0;
}
DWORD startScreenshotActivationListener()
{
auto target=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
auto factory=new ActivatorFactory(target.wstring(),false);
DWORD registration{};
auto result=CoRegisterClassObject(ActivatorId,factory,CLSCTX_LOCAL_SERVER,REGCLS_MULTIPLEUSE,&registration);
factory->Release();
return SUCCEEDED(result)?registration:0;
}
}
+23
View File
@@ -0,0 +1,23 @@
#include "app.h"
namespace ks {
void launchScreenshot(App &app)
{
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
if (!std::filesystem::exists(path))
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
auto command=L"\""+path.wstring()+L"\" --auto-open-editor="+
(app.settings.screenshotAutoOpenEditor?L"1":L"0");
if(app.settings.writeLog) command+=L" --write-log";
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
// Grant foreground rights before the child's first selection window opens.
if (!CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,
nullptr,path.parent_path().c_str(),&startup,&process))
throw std::runtime_error(utf8(lastError(L"Cannot start screenshot editor")));
AllowSetForegroundWindow(process.dwProcessId);
ResumeThread(process.hThread);
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
}
}

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