Compare commits

..

17 Commits

Author SHA1 Message Date
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
114 changed files with 5682 additions and 143 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.9 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.
+181 -11
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 and the snippet editor run on demand in **KSwitcher.Config.exe**, beside the main executable.
Repeated launches activate the existing configurator; closing its last window exits that process.
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
load clipboard history, dictionaries or global input hooks. Clipboard history and the log viewer remain
in the resident app. Screen capture and all screenshot editors run on demand in
**KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same
process. Closing the last editor (or cancelling the only selection) exits that process. It does not
load resident settings, dictionaries, clipboard history or keyboard hooks. Editor windows have
stable numbered titles within that process. Keep all three executables together when copying or
updating the application. Close screenshot editors before installing an update.
If settings change in the tray while the configurator is open, a conflicting Save is rejected; reopen
the configurator to load the latest settings.
Updates from v0.3.x–v0.4.1 first install the compatible **v0.4.2 bridge** through
Update in the tray menu. Select Update again to install the full current release;
manual extraction is not required. Future full releases use stable `release-vX.Y.Z`
tags, which the bridge and newer clients recognize. Older clients ignore those tags
and continue to find the bridge instead of skipping it. Versions v0.2.3 and earlier
still require the previously documented initial configurator migration.
Modern ZIPs contain `release-files.json` (schema, version, file paths, sizes and SHA-256
hashes). The updater validates that every packaged file is declared, all hashes match,
and paths stay within the installation directory. `data/` is protected; existing
dictionaries are preserved. Adding a new program file no longer needs a new updater.
In Settings, enable **Start with Windows** and **Start with Windows as administrator**
to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing
the elevated task requests UAC approval if the app is not already elevated. No password is stored;
@@ -43,11 +70,120 @@ 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.
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. 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
@@ -62,9 +198,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 +228,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 +268,7 @@ only bounded previews/metadata stay in memory. Image data is loaded on demand an
the history popup is hidden. Settings **Write log** (`writeLog`, enabled by default) controls all
log writes. Disabling it also clears diagnostic context from memory; existing log files remain.
Successful operations stay in a memory buffer of at most 40 diagnostic events for 30 seconds.
Only failures or suspected repeated commands flush this context to `kswitcher.log`; flushed
Only failures flush this context to `kswitcher.log`; flushed
context is discarded. Ordinary application errors are also logged when logging is enabled.
Incident context contains tracked physical input, the expected word, copied text, conversion result,
and layout IDs, correlated by request ID even when verification fails. Text fields use quoted,
@@ -121,8 +277,8 @@ original length and a truncation flag; each field is limited to 512 UTF-16 code
Manual conversion attempts in `kswitcher.log` have request IDs and explicit outcomes, including
busy, empty copy, verification mismatch, unchanged conversion, and cancelled paste. Requests
record the process, window, input version, and repeated attempts within ten seconds without new
input. A second repeated manual command triggers a suspicion, not proof of failure; intentional
layout cycling can also trigger it. An unchanged result containing no letters, or a direct command
input. Successful repeated commands do not flush the log; intentional layout cycling is normal.
An unchanged result containing no letters, or a direct command
to the detected current layout, is treated as expected. `paste-sent-unverified` means Windows
accepted the paste shortcut; it does not prove that the editor applied the replacement.
The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates.
@@ -227,6 +383,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 +412,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.
+15 -19
View File
@@ -1,31 +1,27 @@
# Latest Windows release
## v0.2.2 — 2026-10-28
## v0.6.9 — 2026-10-29
- 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
- Show automatic update announcements using the same native Windows notification channel as screenshots. Review update opens the update check and confirmation; Later dismisses the notification.
- Retry notification delivery after five minutes if Windows rejects it, and log update checks and delivery results. Successfully submitted notifications are deduplicated for the current version during the running session.
- Fix Settings flicker when using the mouse wheel: skip layout work at scroll boundaries, move only changed controls in one batch, and avoid erasing the entire window background.
Verified: 168 native checks, 51 input/layout checks, and 26 updater/metadata checks.
The executable description is `KSwitcher — native keyboard and clipboard utility`.
## Validation
203 native checks, 456 input/layout checks, screenshot editor UI integration, isolated notification registration and activation checks, and 45 updater checks. Regression coverage includes notification retry/deduplication, review-only activation, partial wheel steps, scroll boundaries and batched control movement.
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:
Windows notification banners follow system notification and Do not disturb settings. The new update notification behavior takes effect after installing v0.6.9.
Build and package:
```powershell
.\build.ps1 -Stage -Test
.\build.ps1 -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
cd6b3dd62065e50b68cf77344fbad8d7be41467142f31527933342f00e3c0f63 KSwitcher-win-x64.zip
+32 -6
View File
@@ -13,37 +13,63 @@ 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' }
}
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 -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,9,0
PRODUCTVERSION 0,6,9,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.9"
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

+90 -7
View File
@@ -9,8 +9,8 @@ $ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function Get-StableVersion([string]$Tag) {
if ($Tag -notmatch '^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
try { return [version]($Tag -replace '^v','') } catch { return $null }
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
try { return [version]($Tag -replace '^(?:release-)?v','') } catch { return $null }
}
function Assert-ReleaseUrl([string]$Url) {
$uri = [uri]$Url
@@ -63,9 +63,15 @@ function Assert-Checksum([string]$Archive,[string]$Checksum) {
$matchesFound = @([IO.File]::ReadAllLines($Checksum) | Where-Object { $_ -cmatch '^([a-fA-F0-9]{64})\s+\*?KSwitcher-win-x64\.zip$' })
if ($matchesFound.Count -ne 1) { throw 'Missing or ambiguous archive checksum.' }
$expected = ($matchesFound[0] -split '\s+')[0]
if ((Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash -ne $expected) { throw 'Release checksum verification failed.' }
# Do not depend on PowerShell module autoloading: hosts can inherit a
# PowerShell 7 PSModulePath while this helper runs in Windows PowerShell 5.1.
$stream = [IO.File]::OpenRead($Archive)
$sha = [Security.Cryptography.SHA256]::Create()
try { $actual = [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','') }
finally { $sha.Dispose(); $stream.Dispose() }
if ($actual -ne $expected) { throw 'Release checksum verification failed.' }
}
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
function Expand-LegacyRelease([string]$Archive,[string]$Destination,[version]$Expected) {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
try {
@@ -74,7 +80,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
foreach ($entry in $zip.Entries) {
$name = $entry.FullName.Replace('\','/')
if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue }
if ($name -cnotmatch '^(KSwitcher\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or
if ($name -cnotmatch '^(KSwitcher(?:\.(?:Config|Screenshot))?\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or
$name.EndsWith('/.') -or $name.EndsWith('/..') -or $name.EndsWith('.') -or
(($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($name)) {
throw "Unexpected or unsafe archive entry: $name"
@@ -88,8 +94,13 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
foreach ($required in @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md')) {
if (!$seen.Contains($required)) { throw "Incomplete release: $required" }
}
if ($Expected -gt [version]'0.2.3' -and !$seen.Contains('KSwitcher.Config.exe')) {
throw 'Incomplete release: KSwitcher.Config.exe'
}
} finally { $zip.Dispose() }
$exe = Join-Path $Destination 'KSwitcher.exe'
foreach ($binary in @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe')) {
$exe = Join-Path $Destination $binary
if (!(Test-Path -LiteralPath $exe)) { continue }
$version = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe)
if ($version.ProductName -ne 'KSwitcher' -or (Get-StableVersion $version.FileVersion) -ne $Expected) {
throw 'Executable identity/version does not match the release.'
@@ -99,6 +110,76 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
$pe = [BitConverter]::ToInt32($bytes,60)
if ($pe -lt 64 -or $pe -gt $bytes.Length-6 -or [BitConverter]::ToUInt32($bytes,$pe) -ne 0x4550 -or
[BitConverter]::ToUInt16($bytes,$pe+4) -ne 0x8664) { throw 'Release is not a Windows x64 executable.' }
}
}
function Get-Sha256([IO.Stream]$Stream) {
$sha=[Security.Cryptography.SHA256]::Create()
try { return [BitConverter]::ToString($sha.ComputeHash($Stream)).Replace('-','').ToLowerInvariant() }
finally { $sha.Dispose() }
}
function Assert-PackagePath([string]$Name) {
if (!$Name -or $Name.Length -gt 220 -or $Name -match '[\\:<>"|?*]' -or $Name.StartsWith('/')) { throw "Unsafe package path: $Name" }
$parts=$Name.Split('/')
foreach ($part in $parts) {
if (!$part -or $part -in @('.','..') -or $part -match '[. ]$|[\x00-\x1f]' -or
$part -match '^(?i:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\.|$)') { throw "Unsafe package path: $Name" }
}
if ($parts[0] -ieq 'data' -or $parts[0] -like '.kswitcher-*') { throw "Protected user path: $Name" }
}
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip=[IO.Compression.ZipFile]::OpenRead($Archive)
try {
$manifests=@($zip.Entries | Where-Object FullName -CEQ 'release-files.json')
if (!$manifests.Count) {
if ($Expected -gt [version]'0.4.2') { throw 'Release manifest is missing.' }
$zip.Dispose(); $zip=$null
Expand-LegacyRelease $Archive $Destination $Expected
return
}
if ($manifests.Count -ne 1 -or $manifests[0].Length -gt 256KB) { throw 'Invalid release manifest.' }
$reader=[IO.StreamReader]::new($manifests[0].Open())
try { $manifest=$reader.ReadToEnd() | ConvertFrom-Json } finally { $reader.Dispose() }
if ($manifest.schema -ne 1 -or (Get-StableVersion $manifest.version) -ne $Expected -or
@($manifest.files).Count -lt 1 -or @($manifest.files).Count -gt 1000) { throw 'Unsupported or mismatched release manifest.' }
$declared=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($file in $manifest.files) {
Assert-PackagePath $file.path
if ($file.path -ieq 'release-files.json' -or $declared.ContainsKey($file.path) -or
$file.sha256 -notmatch '^[a-fA-F0-9]{64}$' -or $file.size -lt 0 -or $file.size -gt 100MB) { throw 'Invalid manifest file entry.' }
$declared.Add($file.path,$file)
}
if (!$declared.ContainsKey('KSwitcher.exe')) { throw 'Main executable is missing from the manifest.' }
$seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$total=0L
foreach ($entry in $zip.Entries) {
$name=$entry.FullName.Replace('\','/')
if ($name.EndsWith('/')) { Assert-PackagePath $name.TrimEnd('/'); continue }
Assert-PackagePath $name
if (!$seen.Add($name) -or (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000) { throw 'Duplicate or linked archive entry.' }
if ($name -ceq 'release-files.json') { continue }
if (!$declared.ContainsKey($name)) { throw "Undeclared package file: $name" }
$file=$declared[$name]
if ($entry.Length -ne $file.size) { throw "Package file size mismatch: $name" }
$total+=$entry.Length
if ($total -gt 100MB -or $zip.Entries.Count -gt 1100) { throw 'Release expands beyond allowed limits.' }
$stream=$entry.Open()
try { $hash=Get-Sha256 $stream } finally { $stream.Dispose() }
if ($hash -ne $file.sha256) { throw "Package file checksum mismatch: $name" }
}
if ($seen.Count -ne $declared.Count+1) { throw 'Archive does not contain every manifest file.' }
[IO.Directory]::CreateDirectory($Destination) | Out-Null
Assert-PlainDirectory $Destination
foreach ($entry in $zip.Entries) {
if ($entry.FullName.EndsWith('/')) { continue }
$target=Join-Path $Destination $entry.FullName
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($target)) | Out-Null
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($target))
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry,$target,$false)
}
$info=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $Destination 'KSwitcher.exe'))
if ($info.ProductName -ne 'KSwitcher' -or (Get-StableVersion $info.FileVersion) -ne $Expected) { throw 'Main executable identity/version mismatch.' }
} finally { if ($zip) { $zip.Dispose() } }
}
function Assert-PlainDirectory([string]$Directory) {
$item = Get-Item -LiteralPath $Directory -Force
@@ -112,7 +193,7 @@ function Install-Files([string]$Stage,[string]$Target,[string]$Backup,[Collectio
foreach ($file in @(Get-ChildItem -LiteralPath $Stage -File -Recurse)) {
$relative = $file.FullName.Substring($Stage.Length+1)
$destination = Join-Path $Target $relative
if ($relative.StartsWith('dictionaries\') -and (Test-Path -LiteralPath $destination)) { continue }
if ($relative.StartsWith('dictionaries\',[StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $destination)) { continue }
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($destination))
if (Test-Path -LiteralPath $destination) {
@@ -165,6 +246,8 @@ try {
Receive-File $release.checksum $checksum 64KB
Assert-Checksum $archive $checksum
Expand-Release $archive $stage (Get-StableVersion $release.version)
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
Write-Result @{state='prepared';version=$release.version}
$ready.Set() | Out-Null
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
+168 -22
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);
@@ -266,8 +270,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 +299,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);
@@ -330,6 +418,7 @@ void App::applySettings(const Settings &s)
{
bool autostartChanged = false;
validateSettings(s);
validateTesseract(s);
if (s.autoCorrect)
{
AutoCorrector probe;
@@ -372,6 +461,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 +488,18 @@ void App::reloadLayouts()
}
registerHotkeys(settings);
}
void App::clearHistory()
{
if (busy) throw std::runtime_error("Wait for the current operation to finish.");
if (history) history->clear();
if (historyWindow) refreshHistory(*this);
}
void App::setHotkeyCapture(HWND field)
{
hotkeyCapture = field;
hotkeyCaptureProcess = 0;
if (field) GetWindowThreadProcessId(field, &hotkeyCaptureProcess);
}
bool App::isHotkey(DWORD key, UINT mods)
{
for (auto &[id, text] : shortcuts(settings))
@@ -496,6 +598,10 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
// down AND up events use exactly the same path as physical keyboard input.
if (ownKeyboardInput(key) || installingUpdate)
return false;
DWORD captureProcess{};
if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess);
if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess))
setHotkeyCapture(nullptr);
if (hotkeyCapture && GetForegroundWindow() == GetAncestor(hotkeyCapture, GA_ROOT))
{
bool modifier = key.vkCode == VK_CONTROL || key.vkCode == VK_SHIFT || key.vkCode == VK_MENU ||
@@ -569,6 +675,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 +691,11 @@ void App::dispatch(int command)
showSettings(*this);
return;
}
if (command == 22)
{
showLogs(*this);
return;
}
if (command == 21)
{
showSnippets(*this);
@@ -619,9 +736,6 @@ void App::transform(int command, int snippet)
L" window=" + std::to_wstring(reinterpret_cast<uintptr_t>(target)) +
L" version=" + std::to_wstring(currentVersion) +
L" repeat=" + std::to_wstring(repeatAttempts));
if (repeatAttempts >= 2)
diagnosticEvent(L"Transform suspicion id=" + std::to_wstring(attempt) +
L" reason=repeated-command-without-input (may be intentional cycling)");
}
if (busy.exchange(true))
{
@@ -643,8 +757,7 @@ void App::transform(int command, int snippet)
version = inputVersion;
}
enqueue(
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt,
suspicious = manual && repeatAttempts >= 2]
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt]
{
struct BusyReset
{
@@ -679,7 +792,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 +805,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 = [&]
@@ -1027,6 +1139,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 +1167,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 +1194,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 +1226,9 @@ void App::trayMenu()
{
switch (cmd)
{
case 24:
dispatch(Screenshot);
break;
case 10:
showHistory(*this);
break;
@@ -1134,9 +1253,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 +1289,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.");
@@ -1206,6 +1330,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 +1347,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,7 +1371,7 @@ 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:
@@ -1289,6 +1419,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
case WM_COPYDATA:
{
auto *data = (COPYDATASTRUCT *)lp;
if (data && data->dwData >= ConfigRead && data->dwData < ConfigReply)
{
if (app->installingUpdate) return FALSE;
return configurationRequest(*app, (HWND)wp, *data);
}
if (data && data->dwData == 1 && data->cbData == sizeof(int))
{
int cmd;
@@ -1300,7 +1435,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;
}
@@ -1345,6 +1482,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 +1538,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 |
@@ -1412,6 +1554,9 @@ int App::execute()
confirmUpdatedStartup(updateStartupEvent);
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 +1575,7 @@ int App::execute()
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
if(screenshotActivation) CoRevokeClassObject(screenshotActivation);
return (int)msg.wParam;
}
} // namespace ks
+17 -3
View File
@@ -14,7 +14,7 @@ namespace ks
inline constexpr UINT TrayMessage = WM_APP + 1, ErrorMessage = WM_APP + 2, HistoryMessage = WM_APP + 3,
SnippetMessage = WM_APP + 4;
inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, AutoCorrect = 6,
Direct = 100;
Screenshot = 7, Direct = 100;
inline constexpr UINT AutoCorrectMessage = WM_APP + 6;
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
struct LastTransform
@@ -43,6 +43,7 @@ class App
std::atomic<ULONGLONG> pauseUntil{0};
HWND pasteTarget{};
HWND hotkeyCapture{};
DWORD hotkeyCaptureProcess{};
HWND logWindow{};
std::mutex stateMutex;
Run run;
@@ -60,12 +61,15 @@ 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 setHotkeyCapture(HWND);
bool isHotkey(DWORD, UINT);
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
@@ -80,9 +84,18 @@ 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{};
@@ -96,7 +109,8 @@ class App
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
};
void showHistory(App &);
void showHistory(App &, bool visible = true);
void launchScreenshot(App &);
void refreshHistory(App &);
void showSettings(App &, bool visible = true);
void showLogs(App &);
+205
View File
@@ -0,0 +1,205 @@
#include "configuration.h"
#include <commctrl.h>
#include <cstring>
namespace ks
{
App *App::current = nullptr;
namespace
{
HWND service{};
Bytes response;
bool replied{};
Bytes request(App &app, ULONG_PTR command, const Bytes &payload = {})
{
if (!service || !configurationPeer(service, L"KSwitcher.exe"))
throw std::runtime_error("KSwitcher has stopped. Close and reopen the configurator.");
if (payload.size() > ConfigLimit) throw std::runtime_error("Settings are too large.");
response.clear(); replied = false;
COPYDATASTRUCT message{command, (DWORD)payload.size(), const_cast<unsigned char *>(payload.data())};
DWORD_PTR result{};
SetLastError(ERROR_SUCCESS);
if (!SendMessageTimeoutW(service, WM_COPYDATA, (WPARAM)app.window, (LPARAM)&message,
SMTO_ABORTIFHUNG | SMTO_ERRORONEXIT, 15000, &result) || !replied)
{
if (GetLastError() == ERROR_ACCESS_DENIED)
throw std::runtime_error("Open Settings from the KSwitcher tray menu so both processes use the same privileges.");
throw std::runtime_error("KSwitcher did not respond. The operation may still finish; reopen Settings to check.");
}
if (result != 1)
throw std::runtime_error(response.empty() ? "KSwitcher rejected the request." :
std::string(response.begin(), response.end()));
return response;
}
LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
{
auto *app = App::current;
if (!app) return DefWindowProcW(window, message, wp, lp);
if (message == WM_COPYDATA)
{
auto *data = reinterpret_cast<COPYDATASTRUCT *>(lp);
if ((HWND)wp == service && data && data->dwData == ConfigReply && data->cbData <= ConfigLimit &&
(!data->cbData || data->lpData))
{
response.clear();
if (data->cbData)
{
auto first = static_cast<const unsigned char *>(data->lpData);
response.assign(first, first + data->cbData);
}
replied = true;
return TRUE;
}
return FALSE;
}
if (message == WM_APP + 20)
{
try
{
if (wp == 21) showSnippets(*app);
else showSettings(*app);
}
catch (const std::exception &e)
{
MessageBoxW(app->settingsWindow, wide(e.what()).c_str(), L"KSwitcher configurator", MB_OK | MB_ICONERROR);
}
return 0;
}
if (message == WM_CLOSE)
{
if (app->settingsWindow) DestroyWindow(app->settingsWindow);
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
PostQuitMessage(0);
return 0;
}
return DefWindowProcW(window, message, wp, lp);
}
}
// The configurator owns no history store, dictionaries, worker or input hooks.
// All mutations are validated and committed by the resident process.
App::App(HINSTANCE h, bool, const std::filesystem::path &) : instance(h)
{
current = this;
layouts = readLayouts();
}
App::~App()
{
if (settingsWindow) DestroyWindow(settingsWindow);
if (snippetsWindow) DestroyWindow(snippetsWindow);
if (window) DestroyWindow(window);
current = nullptr;
}
void App::applySettings(const Settings &next)
{
validateSettings(next);
auto before = encodeSettings(settings), after = encodeSettings(next);
DWORD length = (DWORD)before.size();
Bytes payload(sizeof(length));
memcpy(payload.data(), &length, sizeof(length));
payload.insert(payload.end(), before.begin(), before.end());
payload.insert(payload.end(), after.begin(), after.end());
settings = decodeSettings(request(*this, ConfigSave, payload), layouts);
}
void App::reloadLayouts()
{
auto bytes = request(*this, ConfigLayouts);
layouts = readLayouts();
settings = decodeSettings(bytes, layouts);
}
void App::clearHistory() { request(*this, ConfigClearHistory); }
void App::setHotkeyCapture(HWND field)
{
hotkeyCapture = field;
Bytes payload(sizeof(field));
memcpy(payload.data(), &field, sizeof(field));
// Focus notifications must not let an exception escape a window callback.
try { request(*this, ConfigCapture, payload); }
catch (const std::exception &e)
{
hotkeyCapture = nullptr;
if (field) MessageBoxW(settingsWindow, wide(e.what()).c_str(), L"Shortcut capture unavailable", MB_OK | MB_ICONWARNING);
}
}
void showLogs(App &app) { request(app, ConfigLogs); }
int runConfigurator(HINSTANCE instance, bool snippets)
{
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock.");
const bool existing = GetLastError() == ERROR_ALREADY_EXISTS;
struct Close { HANDLE handle; ~Close() { CloseHandle(handle); } } close{singleton};
if (existing)
{
HWND window{};
for (int i = 0; i < 100 && !window; ++i)
{
window = FindWindowW(L"KSwitcher.Config", nullptr);
if (!window) Sleep(50);
}
if (!window || !configurationPeer(window, L"KSwitcher.Config.exe"))
throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly.");
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
AllowSetForegroundWindow(pid);
PostMessageW(window, WM_APP + 20, snippets ? 21 : 20, 0);
return 0;
}
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
if (!service)
{
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.exe";
auto command = L"\"" + path.wstring() + L"\"";
STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{};
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
path.parent_path().c_str(), &startup, &process))
throw std::runtime_error("Cannot start KSwitcher beside the configurator.");
CloseHandle(process.hThread); CloseHandle(process.hProcess);
for (int i = 0; i < 100 && !service; ++i)
{
Sleep(50); service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
}
}
if (!configurationPeer(service, L"KSwitcher.exe"))
throw std::runtime_error("Run the configurator from the active KSwitcher installation.");
INITCOMMONCONTROLSEX controls{sizeof(controls), ICC_STANDARD_CLASSES};
InitCommonControlsEx(&controls);
App app(instance);
WNDCLASSW cls{}; cls.hInstance = instance; cls.lpfnWndProc = controller; cls.lpszClassName = L"KSwitcher.Config";
RegisterClassW(&cls);
app.window = CreateWindowExW(WS_EX_TOOLWINDOW, cls.lpszClassName, L"KSwitcher configurator", WS_POPUP,
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
app.settings = decodeSettings(request(app, ConfigRead), app.layouts);
if (snippets) showSnippets(app); else showSettings(app);
MSG message;
while ((app.settingsWindow || app.snippetsWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
{
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
{
HWND popup = GetLastActivePopup(app.settingsWindow);
if (popup != app.settingsWindow && IsDialogMessageW(popup, &message)) continue;
}
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
TranslateMessage(&message); DispatchMessageW(&message);
}
return 0;
}
}
int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
{
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
int result = 1;
try
{
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
LocalFree(args);
result = ks::runConfigurator(instance, snippets);
}
catch (const std::exception &e)
{
MessageBoxW(nullptr, ks::wide(e.what()).c_str(), L"KSwitcher configurator", MB_OK | MB_ICONERROR);
}
CoUninitialize();
return result;
}
+98
View File
@@ -0,0 +1,98 @@
#include "configuration.h"
#include <cstring>
namespace ks
{
bool configurationPeer(HWND window, const wchar_t *filename)
{
DWORD pid{};
GetWindowThreadProcessId(window, &pid);
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
if (!process) return false;
wchar_t path[32768]; DWORD count = std::size(path);
bool ok = QueryFullProcessImageNameW(process, 0, path, &count) != 0;
CloseHandle(process);
auto expected = std::filesystem::path(executable()).parent_path() / filename;
return ok && _wcsicmp(path, expected.c_str()) == 0;
}
void launchConfiguration(App &app, bool snippets)
{
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
if (!std::filesystem::exists(path))
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package.");
auto command = L"\"" + path.wstring() + L"\"" + (snippets ? L" --snippets" : L"");
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
path.parent_path().c_str(), &startup, &process))
throw std::runtime_error(utf8(lastError(L"Cannot start configurator")));
AllowSetForegroundWindow(process.dwProcessId);
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
(void)app;
}
LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &message)
{
if (!configurationPeer(sender, L"KSwitcher.Config.exe") || message.cbData > ConfigLimit ||
(message.cbData && !message.lpData)) return FALSE;
Bytes reply;
bool ok = false;
try
{
switch (message.dwData)
{
case ConfigSave:
{
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
if (message.cbData < sizeof(DWORD)) throw std::runtime_error("Invalid settings request.");
DWORD length{};
memcpy(&length, message.lpData, sizeof(length));
if (length > message.cbData - sizeof(length)) throw std::runtime_error("Invalid settings snapshot.");
auto begin = static_cast<const unsigned char *>(message.lpData) + sizeof(length);
if (Bytes(begin, begin + length) != encodeSettings(app.settings))
throw std::runtime_error("Settings changed in KSwitcher. Close and reopen the configurator before saving.");
Bytes next(begin + length, static_cast<const unsigned char *>(message.lpData) + message.cbData);
if (next.empty()) throw std::runtime_error("Empty settings request.");
app.applySettings(decodeSettings(next, app.layouts));
reply = encodeSettings(app.settings);
break;
}
case ConfigLayouts:
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
app.reloadLayouts();
[[fallthrough]];
case ConfigRead:
reply = encodeSettings(app.settings);
break;
case ConfigClearHistory:
app.clearHistory();
break;
case ConfigCapture:
{
if (message.cbData != sizeof(HWND)) throw std::runtime_error("Invalid shortcut field.");
HWND field{}; memcpy(&field, message.lpData, sizeof(field));
DWORD fieldPid{}, senderPid{};
GetWindowThreadProcessId(field, &fieldPid);
GetWindowThreadProcessId(sender, &senderPid);
if (field && fieldPid != senderPid) throw std::runtime_error("Invalid shortcut field owner.");
app.setHotkeyCapture(field);
break;
}
case ConfigLogs:
PostMessageW(app.window, WM_HOTKEY, 22, 0);
break;
default: return FALSE;
}
ok = true;
}
catch (const std::exception &error)
{
std::string text = error.what();
reply.assign(text.begin(), text.end());
}
COPYDATASTRUCT response{ConfigReply, (DWORD)reply.size(), reply.data()};
DWORD_PTR received{};
if (!SendMessageTimeoutW(sender, WM_COPYDATA, (WPARAM)app.window, (LPARAM)&response,
SMTO_ABORTIFHUNG, 2000, &received)) return FALSE;
return ok ? 1 : 2;
}
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include "app.h"
namespace ks
{
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
ConfigReply = ConfigRead + 6;
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
bool configurationPeer(HWND, const wchar_t *filename);
void launchConfiguration(App &, bool snippets = false);
LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
}
+6 -1
View File
@@ -48,6 +48,10 @@ 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"([{«“‘\"";
@@ -59,7 +63,8 @@ struct Settings
L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
int maxItems = 500, maxMiB = 200, maxImageMiB = 20;
std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C",
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Ctrl+Shift+V";
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"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);
}
}
+119
View File
@@ -0,0 +1,119 @@
#include "screenshot.h"
#include "screenshot_notifications.h"
#include <commctrl.h>
#include <shellapi.h>
#include <roapi.h>
namespace {
constexpr wchar_t ControllerClass[]=L"KSwitcher.Screenshot.Controller";
LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM lp)
{
auto host=(ks::ScreenshotHost *)GetWindowLongPtrW(window,GWLP_USERDATA);
if(message==WM_NCCREATE) {
host=(ks::ScreenshotHost *)((CREATESTRUCTW *)lp)->lpCreateParams;
SetWindowLongPtrW(window,GWLP_USERDATA,(LONG_PTR)host);
}
if(host && message==ks::ScreenshotRequest) {
host->autoOpenEditor=(wp&1)!=0;
ks::setLoggingEnabled((wp&2)!=0);
try { ks::showScreenshot(*host); }
catch(const std::exception &e) {
MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screen capture",MB_OK|MB_ICONERROR);
PostMessageW(window,ks::ScreenshotIdle,0,0);
}
return TRUE;
}
if(host && message==WM_COPYDATA) {
auto data=(COPYDATASTRUCT *)lp;
if(!data || data->dwData!=73 || data->cbData!=37*sizeof(wchar_t) || !data->lpData) return FALSE;
std::wstring token((const wchar_t *)data->lpData,36);
if(!ks::validScreenshotToken(token)) return FALSE;
try { ks::showScreenshotEditor(*host,ks::loadNotifiedScreenshot(token)); }
catch(const std::exception &e) {
MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screenshot",MB_OK|MB_ICONINFORMATION);
PostMessageW(window,ks::ScreenshotIdle,0,0);
}
return TRUE;
}
if(host && message==ks::ScreenshotIdle && !host->windows) {
DestroyWindow(window);
PostQuitMessage(0);
return 0;
}
return DefWindowProcW(window,message,wp,lp);
}
}
int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
{
std::wstring token;
bool autoOpenEditor=false, writeLog=false;
int argc{}; auto argv=CommandLineToArgvW(GetCommandLineW(),&argc);
if(argc>1 && std::wstring(argv[argc-1])==L"--write-log") {
writeLog=true; --argc;
ks::setLoggingEnabled(true);
}
if(argc>=2 && std::wstring(argv[1])==L"--toast-activated") {
LocalFree(argv); return ks::runScreenshotToastActivator();
}
if(argc==2 && std::wstring(argv[1])==L"--register-notifications") {
LocalFree(argv); CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
int result=0; try { ks::registerScreenshotNotifications(); } catch(...) { result=1; }
CoUninitialize(); return result;
}
if(argc==2 && (std::wstring(argv[1])==L"--auto-open-editor=0" ||
std::wstring(argv[1])==L"--auto-open-editor=1")) {
autoOpenEditor=std::wstring(argv[1])==L"--auto-open-editor=1";
} else if(argc>1) {
if(argc==3 && std::wstring(argv[1])==L"--open") {
std::wstring uri=argv[2]; const std::wstring prefix=L"kswitcher-screenshot:";
if(uri.starts_with(prefix)) token=uri.substr(prefix.size());
}
if(!ks::validScreenshotToken(token)) { LocalFree(argv); return 1; }
}
LocalFree(argv);
COPYDATASTRUCT open{73,DWORD((token.size()+1)*sizeof(wchar_t)),token.data()};
HANDLE mutex=CreateMutexW(nullptr,FALSE,L"Local\\KSwitcher.Screenshot.Singleton");
if(!mutex) return 1;
// Wait through startup/shutdown races; the controller accepts requests only while alive.
bool acquired=false;
for(int attempt=0;attempt<100;++attempt) {
auto wait=WaitForSingleObject(mutex,0);
if(wait==WAIT_OBJECT_0 || wait==WAIT_ABANDONED) { acquired=true; break; }
if(auto existing=FindWindowW(ControllerClass,nullptr)) {
DWORD pid{}; GetWindowThreadProcessId(existing,&pid);
AllowSetForegroundWindow(pid);
DWORD_PTR accepted{};
if(SendMessageTimeoutW(existing,token.empty()?ks::ScreenshotRequest:WM_COPYDATA,token.empty()?(autoOpenEditor?1:0)|(writeLog?2:0):0,
token.empty()?0:(LPARAM)&open,SMTO_ABORTIFHUNG,2000,&accepted) && accepted) {
CloseHandle(mutex); return 0;
}
}
Sleep(50);
}
if(!acquired) { CloseHandle(mutex); return 1; }
RoInitialize(RO_INIT_SINGLETHREADED);
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_BAR_CLASSES};
InitCommonControlsEx(&controls);
ks::ScreenshotHost host{instance};
host.notify=ks::notifyScreenshot;
WNDCLASSW cls{}; cls.hInstance=instance; cls.lpfnWndProc=controller; cls.lpszClassName=ControllerClass;
RegisterClassW(&cls);
host.window=CreateWindowW(ControllerClass,L"KSwitcher Screenshot",0,0,0,0,0,nullptr,nullptr,instance,&host);
int result=1;
if(host.window) {
// Toast protocol activation may run unelevated while the editor was launched by an elevated resident.
// WM_COPYDATA accepts only a validated cache GUID, never a path or executable command.
ChangeWindowMessageFilterEx(host.window,WM_COPYDATA,MSGFLT_ALLOW,nullptr);
if(token.empty()) PostMessageW(host.window,ks::ScreenshotRequest,(autoOpenEditor?1:0)|(writeLog?2:0),0);
else SendMessageW(host.window,WM_COPYDATA,0,(LPARAM)&open);
MSG message{};
while((result=GetMessageW(&message,nullptr,0,0))>0) {
TranslateMessage(&message); DispatchMessageW(&message);
}
result=result<0?1:0;
}
RoUninitialize();
ReleaseMutex(mutex); CloseHandle(mutex);
return result;
}
+224
View File
@@ -0,0 +1,224 @@
#include "screenshot_notifications.h"
#include "ocr.h"
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <aclapi.h>
#include <sddl.h>
#include <shlobj.h>
#include <propkey.h>
#include <propvarutil.h>
#include <roapi.h>
#include <winstring.h>
#include <wincodec.h>
#ifdef __MINGW32__
// This SDK aliases BYTE and boolean, producing duplicate template specializations.
// Notifications use the boolean specialization; IReference<BYTE> is unused here.
#define ____FIReference_1_BYTE_INTERFACE_DEFINED__
#endif
#include <windows.ui.notifications.h>
#include <windows.data.xml.dom.h>
#include <wrl/client.h>
namespace ks {
namespace {
using Microsoft::WRL::ComPtr;
namespace Notifications=ABI::Windows::UI::Notifications;
namespace Xml=ABI::Windows::Data::Xml::Dom;
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
std::string notificationError;
void check(HRESULT result) { if(FAILED(result)) { char message[100]; snprintf(message,sizeof(message),"Windows screenshot notification failed (0x%08lx)",(unsigned long)result); throw std::runtime_error(message); } }
struct String {
HSTRING value{};
explicit String(const std::wstring &s) { check(WindowsCreateString(s.c_str(),(UINT32)s.size(),&value)); }
~String() { WindowsDeleteString(value); }
};
std::filesystem::path cacheDirectory()
{
PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_LocalAppData,0,nullptr,&folder));
auto path=std::filesystem::path(folder)/L"KSwitcher"/L"ScreenshotNotifications";
CoTaskMemFree(folder); std::filesystem::create_directories(path); return path;
}
std::filesystem::path previewDirectory()
{
HANDLE token{};
if(!OpenProcessToken(GetCurrentProcess(),TOKEN_QUERY,&token)) check(HRESULT_FROM_WIN32(GetLastError()));
DWORD size{}; GetTokenInformation(token,TokenUser,nullptr,0,&size);
std::vector<BYTE> data(size);
BOOL read=GetTokenInformation(token,TokenUser,data.data(),size,&size);
DWORD error=read?ERROR_SUCCESS:GetLastError(); CloseHandle(token); check(HRESULT_FROM_WIN32(error));
auto user=reinterpret_cast<TOKEN_USER *>(data.data())->User.Sid;
LPWSTR sidText{}; if(!ConvertSidToStringSidW(user,&sidText)) check(HRESULT_FROM_WIN32(GetLastError()));
std::wstring sid(sidText); LocalFree(sidText);
PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_ProgramData,0,nullptr,&folder));
auto path=std::filesystem::path(folder)/(L"KSwitcher.ScreenshotPreviews-"+sid); CoTaskMemFree(folder);
// The notification broker cannot load previews from some profile directories.
// Keep this cache outside the profile, private to the current user and SYSTEM.
auto sddl=L"O:"+sid+L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;"+sid+L")";
PSECURITY_DESCRIPTOR security{};
if(!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(),SDDL_REVISION_1,&security,nullptr))
check(HRESULT_FROM_WIN32(GetLastError()));
SECURITY_ATTRIBUTES attributes{sizeof(attributes),security,FALSE};
BOOL created=CreateDirectoryW(path.c_str(),&attributes);
error=created?ERROR_SUCCESS:GetLastError();
LocalFree(security);
if(error!=ERROR_ALREADY_EXISTS) check(HRESULT_FROM_WIN32(error));
// Do not use a pre-created directory or junction controlled by another user.
DWORD flags=GetFileAttributesW(path.c_str());
if(flags==INVALID_FILE_ATTRIBUTES || !(flags&FILE_ATTRIBUTE_DIRECTORY) || (flags&FILE_ATTRIBUTE_REPARSE_POINT))
throw std::runtime_error("Invalid screenshot preview directory");
PSID owner{}; PSECURITY_DESCRIPTOR existing{};
error=GetNamedSecurityInfoW(const_cast<wchar_t *>(path.c_str()),SE_FILE_OBJECT,OWNER_SECURITY_INFORMATION,
&owner,nullptr,nullptr,nullptr,&existing);
check(HRESULT_FROM_WIN32(error));
bool owned=EqualSid(owner,user); LocalFree(existing);
if(!owned) throw std::runtime_error("Screenshot preview directory belongs to another user");
return path;
}
void cleanCache(const std::filesystem::path &path)
{
std::vector<std::filesystem::directory_entry> files;
for(const auto &file:std::filesystem::directory_iterator(path))
if(file.is_regular_file() && validScreenshotToken(file.path().stem().wstring()) &&
(file.path().extension()==L".capture" || file.path().extension()==L".png")) files.push_back(file);
std::sort(files.begin(),files.end(),[](const auto &a,const auto &b){return a.last_write_time()>b.last_write_time();});
uintmax_t size=0;
for(size_t i=0;i<files.size();++i) {
size+=files[i].file_size();
if(i>=40 || size>256*1024*1024 || std::filesystem::file_time_type::clock::now()-files[i].last_write_time()>std::chrono::hours(24)) {
std::error_code ignored; std::filesystem::remove(files[i].path(),ignored);
}
}
}
void savePreview(const Clip &clip,const std::filesystem::path &path)
{
auto pixels=decodeImage(clip,360,220);
for(size_t i=3;i<pixels.bgra.size();i+=4) pixels.bgra[i]=255;
ComPtr<IWICImagingFactory> factory; check(CoCreateInstance(CLSID_WICImagingFactory,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&factory)));
ComPtr<IWICStream> stream; check(factory->CreateStream(&stream)); check(stream->InitializeFromFilename(path.c_str(),GENERIC_WRITE));
ComPtr<IWICBitmapEncoder> encoder; check(factory->CreateEncoder(GUID_ContainerFormatPng,nullptr,&encoder));
check(encoder->Initialize(stream.Get(),WICBitmapEncoderNoCache));
ComPtr<IWICBitmapFrameEncode> frame; check(encoder->CreateNewFrame(&frame,nullptr)); check(frame->Initialize(nullptr));
check(frame->SetSize(pixels.width,pixels.height));
auto format=GUID_WICPixelFormat32bppBGRA; check(frame->SetPixelFormat(&format));
if(format!=GUID_WICPixelFormat32bppBGRA) throw std::runtime_error("Unsupported preview pixel format");
check(frame->WritePixels(pixels.height,pixels.width*4,(UINT)pixels.bgra.size(),pixels.bgra.data()));
check(frame->Commit()); check(encoder->Commit());
}
std::wstring escapeXml(const std::wstring &s)
{
std::wstring result;
for(auto ch:s) { if(ch==L'&') result+=L"&amp;"; else if(ch==L'\"') result+=L"&quot;"; else if(ch==L'<') result+=L"&lt;"; else result+=ch; }
return result;
}
}
Clip loadNotifiedScreenshot(const std::wstring &id)
{
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
auto directory=cacheDirectory(); cleanCache(directory);
auto path=directory/(id+L".capture");
if(!std::filesystem::exists(path)) throw std::runtime_error("This screenshot notification has expired. Take a new screenshot.");
return Clip{{{CF_DIB,L"",unprotect(readFile(path,160*1024*1024))}}};
}
bool notifyScreenshot(const Clip &clip)
{
notificationError.clear();
const char *stage="registration";
try {
registerScreenshotNotifications();
check(SetCurrentProcessExplicitAppUserModelID(AppId));
stage="notification manager";
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
ComPtr<Notifications::IToastNotifier> notifier; String appId(AppId);
stage="notifier";
check(manager->CreateToastNotifierWithId(appId.value,&notifier));
stage="settings";
Notifications::NotificationSetting setting{};
auto settingResult=notifier->get_Setting(&setting);
// A first-time sender has no settings record until its first notification.
if(settingResult!=HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) check(settingResult);
if(setting!=Notifications::NotificationSetting_Enabled) throw std::runtime_error("Screenshot notifications are disabled in Windows settings");
stage="cache";
auto directory=cacheDirectory(); cleanCache(directory);
GUID guid{}; check(CoCreateGuid(&guid)); wchar_t value[40]{}; StringFromGUID2(guid,value,40);
std::wstring id(value+1,36);
auto dib=std::find_if(clip.formats.begin(),clip.formats.end(),[](const auto &format){return format.id==CF_DIB;});
if(dib==clip.formats.end() || dib->data.size()>128*1024*1024) return false;
atomicWrite(directory/(id+L".capture"),protect(dib->data));
stage="preview";
auto previews=previewDirectory(); cleanCache(previews);
auto preview=previews/(id+L".png"); savePreview(clip,preview);
cleanCache(previews);
cleanCache(directory);
stage="XML";
String documentClass(L"Windows.Data.Xml.Dom.XmlDocument"); ComPtr<IInspectable> documentInstance;
check(RoActivateInstance(documentClass.value,&documentInstance));
ComPtr<Xml::IXmlDocument> document; check(documentInstance.As(&document));
ComPtr<Xml::IXmlDocumentIO> documentIO; check(document.As(&documentIO));
auto uri=L"file:///"+preview.generic_wstring();
String content(L"<toast launch=\""+id+L"\"><visual><binding template=\"ToastGeneric\">"
L"<text>Screenshot copied</text><text>Click to edit and annotate</text><image id=\"1\" placement=\"appLogoOverride\" alt=\"Screenshot preview\" src=\""+escapeXml(uri)+L"\"/></binding></visual><audio silent=\"true\"/></toast>");
check(documentIO->LoadXml(content.value));
stage="toast factory";
ComPtr<Notifications::IToastNotificationFactory> toastFactory; String toastClass(L"Windows.UI.Notifications.ToastNotification");
check(RoGetActivationFactory(toastClass.value,IID_PPV_ARGS(&toastFactory)));
ComPtr<Notifications::IToastNotification> toast; check(toastFactory->CreateToastNotification(document.Get(),&toast));
ComPtr<ABI::Windows::Foundation::IPropertyValueStatics> values;
String propertyClass(L"Windows.Foundation.PropertyValue");
check(RoGetActivationFactory(propertyClass.value,IID_PPV_ARGS(&values)));
FILETIME now{}; GetSystemTimeAsFileTime(&now);
ULARGE_INTEGER timestamp{}; timestamp.LowPart=now.dwLowDateTime; timestamp.HighPart=now.dwHighDateTime;
ABI::Windows::Foundation::DateTime expires{(INT64)(timestamp.QuadPart+24ull*60*60*10000000)};
ComPtr<IInspectable> boxed; check(values->CreateDateTime(expires,&boxed));
ComPtr<ABI::Windows::Foundation::IReference<ABI::Windows::Foundation::DateTime>> expiration;
check(boxed.As(&expiration)); check(toast->put_ExpirationTime(expiration.Get()));
stage="show";
check(notifier->Show(toast.Get()));
return true;
} catch(const std::exception &e) { notificationError=std::string(stage)+": "+e.what(); log(wide(notificationError)); return false; } // Clipboard is already populated; fall back to the editor.
}
std::string screenshotNotificationError() { return notificationError; }
std::wstring updateToastXml(const std::wstring &version)
{
return L"<toast launch=\"check-update\"><visual><binding template=\"ToastGeneric\">"
L"<text>KSwitcher update available</text><text>Version " + escapeXml(version) +
L" is available. Click to review and install.</text></binding></visual>"
L"<actions><action content=\"Review update\" arguments=\"check-update\" activationType=\"foreground\"/>"
L"<action content=\"Later\" arguments=\"dismiss\" activationType=\"system\"/></actions></toast>";
}
bool notifyUpdateAvailable(const std::wstring &version)
{
try {
// Reuse the screenshot notification identity, Windows settings and COM activator.
registerScreenshotNotifications();
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
ComPtr<Notifications::IToastNotifier> notifier; String appId(AppId);
check(manager->CreateToastNotifierWithId(appId.value,&notifier));
Notifications::NotificationSetting setting{};
auto settingsResult=notifier->get_Setting(&setting);
if(settingsResult!=HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) check(settingsResult);
if(setting!=Notifications::NotificationSetting_Enabled)
throw std::runtime_error("Windows notifications for KSwitcher Screenshot are disabled");
String documentClass(L"Windows.Data.Xml.Dom.XmlDocument"); ComPtr<IInspectable> instance;
check(RoActivateInstance(documentClass.value,&instance));
ComPtr<Xml::IXmlDocument> document; check(instance.As(&document));
ComPtr<Xml::IXmlDocumentIO> io; check(document.As(&io));
String content(updateToastXml(version)); check(io->LoadXml(content.value));
ComPtr<Notifications::IToastNotificationFactory> factory; String toastClass(L"Windows.UI.Notifications.ToastNotification");
check(RoGetActivationFactory(toastClass.value,IID_PPV_ARGS(&factory)));
ComPtr<Notifications::IToastNotification> toast; check(factory->CreateToastNotification(document.Get(),&toast));
// Repeated submissions replace the existing update card in Notification Center.
ComPtr<Notifications::IToastNotification2> tagged; check(toast.As(&tagged));
String tag(L"update"),group(L"KSwitcher"); check(tagged->put_Tag(tag.value)); check(tagged->put_Group(group.value));
check(notifier->Show(toast.Get()));
return true;
} catch(const std::exception &e) { log(L"Update toast: "+wide(e.what())); return false; }
}
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include "platform.h"
namespace ks {
bool notifyScreenshot(const Clip &);
std::string screenshotNotificationError();
void registerScreenshotNotifications();
int runScreenshotToastActivator();
DWORD startScreenshotActivationListener();
bool validScreenshotToken(const std::wstring &);
Clip loadNotifiedScreenshot(const std::wstring &);
}
+93 -8
View File
@@ -44,8 +44,15 @@ bool validId(const std::wstring &id)
} // namespace
void validateSettings(const Settings &s)
{
if (s.tesseractPath.size() > 32700 || s.tesseractPath.find_first_of(L"\r\n\"") != s.tesseractPath.npos)
throw std::runtime_error("Invalid Tesseract executable path");
if (s.tesseractLanguages.empty() || s.tesseractLanguages.size() > 128 ||
s.tesseractLanguages.front() == L'+' || s.tesseractLanguages.back() == L'+' ||
s.tesseractLanguages.find(L"++") != s.tesseractLanguages.npos ||
s.tesseractLanguages.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+") != s.tesseractLanguages.npos)
throw std::runtime_error("Use Tesseract language codes separated by +, for example ukr+eng+rus");
std::vector<Hotkey> used;
std::vector<std::wstring> keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard};
std::vector<std::wstring> keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard, s.screenshot};
keys.insert(keys.end(), s.direct.begin(), s.direct.end());
for (auto &text : keys)
{
@@ -102,6 +109,10 @@ void validateSettings(const Settings &s)
}
}
Settings loadSettings(const std::filesystem::path &path, const std::vector<Layout> &layouts)
{
return decodeSettings(std::filesystem::exists(path) ? readFile(path, 4 * 1024 * 1024) : Bytes{}, layouts);
}
Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
{
Settings s;
for (size_t i = 0; i < layouts.size(); ++i)
@@ -111,9 +122,9 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
s.direct.push_back(i < 3 ? L"Ctrl+Alt+Shift+" + std::to_wstring(i + 1) : L"");
s.directIds.push_back(layouts[i].id);
}
if (!std::filesystem::exists(path))
if (bytes.empty())
return s;
auto j = parse(readFile(path, 4 * 1024 * 1024));
auto j = parse(bytes);
auto format = j.value("format", std::string());
if (format != "kswitcher-settings" && format != "typlo-settings")
throw std::runtime_error("Not a KSwitcher or Typlo settings file");
@@ -121,6 +132,9 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
s.switchLayout = j.value("switchSystemLayout", true);
s.writeLog = j.value("writeLog", true);
s.history = j.value("historyEnabled", true);
s.useTesseract = j.value("useTesseract", false);
s.tesseractPath = str(j, "tesseractPath");
s.tesseractLanguages = str(j, "tesseractLanguages", s.tesseractLanguages);
s.autostart = j.value("startWithSystem", false);
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
s.awake = j.value("preventScreenLock", false);
@@ -146,6 +160,8 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
s.translit = str(j, "translitHotkey", s.translit);
s.undo = str(j, "undoHotkey", s.undo);
s.clipboard = str(j, "clipboardHotkey", s.clipboard);
s.screenshot = str(j, "screenshotHotkey", s.screenshot);
s.screenshotAutoOpenEditor = j.value("screenshotAutoOpenEditor", false);
if (j.contains("layouts"))
{
s.cycle.clear();
@@ -177,6 +193,10 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
return s;
}
void saveSettings(const std::filesystem::path &path, const Settings &s)
{
atomicWrite(path, encodeSettings(s));
}
Bytes encodeSettings(const Settings &s)
{
validateSettings(s);
Json j = {{"format", "kswitcher-settings"},
@@ -186,6 +206,9 @@ void saveSettings(const std::filesystem::path &path, const Settings &s)
{"writeLog", s.writeLog},
{"autostartAsAdministrator", s.elevatedAutostart},
{"historyEnabled", s.history},
{"useTesseract", s.useTesseract},
{"tesseractPath", utf8(s.tesseractPath)},
{"tesseractLanguages", utf8(s.tesseractLanguages)},
{"startWithSystem", s.autostart},
{"preventScreenLock", s.awake},
{"maxItems", s.maxItems},
@@ -195,7 +218,9 @@ void saveSettings(const std::filesystem::path &path, const Settings &s)
{"caseHotkey", utf8(s.casing)},
{"translitHotkey", utf8(s.translit)},
{"undoHotkey", utf8(s.undo)},
{"clipboardHotkey", utf8(s.clipboard)}};
{"clipboardHotkey", utf8(s.clipboard)},
{"screenshotHotkey", utf8(s.screenshot)},
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
j["layouts"] = Json::array();
for (auto &id : s.cycle)
j["layouts"].push_back(utf8(id));
@@ -221,7 +246,7 @@ void saveSettings(const std::filesystem::path &path, const Settings &s)
j["excludedApps"] = Json::array();
for (auto &name : s.excluded)
j["excludedApps"].push_back(utf8(name));
atomicWrite(path, jsonBytes(j));
return jsonBytes(j);
}
Bytes encodeClip(const Clip &clip)
{
@@ -285,7 +310,8 @@ History::History(const std::filesystem::path &folder) : folder_(folder)
e.value("at", uint64_t{}),
e.value("pinned", false),
e.value("image", false),
e.value("files", false)};
e.value("files", false), e.value("ocrState", e.value("image", false) ? 1 : 0),
str(e, "ocrProfile", L"windows")};
if (validId(item.id) && std::filesystem::exists(folder_ / (item.id + L".dat")))
entries_.push_back(std::move(item));
}
@@ -301,7 +327,7 @@ void History::saveIndex()
{"at", e.at},
{"pinned", e.pinned},
{"image", e.image},
{"files", e.files}});
{"files", e.files}, {"ocrState", e.ocrState}, {"ocrProfile", utf8(e.ocrProfile)}});
atomicWrite(folder_ / L"index.dat", protect(jsonBytes(j)));
}
std::vector<Entry> History::entries() const
@@ -330,6 +356,7 @@ void History::prune(const Settings &s)
total -= entries_[i].bytes;
std::error_code ec;
std::filesystem::remove(folder_ / (entries_[i].id + L".dat"), ec);
std::filesystem::remove(folder_ / (entries_[i].id + L".ocr"), ec);
entries_.erase(entries_.begin() + i);
}
}
@@ -417,7 +444,8 @@ void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
GetSystemTimeAsFileTime(&now);
entries_.insert(entries_.begin(),
Entry{id, text, app, encrypted.size(),
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files});
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files,
image ? 1 : 0, ocrProfile(s)});
prune(s);
saveIndex();
}
@@ -433,6 +461,7 @@ void History::remove(const std::wstring &id)
saveIndex();
std::error_code ec;
std::filesystem::remove(folder_ / (id + L".dat"), ec);
std::filesystem::remove(folder_ / (id + L".ocr"), ec);
}
void History::pin(const std::wstring &id)
{
@@ -452,8 +481,49 @@ void History::clear()
{
std::error_code ec;
std::filesystem::remove(folder_ / (e.id + L".dat"), ec);
std::filesystem::remove(folder_ / (e.id + L".ocr"), ec);
}
}
bool History::saveOcr(const std::wstring &id, const std::optional<std::wstring> &text, const Settings &settings)
{
std::lock_guard lock(mutex_);
auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; });
// A late OCR completion must never recreate a deleted/pruned image.
auto profile = ocrProfile(settings);
if (it == entries_.end() || !it->image || (it->ocrState == 2 && it->ocrProfile == profile)) return false;
if (text)
{
auto utf = utf8(*text);
if (utf.size() > 1024 * 1024) throw std::runtime_error("OCR text exceeds storage limit");
// A version byte also makes empty recognition results encryptable by DPAPI.
Bytes payload{1};
payload.insert(payload.end(), utf.begin(), utf.end());
auto encrypted = protect(payload);
auto path = folder_ / (id + L".ocr");
std::error_code ec;
auto previousBytes = std::filesystem::file_size(path, ec);
if (ec) previousBytes = 0;
atomicWrite(path, encrypted);
it->bytes = it->bytes - std::min(it->bytes, uint64_t(previousBytes)) + encrypted.size();
it->ocrState = 2;
}
else it->ocrState = 3;
it->ocrProfile = profile;
prune(settings);
saveIndex();
return true;
}
std::optional<std::wstring> History::ocrText(const std::wstring &id, const std::wstring &profile) const
{
{
std::lock_guard lock(mutex_);
auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; });
if (it == entries_.end() || it->ocrState != 2 || (!profile.empty() && it->ocrProfile != profile)) return std::nullopt;
}
auto bytes = unprotect(readFile(folder_ / (id + L".ocr"), 2 * 1024 * 1024));
if (bytes.empty() || bytes.front() != 1) throw std::runtime_error("Invalid OCR text format");
return wide(std::string(bytes.begin() + 1, bytes.end()));
}
std::vector<Entry> History::search(const std::wstring &query) const
{
auto items = entries();
@@ -465,6 +535,21 @@ std::vector<Entry> History::search(const std::wstring &query) const
{
if (lower(e.preview + L" " + e.app).find(q) != q.npos)
return false;
if (e.ocrState == 2)
{
try
{
auto text = ocrText(e.id);
if (text && lower(*text).find(q) != q.npos) return false;
}
catch (...) {} // Missing/corrupt OCR must not hide the original clip.
}
// Short previews contain the complete text (or a placeholder for
// textless clips). Do not decrypt image payloads just to rule them out.
// Newlines/tabs are flattened in previews, so those queries still
// need the original text. Exactly 240 characters may be truncated.
if (e.preview.size() < 240 && q.find_first_of(L"\r\n\t") == q.npos)
return true;
try
{
return lower(load(e.id).text()).find(q) == q.npos;

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