Compare commits
37 Commits
v0.3.0
...
ca553a5e7d
| Author | SHA1 | Date | |
|---|---|---|---|
| ca553a5e7d | |||
| 55993c0f90 | |||
| 738c81dce2 | |||
| 22edece7cf | |||
| 908985d775 | |||
| 20f3b72287 | |||
| 9b231146cb | |||
| c0ab02df16 | |||
| 64a04b4382 | |||
| 4c5275631c | |||
| 34e07e7107 | |||
| 70ea9b10bc | |||
| 6603d28f68 | |||
| 0d3b2acae9 | |||
| 4eb9f6c671 | |||
| a830f55575 | |||
| 07e31228d7 | |||
| 39d3876fd2 | |||
| 4cdcc92eb7 | |||
| 77f27ea087 | |||
| c326db7dc5 | |||
| 8b5f632ad7 | |||
| 5c786e12a9 | |||
| 107d2f8852 | |||
| 95c7199027 | |||
| 8fedb54dfe | |||
| bbf20e8270 | |||
| b8bb14a985 | |||
| d28e28c03c | |||
| 6b180798f5 | |||
| 21a09e33be | |||
| 3dbd7317f1 | |||
| 510d0ed211 | |||
| dd9629f8fd | |||
| ccaa8dad3f | |||
| d1eabfca88 | |||
| dc615d0223 |
@@ -0,0 +1,35 @@
|
||||
# Project instructions
|
||||
|
||||
## Mandatory Wiki updates for every release
|
||||
|
||||
A release is not complete until the published project Wiki reflects that release.
|
||||
Apply this rule to every release, including patch releases and bug-fix-only releases.
|
||||
|
||||
Wiki: https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki
|
||||
|
||||
1. Confirm the release version from `CMakeLists.txt`, the release tag and
|
||||
`Release/README.md`. Use the complete version (for example, `v0.6.17`) and
|
||||
preserve the release channel rules in `README.md`.
|
||||
2. Update the latest-version information on the Wiki home pages and any other
|
||||
pages identifying the current release. Update release dates, download links
|
||||
and upgrade instructions where needed. Keep historical version references
|
||||
and compatibility bridge instructions when they are still accurate.
|
||||
3. Review the changes since the last release documented in the Wiki, not just
|
||||
the immediately preceding release. Bring the relevant feature pages up to
|
||||
date with the shipped behavior: new, changed and removed functionality,
|
||||
settings, shortcuts, defaults, limitations and troubleshooting. Update
|
||||
screenshots or examples when they no longer match the application.
|
||||
For bug-fix-only releases, check whether documented behavior or workarounds
|
||||
need correction; do not invent feature changes.
|
||||
4. Keep English and Ukrainian pages synchronized, including `Home` and
|
||||
`Home-uk`, and preserve working translation and navigation links.
|
||||
5. Publish the Wiki changes as part of the release workflow. Read back the
|
||||
published pages and verify the current version, feature descriptions,
|
||||
download links and translation links. A local Wiki edit alone is not enough.
|
||||
6. In the release completion report, link the updated Wiki pages and summarize
|
||||
the documentation changes. If Wiki publication or verification is blocked,
|
||||
explicitly report the outstanding step and do not claim the release workflow
|
||||
is complete.
|
||||
|
||||
Updating only the release notes or changing only the Wiki version number does
|
||||
not satisfy this rule; the current functionality must also be reviewed.
|
||||
@@ -1,15 +1,15 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.3.0 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.29 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp)
|
||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp src/sync.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/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(kscore PUBLIC winhttp 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)
|
||||
@@ -17,32 +17,63 @@ set_target_properties(KSwitcherConfig PROPERTIES OUTPUT_NAME "KSwitcher.Config")
|
||||
target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
|
||||
target_link_libraries(KSwitcherConfig PRIVATE kscore)
|
||||
add_executable(KSwitcherTests tests/tests.cpp)
|
||||
add_executable(KSwitcherSyncTests tests/sync.cpp)
|
||||
target_include_directories(KSwitcherSyncTests PRIVATE vendor)
|
||||
target_link_libraries(KSwitcherSyncTests PRIVATE kscore)
|
||||
if(MSVC)
|
||||
set_property(TARGET KSwitcherSyncTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
else()
|
||||
target_link_options(KSwitcherSyncTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
add_executable(KSwitcherScrollTests tests/scroll.cpp)
|
||||
target_link_libraries(KSwitcherScrollTests PRIVATE kscore)
|
||||
target_link_libraries(KSwitcherTests PRIVATE kscore)
|
||||
add_executable(KSwitcherIntegration tests/integration.cpp src/app.cpp src/configuration.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/configuration.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 KSwitcherConfig KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherScrollTests 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(KSwitcherScrollTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
enable_testing()
|
||||
add_test(NAME sync-regressions COMMAND KSwitcherSyncTests)
|
||||
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
||||
add_test(NAME scroll-regressions COMMAND KSwitcherScrollTests)
|
||||
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
||||
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
|
||||
add_test(NAME screenshot-save-regressions COMMAND KSwitcherScreenshotUITests --save-only)
|
||||
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 KSwitcherConfig 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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](https://send.monobank.ua/jar/88h2TddKfS)
|
||||
|
||||
[Donate via Monobank](https://send.monobank.ua/jar/88h2TddKfS) · Thank you for supporting KSwitcher.
|
||||
|
||||
## Run
|
||||
|
||||
@@ -12,16 +16,34 @@ 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.
|
||||
Menus support access keys: press the underlined letter while the menu is open (for example,
|
||||
S for Settings or X for Exit in the tray menu). In forms, press Alt+the underlined letter
|
||||
to activate a button, toggle a checkbox or focus a labeled field. Press Alt to reveal
|
||||
the underlines when Windows hides them. Shortcut-capture fields still record Alt combinations;
|
||||
press Tab to leave a shortcut field before using form access keys.
|
||||
Settings, the snippet editor and log viewer run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
||||
Repeated launches activate the existing configurator; closing its last window exits that process.
|
||||
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
|
||||
load clipboard history, dictionaries or global input hooks. Clipboard history and the log viewer remain
|
||||
in the resident app. Keep both executables together when copying or updating the application.
|
||||
load clipboard history, dictionaries or global input hooks. Clipboard history remains
|
||||
in the resident app. Screen capture and all screenshot editors run on demand in
|
||||
**KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same
|
||||
process. Closing the last editor (or cancelling the only selection) exits that process. It does not
|
||||
load resident settings, dictionaries, clipboard history or keyboard hooks. Editor windows have
|
||||
stable numbered titles within that process. Keep all three executables together when copying or
|
||||
updating the application. Close screenshot editors before installing an update.
|
||||
If settings change in the tray while the configurator is open, a conflicting Save is rejected; reopen
|
||||
the configurator to load the latest settings.
|
||||
The first upgrade from v0.2.3 or earlier to this two-executable build requires manual extraction:
|
||||
the older updater rejects additional executable files. Exit KSwitcher, replace both executables,
|
||||
and retain `data/` and customized dictionaries. Subsequent updates support both executables.
|
||||
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;
|
||||
@@ -53,12 +75,167 @@ 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.
|
||||
**Save… / Ctrl+S** saves the full-resolution image and annotations as PNG. The first save asks
|
||||
for a file; later saves update that file. **Ctrl+Shift+S (Save As…)** always asks for a new
|
||||
destination. Saving keeps the editor open and leaves the clipboard unchanged. Further edits
|
||||
must be saved or copied again before closing without a warning.
|
||||
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, triangles, trapezoids, 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; shape and 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.
|
||||
**Canvas zoom:** use **Ctrl++ / Ctrl+-** (including numpad keys) or **Ctrl+mouse wheel**.
|
||||
Each step changes the view by 25%; the wheel zooms around the cursor, keyboard shortcuts around
|
||||
the viewport center. **Ctrl+0** restores the initial fit. Drag with the **middle mouse button**
|
||||
to pan an enlarged image. Zoom ranges from 0.1× to 16× the initial fit; the status bar shows the
|
||||
actual image scale. Zoom and pan do not change annotations, export resolution or undo history.
|
||||
The wheel without Ctrl still adjusts the object under the cursor.
|
||||
|
||||
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
|
||||
after mouse release, nearly straight strokes become editable straight lines at their drawn angle,
|
||||
preserving endpoints, color, thickness, line style and arrowheads. Short strokes, broad curves and
|
||||
retraced strokes are not straightened. Other strokes receive light smoothing to reduce jitter.
|
||||
Confidently recognized closed outlines become
|
||||
rectangles, circles, ellipses, triangles or trapezoids (including tilted outlines), stored as shape objects.
|
||||
Drag either bounding-box corner handle to resize the entire recognized outline; circles keep equal
|
||||
width and height. Shapes can also be moved, recolored and cloned.
|
||||
Every shape has a round rotation handle above its outline. Drag it to rotate around the center;
|
||||
hold **Shift** for 15-degree steps. The handle works for toolbar shapes and recognized shapes,
|
||||
including triangles. Resize handles and attached connectors follow the rotation; Ctrl+Z/Ctrl+Y
|
||||
undo and redo it. The square handle still controls outline thickness.
|
||||
**Triangle (G)** is also available directly on the toolbar: drag to create a triangle.
|
||||
Both drawn and recognized triangles have three independent vertex handles instead of bounding-box
|
||||
resize handles. Drag any vertex to reshape the triangle while keeping the other two fixed, even
|
||||
after rotation. Vertex edits support undo/redo, and the rotation and thickness handles remain available.
|
||||
**Trapezoid (B)** on the toolbar creates a trapezoid with parallel bases. Pencil recognition also
|
||||
fits nearly parallel, unequal bases into a clean trapezoid. Trapezoids support bounding-box resizing,
|
||||
rotation, styling and undo/redo; Ctrl+Z after recognition restores the original pencil stroke.
|
||||
Oval fitting tolerates uneven contours and small closing gaps or overlaps; broad open arcs and
|
||||
repeated loops remain freehand strokes.
|
||||
Press **Ctrl+Z** once to restore the original stroke, again to remove it; **Ctrl+Y** reapplies the improvement.
|
||||
Small strokes, strokes linked to other objects, and any stroke drawn partly with Shift are left unchanged.
|
||||
Ambiguous shapes are only smoothed. To edit the resulting curve,
|
||||
move it, change its color or thickness, clone it, or drag its endpoints. Hold Ctrl to start a new
|
||||
stroke over an existing object. Hold **Shift** while drawing to constrain the current segment
|
||||
to a horizontal, vertical or 45-degree diagonal line; release Shift to resume freehand drawing in the same stroke.
|
||||
While holding Shift, moving more than 50 image pixels away from the current line selects a new
|
||||
direction at the last drawn endpoint, preserving earlier segments. Shift can be pressed and
|
||||
released repeatedly within one stroke. 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 changes that have not been saved or copied.
|
||||
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy keeps the editor open and publishes the image
|
||||
at its original pixel size, ready to paste. The preview fits the window without changing export size.
|
||||
The shortcut is configurable in Settings under **Screen capture**.
|
||||
Each capture opens an independent editor available through Alt+Tab; use the shortcut again to take another screenshot while
|
||||
keeping earlier editors and their annotations open.
|
||||
Captures use ordinary clipboard history settings and limits. Protected video and secure desktops cannot be captured.
|
||||
|
||||
### Updates
|
||||
|
||||
Select **Update** in the tray menu to check the stable Releases of
|
||||
[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases).
|
||||
A startup check also reports an available version, but never installs it automatically.
|
||||
Confirm the offered version to download and install it. The updater uses the existing
|
||||
A background check runs shortly after startup and every 6 hours while KSwitcher is running.
|
||||
It reports each available version once per session and never installs automatically.
|
||||
Update availability uses the same Windows toast notification channel as screenshot notifications.
|
||||
Click the notification or **Review update** to check the latest release and confirm installation;
|
||||
**Later** dismisses it. Windows Do Not Disturb may send it directly to Notification Center.
|
||||
Failed notification submissions are logged and retried after five minutes instead of being marked
|
||||
as delivered. Check results and successful submissions are recorded in the log when logging is enabled.
|
||||
Network failures stay in diagnostics; the next scheduled check retries. You can also check manually at any time.
|
||||
Confirm the offered version to download and install it. A progress window shows
|
||||
download percentage and received/total size, package verification,
|
||||
installation and restart. After the new process confirms startup, that window shows the installed
|
||||
version and a Done button; it does not send a separate completion notification. If installation
|
||||
fails after KSwitcher closes, the same window shows recovery and the error. Background checks
|
||||
remain silent unless a new version is available. This progress UI is supplied by the updater in
|
||||
the currently installed build, so it becomes available for subsequent updates after installing it.
|
||||
|
||||
The updater uses the existing
|
||||
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
|
||||
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
|
||||
before the application exits. If replacement or startup fails, it restores the previous files;
|
||||
@@ -72,22 +249,37 @@ 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
|
||||
|
||||
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
|
||||
Text conversion cycles skip layouts that leave the text unchanged and use the next enabled layout
|
||||
that changes it. If none changes the text, the operation keeps the text and advances one layout.
|
||||
Partial copied suffixes can be reselected with paced keys; replacement still requires verification
|
||||
of the complete expected text. Browser/editor compatibility depends on its handling of simulated input.
|
||||
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary).
|
||||
- Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix.
|
||||
- Teams recent-word conversion uses a single word-selection command and validates the copied range.
|
||||
If the editor's word boundary differs from the recorded typing, it falls back to paced character
|
||||
selection: Teams can coalesce a burst of Shift+Left events into just one character.
|
||||
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
||||
A silently dropped copy is retried up to three total attempts while the clipboard and input
|
||||
context remain unchanged. Temporary transaction contents are excluded from Windows clipboard
|
||||
history and cloud synchronization. Paste is not blindly retried.
|
||||
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
||||
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
||||
`{{cursor}}` inserts the literal marker. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
|
||||
`{enter}` presses Enter after insertion and cursor placement (once per marker), before buffered typing resumes.
|
||||
For example, `rider .{enter}` inserts `rider .` and presses Enter.
|
||||
Automatic correction is suppressed during snippet expansion and for typing buffered by that expansion; the global setting is preserved.
|
||||
If the trigger cannot be verified after the copy attempts, it is preserved, queued typing resumes,
|
||||
and no Enter action is sent.
|
||||
`{{cursor}}` and `{{enter}}` insert literal markers. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
|
||||
- Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste;
|
||||
full-text search, pinning, deletion, full text viewing and image previews.
|
||||
- Image thumbnails and text/file icons appear directly in the history list. Only visible rows
|
||||
@@ -99,19 +291,46 @@ 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.
|
||||
|
||||
**Clear history… / Alt+H** in the history window clears all saved entries after confirmation,
|
||||
including pinned entries and entries hidden by search. No is the default choice. The current
|
||||
Windows clipboard and future recording are unchanged. Settings also retains its Clear history action.
|
||||
For an image, **Edit image**, **Ctrl+O**, or **Edit image in screenshot editor**
|
||||
in the context menu opens the original image in the built-in screenshot editor.
|
||||
Annotate it there, then copy the result. Opening the editor leaves the
|
||||
clipboard and original history entry unchanged. Text items still open as text;
|
||||
Enter and double-click still paste the selected item.
|
||||
|
||||
Pressing the history shortcut a second time pastes the second-most-recent item.
|
||||
|
||||
## Data and migration
|
||||
@@ -121,25 +340,45 @@ the executable's directory is not writable. Each clipboard payload is an encrypt
|
||||
only bounded previews/metadata stay in memory. Image data is loaded on demand and released when
|
||||
the history popup is hidden. Settings **Write log** (`writeLog`, enabled by default) controls all
|
||||
log writes. Disabling it also clears diagnostic context from memory; existing log files remain.
|
||||
Successful operations stay in a memory buffer of at most 40 diagnostic events for 30 seconds.
|
||||
Only failures flush this context to `kswitcher.log`; flushed
|
||||
context is discarded. Ordinary application errors are also logged when logging is enabled.
|
||||
Diagnostic stages stay in a memory buffer of at most 128 events for 30 seconds.
|
||||
Selection diagnostics include the expected text, each copied candidate before and after suffix
|
||||
recovery, paced Shift+Left progress or batch delivery counts, and clipboard sequence changes,
|
||||
retry counts and elapsed times. Focus, caret geometry and keyboard layout are recorded when
|
||||
Windows exposes them; selection offsets are available for native EDIT controls and explicitly
|
||||
marked unavailable for other editors. Input interruptions identify keyboard, mouse, settings
|
||||
or layout-refresh events during an operation. Failed selection verification includes the expected
|
||||
selection and actual copied text in the operation details, without implying that a paste occurred.
|
||||
Only problem operations write a structured summary to `kswitcher.log`, with their preceding
|
||||
context. Successful operations stay in that bounded memory buffer and are included only when
|
||||
a subsequent problem flushes it; they do not create separate rows. Flushed context is discarded. Ordinary application
|
||||
errors are also logged when logging is enabled. This works in normal Release builds.
|
||||
Incident context contains tracked physical input, the expected word, copied text, conversion result,
|
||||
and layout IDs, correlated by request ID even when verification fails. Text fields use quoted,
|
||||
escaped UTF-16 (`\uXXXX` for non-ASCII and control characters) for exact reproduction, with
|
||||
original length and a truncation flag; each field is limited to 512 UTF-16 code units.
|
||||
original length and a truncation flag; context fields are limited to 512 UTF-16 code units.
|
||||
Structured summaries retain up to 4096 UTF-16 code units per text field and mark truncation.
|
||||
Manual conversion attempts in `kswitcher.log` have request IDs and explicit outcomes, including
|
||||
busy, empty copy, verification mismatch, unchanged conversion, and cancelled paste. Requests
|
||||
record the process, window, input version, and repeated attempts within ten seconds without new
|
||||
input. Successful repeated commands do not flush the log; intentional layout cycling is normal.
|
||||
An unchanged result containing no letters, or a direct command
|
||||
to the detected current layout, is treated as expected. `paste-sent-unverified` means Windows
|
||||
accepted the paste shortcut; it does not prove that the editor applied the replacement.
|
||||
input. An unchanged manual conversion is recorded as a problem even when the detected source
|
||||
and target layouts coincide. After a changed result is pasted, read-only Windows Accessibility
|
||||
checks the focused editor without moving the caret, changing selection, or copying again.
|
||||
The log distinguishes **Paste verified**, **Original text remained**, **Readback differs**, and
|
||||
**Paste not verified**. Unsupported/protected controls, changed focus, and readback limits are
|
||||
explicit verification reasons, never proof of success. Readback is bounded to 4096 characters;
|
||||
provider-specific character units or delayed editor updates may prevent verification.
|
||||
With logging disabled, this additional accessibility check is skipped.
|
||||
The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates.
|
||||
Open **View log…** from Settings or the tray menu to read logs inside KSwitcher.
|
||||
The viewer runs in the configurator process so log loading, painting and pointer interaction do not
|
||||
block the resident keyboard/mouse hook thread. Clear requests are still serialized by the resident.
|
||||
Choose the current or previous log and press **Refresh** to reload. The ListView shows Time,
|
||||
Attempt, Event, and Details columns. Select rows and use **Copy selected** or Ctrl+C to copy
|
||||
tab-separated entries; Ctrl+A selects all rows. Viewing saved logs also works when logging is disabled.
|
||||
Program, Command, Source layout, Target layout, and Result. Click column headings to sort;
|
||||
filter by program, command, or result, or enable **Only problems**. **Reset filters** restores all entries.
|
||||
Select a row to inspect original, expected and read-back text, verification status and stages
|
||||
in the panel below. **Copy selected** or Ctrl+C copies the selected rows with their details;
|
||||
Ctrl+A selects all rows. Older unstructured log entries remain readable. Viewing saved logs
|
||||
also works when logging is disabled.
|
||||
**Clear log** clears only the selected current or previous file and refreshes the table.
|
||||
Clearing the current log also discards buffered diagnostic context; new problems can still be logged.
|
||||
|
||||
@@ -160,6 +399,119 @@ This is a working native implementation, not a claim of complete UI/release pari
|
||||
The Windows focus/injection rules still apply: an unelevated process cannot edit an elevated app.
|
||||
Conversion is cancelled rather than pasting into a different window or replacing unverified recent text.
|
||||
|
||||
## OpenBao synchronization (experimental, opt-in)
|
||||
|
||||
The **Sync...** button is available in Settings for everyone. Synchronization remains disabled
|
||||
until you configure your own OpenBao and enable it. Connection configuration is saved separately
|
||||
in `sync.json`. The portable data directory is normally `data/` beside the executable; installations
|
||||
without a writable application directory use the existing local-app-data fallback.
|
||||
No server address or account is included in the application.
|
||||
|
||||
The first provider is **OpenBao KV v2**. The dialog accepts an HTTPS server origin, username,
|
||||
password, KV mount (default `kswitcher`), profile path (default `personal/settings`) and userpass
|
||||
authentication mount (default `userpass`). Use the same profile on each of your PCs. This is
|
||||
administrator-provisioned private access, not public account registration. Non-root namespaces,
|
||||
reverse-proxy URL prefixes, MFA login flows and other providers are not implemented yet.
|
||||
|
||||
Enable synchronization and choose **Save / Sign in / Sync**. The dialog saves connection changes
|
||||
immediately; the main Settings window's Save/Cancel does not control this separate configuration.
|
||||
When synchronization is disabled, connection fields and sync actions are inactive. Check **Enable
|
||||
synchronization** to make them available. To disable an existing connection, uncheck it and click
|
||||
**Save**; this preserves the saved connection and password without starting a sync request.
|
||||
**Disconnect / forget password** remains available while synchronization is disabled.
|
||||
A blank password keeps the existing Windows credential for that server, login mount and username.
|
||||
The password is saved in Windows Credential Manager, not in settings exports, logs or the remote
|
||||
profile. HTTPS certificate validation is mandatory and redirects are refused. Login tokens are
|
||||
kept in memory for one sync attempt, with best-effort self-revocation afterwards. Configure bounded
|
||||
token lifetimes on the server as well (for example a 5-minute default and 10-minute maximum).
|
||||
**Disconnect / forget password** disables synchronization and removes the current connection's
|
||||
saved credential. A request already sent to the server may still complete; its downloaded settings
|
||||
will not be applied after the connection changes. Old credentials for other connection identities
|
||||
can be removed in Windows Credential Manager under `KSwitcher.Sync/`.
|
||||
|
||||
On the first connection, **Save / Sign in / Sync** merges the existing profiles without
|
||||
requiring a whole-profile replacement. Independent entries (such as snippets, exclusions and
|
||||
replacement pairs) are combined. Different values for the same setting remain on their respective
|
||||
sides and appear in **Resolve individual conflicts...**; no value is guessed without a common history.
|
||||
Nonconflicting changes synchronize immediately, and unresolved conflicts survive subsequent syncs.
|
||||
|
||||
- **Upload local** creates the remote profile or replaces it with this PC's saved shared settings.
|
||||
- **Download remote** replaces this PC's shared settings with the server profile.
|
||||
- If the server has no profile yet, choose **Upload local** to create it.
|
||||
- Unsaved edits in Settings are not synchronized. Close and reopen Settings after a download;
|
||||
the existing stale-editor check prevents an old Settings window overwriting downloaded changes.
|
||||
|
||||
Once connected, the resident checks at startup, every **10 minutes**, and about two seconds after saving settings, on a separate
|
||||
network thread. Errors back off for 15 minutes; the Sync button allows a manual retry. Local use
|
||||
continues offline. Independent changes to different settings fields are merged against the last
|
||||
successful baseline. **Resolve individual conflicts...** shows the name and local/server values
|
||||
for each conflict. Choose either value for selected settings and apply your choices; unselected
|
||||
conflicts stay pending. Other, nonconflicting changes continue to synchronize. Choices are checked
|
||||
against freshly read values before writing, so a stale dialog cannot overwrite a newer edit.
|
||||
Conflicting shortcuts are separate settings, including bindings for individual keyboard layouts.
|
||||
Exclusions and personal dictionary words merge by membership. Ordered layout/dictionary lists
|
||||
remain single settings because their ordering affects behavior.
|
||||
|
||||
Snippets have persistent IDs that survive renaming. Different snippets merge independently;
|
||||
trigger and text edits within a snippet can merge too. Deleting a snippet retains a deletion
|
||||
marker, so an unchanged offline copy cannot bring it back. Deletion versus a concurrent edit
|
||||
is an explicit conflict, offering the deleted or edited version. Duplicate triggers and shortcut
|
||||
collisions created by independent edits are also presented for resolution. Deletion markers are
|
||||
retained without automatic expiry; recreating a snippet in the editor gives it a new ID.
|
||||
|
||||
Existing profiles migrate automatically: legacy snippet IDs derive deterministically from the
|
||||
original trigger, then are stored permanently. Sync writes use envelope version 2; older clients
|
||||
refuse this version instead of dropping IDs/deletions. Update all participating PCs before using
|
||||
the new merge behavior. Existing version-1 profiles and local baselines remain readable.
|
||||
|
||||
Download/Upload are still available for deliberate whole-profile recovery/initialization, not
|
||||
required for ordinary conflicts. Every server write uses the version just read as a CAS
|
||||
condition, including explicit Upload. A racing write is rejected rather than silently overwritten.
|
||||
Deleted server versions are not silently recreated.
|
||||
|
||||
Hotkeys, snippets, application exclusions, layout order, conversion, screenshot and autocorrection
|
||||
preferences are shared. Startup/elevation, keep-awake, logging, history enablement/limits,
|
||||
Tesseract executable/engine selection, connection details and credentials stay local. Clipboard
|
||||
history, screenshots and dictionary files are never uploaded. Full synchronization identifies
|
||||
missing keyboard layouts by language (when Windows can identify it) and exact ID.
|
||||
Install the same layout variant, or select **Keep this PC's layouts and layout shortcuts (sync
|
||||
other settings)** and click **Save / Sign in / Sync**. This device-local option keeps the entire
|
||||
local layout cycle and its direct shortcuts unchanged while synchronizing the other shared settings.
|
||||
The server's layout cycle and direct shortcuts are preserved on automatic sync and explicit Upload;
|
||||
Download also keeps the local layout configuration. Later syncs cannot erase missing server layouts
|
||||
just because this PC does not have them installed. Uncheck the option to resume full layout sync.
|
||||
Settings are JSON inside OpenBao; this is not end-to-end encryption from the vault administrator.
|
||||
|
||||
The last common profile (`sync-base.bin`) and the settings preceding the most recent downloaded
|
||||
change (`settings-before-sync.bin`) are protected with Windows DPAPI and belong to this Windows
|
||||
account/machine. Reinstallation recovery uses the remote profile and a fresh login; copying these
|
||||
local DPAPI files to a new machine does not recover them. For an older server snapshot, use OpenBao
|
||||
KV version history to restore that snapshot as the latest version, then choose Download remote.
|
||||
The app does not yet provide a server-history browser. Keep ordinary server backups too.
|
||||
|
||||
OpenBao administrator setup:
|
||||
|
||||
1. Enable a KV **version 2** engine at `kswitcher`; enable **Require Check-and-Set** and set
|
||||
maximum versions to a suitable value, such as 30.
|
||||
2. Create a dedicated userpass account, with a policy restricted to the chosen profile. Do not
|
||||
use the administrator account. For the defaults, the application needs only:
|
||||
|
||||
```hcl
|
||||
path "kswitcher/data/personal/settings" {
|
||||
capabilities = ["create", "read", "update"]
|
||||
}
|
||||
path "auth/token/revoke-self" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
```
|
||||
|
||||
3. Set bounded token lifetimes on that account/auth mount and enter its credentials in KSwitcher.
|
||||
Keep the server's address and TLS certificate reachable/trusted on each participating PC.
|
||||
|
||||
`SyncProvider` separates versioned read/conditional-write operations from merging, local state
|
||||
and UI integration, so another service can be added without replacing the synchronization logic.
|
||||
Protocol and conflict regressions run with `build.ps1 -Test` or CMake's `sync-regressions` test.
|
||||
|
||||
## Build
|
||||
|
||||
The easiest reproducible local route uses the pinned portable w64devkit toolchain:
|
||||
@@ -179,13 +531,40 @@ Alternatively open this directory as a CMake project in Visual Studio with the C
|
||||
and Windows SDK installed, or use CMake with a Windows GCC toolchain. CMake/MSVC is provided but the
|
||||
verified local build uses GCC/w64devkit.
|
||||
|
||||
## Automatic layout correction (experimental)
|
||||
## Automatic correction (experimental)
|
||||
|
||||
Enable **Automatically correct layout (experimental)** in Settings, then use **Auto-correction
|
||||
Enable **Automatic correction (experimental)** in Settings, then use **Auto-correction
|
||||
options…** to select dictionary languages and edit excluded applications or words to never correct.
|
||||
The feature is off by default. English and Ukrainian are selected initially; Russian is optional.
|
||||
Target layouts must also be enabled in the layout cycle.
|
||||
|
||||
### Custom replacements and spelling typos
|
||||
|
||||
Two optional modes in **Auto-correction options…** extend layout correction. Both are off by
|
||||
default and require the main **Automatic correction (experimental)** checkbox to be enabled.
|
||||
|
||||
- **Use custom replacements**: enter one `typo=correction` pair per line, for example
|
||||
`првиіт=привіт` or `helo=hello`. Entries are normalized to lowercase; duplicate source words
|
||||
are rejected. Each side must contain 1–32 letters of the same supported language, without
|
||||
spaces or punctuation. Explicit pairs take priority over dictionary recognition, including
|
||||
already valid words, and do not require dictionary membership or satisfy the frequency limit.
|
||||
- **Correct spelling typos using dictionaries**: checks unknown words in the current language
|
||||
after no layout correction is found. It accepts exactly one dictionary candidate reachable
|
||||
by inserting, deleting or replacing one letter, or swapping two adjacent letters. Both input
|
||||
and target must have at least four letters. Multiple candidates leave the input unchanged,
|
||||
even if a frequency limit would exclude one of them. Personal dictionary words participate.
|
||||
This is word-level spelling correction, not grammar or sentence-context correction.
|
||||
|
||||
Both modes run after Space, preserve surrounding punctuation and an initial capital, and keep
|
||||
the current layout. The selected dictionary languages, enabled layout cycle, minimum input
|
||||
length, excluded applications, never-correct list, protected fields and Undo behavior apply.
|
||||
ALL CAPS, mixed-case identifiers, numbers, URLs and email addresses are skipped. A replacement
|
||||
is applied once, without chaining. Click **OK**, then **Save** in Settings; pairs and switches
|
||||
are included in settings export/import and synchronization. Undo restores the original word
|
||||
and suppresses its correction until restart.
|
||||
|
||||
### Layout correction and shared options
|
||||
|
||||
**Characters before a word** and **Characters after a word** configure punctuation to preserve around
|
||||
the corrected word. Enter characters directly, without spaces or separators; a comma or semicolon
|
||||
is itself a character. Defaults include parentheses, square/curly brackets, quotation marks and common
|
||||
@@ -197,15 +576,29 @@ after Space, not immediately after a punctuation key. Save Settings to apply cha
|
||||
**Frequency rank limit** defaults to **0 (entire dictionary)**. A positive value allows corrections
|
||||
only to words with a rank at or below that number (for example, 10000). All dictionary words still
|
||||
protect already valid input, and ambiguous alternatives remain unchanged regardless of this limit.
|
||||
**My dictionary** provides separate English, Ukrainian and Russian fields for correct words missing
|
||||
from the bundled dictionaries. Enter words separated by commas or new lines, then click OK and Save
|
||||
in Settings. Case and duplicates are normalized automatically; no file editing or ranks are needed.
|
||||
The v0.6.23 bundled Ukrainian dictionary includes `поточні`. The updater preserves existing dictionary files;
|
||||
if upgrading from an older dictionary, add this word in My dictionary → Ukrainian and click OK → Save.
|
||||
These entries are stored in settings and survive dictionary updates. They protect correct input and
|
||||
are eligible as correction targets at any frequency limit. The language and target layout must still
|
||||
be enabled. **Never correct these words** is a separate exception list and takes precedence.
|
||||
**Open dictionary folder** opens the files beside the running executable. After editing, click OK
|
||||
in the options window and Save in Settings to validate and reload the dictionaries without restarting.
|
||||
Use UTF-8, one lowercase word followed by a Tab and a positive rank per line; keep words sorted and
|
||||
unique. For a personal addition, rank 1 makes it eligible at every positive limit. Back up your edited
|
||||
Use UTF-8, one lowercase word followed by a Tab and a positive rank per line. Entries can be appended;
|
||||
loading sorts them and merges duplicates using the lowest rank. Back up your edited
|
||||
files before replacing dictionaries with a new distribution.
|
||||
|
||||
After Space and a 250 ms typing pause, KSwitcher checks the last physically typed word against local
|
||||
dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type cancels pending work.
|
||||
It only corrects unknown words when exactly one alternative is a dictionary word.
|
||||
On Space, KSwitcher checks the last physically typed word against preloaded local dictionaries.
|
||||
For example, `ghbdsn ` can become `привіт `. A confirmed candidate immediately queues subsequent
|
||||
key presses and releases; no typing pause is required. Queued input is replayed after
|
||||
clipboard restoration and layout switching, preserving typing order, modifiers and Backspace.
|
||||
If the active window changes, queued typing is not sent to another window and a warning is shown.
|
||||
It only corrects unknown words when the dictionary alternatives agree on the resulting text.
|
||||
Identical Ukrainian/Russian spellings such as `ntcn` → `тест` are accepted, using the first matching
|
||||
language in the configured dictionary order (the UI uses English, Ukrainian, Russian).
|
||||
Different valid spellings remain ambiguous and are left unchanged.
|
||||
**Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation
|
||||
does not count toward this minimum; the maximum word length is 32 letters.
|
||||
Known words, ambiguous alternatives, ALL CAPS, mixed-case identifiers, numbers, URLs and email
|
||||
@@ -273,3 +666,22 @@ It still uses the normal instance lock; close a running KSwitcher first if measu
|
||||
|
||||
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
|
||||
|
||||
Every release must also update the published [Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki):
|
||||
the latest version and relevant feature documentation in both English and Ukrainian.
|
||||
Review all changes since the last documented release, update affected instructions,
|
||||
shortcuts, settings and screenshots, and verify the published pages and links.
|
||||
The release workflow is incomplete until this is done. Follow the mandatory
|
||||
[Wiki release checklist](AGENTS.md#mandatory-wiki-updates-for-every-release).
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.3.0 — 2026-10-28
|
||||
## v0.6.29 — 2026-11-02
|
||||
|
||||
- Move Settings and the snippet editor into the on-demand `KSwitcher.Config.exe` process.
|
||||
- Repeated launches activate the existing configurator. Closing its last window releases the process and its memory.
|
||||
- Apply settings immediately through validated IPC with the resident process; reject conflicting saves instead of overwriting newer settings.
|
||||
- Keep shortcut capture in the resident hook, with no extra global hooks in the configurator.
|
||||
- Support packaging, validation and rollback of both executables. About now displays the actual application version.
|
||||
## Changes
|
||||
- Added **Clear history… / Alt+H** directly to the Clipboard history window beside Edit image / Open item.
|
||||
- Clearing requires confirmation, with **No** selected by default. It removes all saved entries, including pinned entries and entries hidden by the current search. Cancelling leaves history unchanged.
|
||||
- The button follows the existing bottom action row when resizing. Settings retains its existing Clear history action.
|
||||
- Clearing saved history does not clear the current Windows clipboard or disable future recording. This release does not change memory management.
|
||||
|
||||
Verified: 170 native checks, 51 input/layout checks, 28 updater/metadata checks and 21 configurator process checks.
|
||||
A local before/after measurement after opening and closing Settings reduced resident working set from about 21.0 MiB to 14.7 MiB. Results vary; total memory is higher while the separate configurator is open.
|
||||
## Validation
|
||||
The final build passed isolated checks for the new button, cancellation, confirmed deletion of pinned/search-hidden entries and persistence after reloading the history store. 48 updater regression checks passed; all executable versions and release-manifest hashes were verified. The full desktop-interaction suite was not rerun for this focused patch.
|
||||
|
||||
**Upgrade from v0.2.3 or earlier: install this release manually once.** The old updater rejects the additional EXE.
|
||||
Exit KSwitcher, extract the entire ZIP into the installation folder, and retain `data/` and any customized dictionaries.
|
||||
Keep `KSwitcher.exe`, `KSwitcher.Config.exe` and `dictionaries/` together. Launch `KSwitcher.exe`.
|
||||
Subsequent updates support both executables through the tray Update menu.
|
||||
## Known limitations
|
||||
Existing asynchronous-editor selection/clipboard limitations remain; this release does not fix the reported ChatGPT selection mismatch. Dictionary typo correction and OpenBao synchronization remain experimental as documented in v0.6.28.
|
||||
|
||||
Download **KSwitcher-win-x64.zip**. The archive contains both applications, 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, close screenshot editors and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||
|
||||
This folder holds the latest compiled version; older versions remain in Git history and Releases.
|
||||
To refresh the tracked package from source:
|
||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package includes all three executables, a release manifest, offline dictionaries, documentation and license notices. No personal settings, passwords, server address or clipboard history are included.
|
||||
|
||||
Build and package:
|
||||
|
||||
```powershell
|
||||
.\build.ps1 -Stage -Test
|
||||
.\package.ps1 -PublishRelease
|
||||
```
|
||||
|
||||
Commit the ZIP and checksum together with their source changes. Publish both files as assets of
|
||||
one stable release tagged with the executable version, so the updater can verify it.
|
||||
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
||||
@@ -1 +1 @@
|
||||
104c79b64bb30a2161e3ec2f5108a731da0d274e015d53d8835ea7e64f431ee1 KSwitcher-win-x64.zip
|
||||
c9c3e3579575a41d434201d4f37bf3829e53c1ef23c42cb1c077930dfe8fdfa6 KSwitcher-win-x64.zip
|
||||
|
||||
@@ -10,11 +10,13 @@ $outDir = Join-Path $root 'build'
|
||||
New-Item -ItemType Directory -Force $outDir | Out-Null
|
||||
$executablePath = if ($Stage) { Join-Path $outDir 'staged\KSwitcher.exe' } else { Join-Path $outDir 'KSwitcher.exe' }
|
||||
New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $outDir '.local-dev') -Value 'Local development build' -Encoding ascii
|
||||
Set-Content -LiteralPath (Join-Path (Split-Path $executablePath) '.local-dev') -Value 'Local development build' -Encoding ascii
|
||||
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','configuration','config_main')) {
|
||||
$libs = @('-lwinhttp','-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','sync','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" }
|
||||
}
|
||||
@@ -24,34 +26,64 @@ try {
|
||||
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/configuration.o build/ui-resident.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/sync.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
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/sync.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
|
||||
& $compiler @common tests/sync.cpp build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o @libs -static -s -o build/KSwitcherSyncTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Sync test build failed' }
|
||||
& .\build\KSwitcherSyncTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Sync tests failed' }
|
||||
& $compiler @common tests/scroll.cpp @libs -static -s -o build/KSwitcherScrollTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Scroll test build failed' }
|
||||
& .\build\KSwitcherScrollTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Scroll tests failed' }
|
||||
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.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/configuration.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/sync.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' }
|
||||
& .\build\KSwitcherScreenshotUITests.exe --save-only
|
||||
if ($LASTEXITCODE) { throw 'Screenshot save tests failed' }
|
||||
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
|
||||
try {
|
||||
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
|
||||
if ($LASTEXITCODE) { throw 'Screenshot registration test build failed' }
|
||||
& $registrationTest
|
||||
if ($LASTEXITCODE) { throw 'Screenshot registration tests failed' }
|
||||
} finally { Remove-Item -LiteralPath $registrationTest -ErrorAction SilentlyContinue }
|
||||
|
||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
|
||||
if ($LASTEXITCODE) { throw 'Updater tests failed' }
|
||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update_progress.ps1
|
||||
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
|
||||
}
|
||||
if ($Integration) {
|
||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/app.o build/configuration.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/sync.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/sync.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' }
|
||||
|
||||
@@ -17,9 +17,19 @@ replace counts with original frequency ranks, sort lexically. Reproduce with
|
||||
hashes. The lists contain word forms from subtitles, not a complete spelling
|
||||
or grammar dictionary. Recognition can miss names, jargon and uncommon forms.
|
||||
|
||||
File format: UTF-8 word, TAB, positive original frequency rank, newline.
|
||||
Supplemental Ukrainian vocabulary: `реліз`, `поточні`. These additions use rank 50001,
|
||||
after the upstream 50k list, rather than claiming a measured corpus frequency.
|
||||
They are included with the default unlimited rank setting and recorded in sources.json.
|
||||
|
||||
File format: UTF-8 word, TAB, positive frequency rank (or supplemental rank), newline.
|
||||
All entries protect known words. The frequency rank limit in Auto-correction options defaults to 0
|
||||
(entire dictionary); a positive limit only permits correction candidates at or below that rank.
|
||||
Personal words may use rank 1. Keep entries lowercase, unique and sorted, with a Tab separator.
|
||||
Prefer Settings > Auto-correction options > My dictionary for personal words.
|
||||
Enter correct spellings in the matching language field, separated by commas or new lines.
|
||||
These words are stored in settings, survive dictionary updates, and need no ranks or sorting.
|
||||
Click OK, then Save in Settings to apply them.
|
||||
|
||||
For advanced file edits, personal words may use rank 1. Keep entries lowercase with a Tab separator.
|
||||
Words may be appended: loading sorts entries and merges duplicates using the lowest rank.
|
||||
Save the UTF-8 file, then click OK in Auto-correction options and Save in Settings to reload it.
|
||||
Back up edited files before replacing dictionaries with a new distribution.
|
||||
|
||||
@@ -9,9 +9,13 @@
|
||||
"uk": {
|
||||
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
|
||||
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
|
||||
"words": 42549,
|
||||
"bytes": 898210,
|
||||
"sha256": "b4104898dcccbc1331ec01e9cd690e30285fe58481460e47438e973964d09802"
|
||||
"words": 42551,
|
||||
"bytes": 898248,
|
||||
"sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86",
|
||||
"supplementalWords": {
|
||||
"\u0440\u0435\u043b\u0456\u0437": 50001,
|
||||
"\u043f\u043e\u0442\u043e\u0447\u043d\u0456": 50001
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/ru/ru_50k.txt",
|
||||
|
||||
@@ -27337,6 +27337,7 @@
|
||||
поторопись 9062
|
||||
поторопитесь 20656
|
||||
поторопиться 15390
|
||||
поточні 50001
|
||||
потрапив 4168
|
||||
потрапила 7383
|
||||
потрапили 6631
|
||||
@@ -31207,6 +31208,7 @@
|
||||
релігію 26138
|
||||
релігія 22202
|
||||
релігії 23427
|
||||
реліз 50001
|
||||
рембо 28603
|
||||
ремень 23872
|
||||
ремені 28385
|
||||
|
||||
@@ -5,12 +5,22 @@ $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','KSwitcher.Config.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
|
||||
|
||||
@@ -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,3,0,0
|
||||
PRODUCTVERSION 0,3,0,0
|
||||
FILEVERSION 0,6,29,0
|
||||
PRODUCTVERSION 0,6,29,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.3.0"
|
||||
VALUE "FileVersion", "0.6.29"
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -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 |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -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 |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -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 |
|
After Width: | Height: | Size: 36 KiB |
@@ -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 |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -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 |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -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 |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 53 KiB |
@@ -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 |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -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 |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
@@ -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 |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -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 |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -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 |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -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 |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -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 |
|
After Width: | Height: | Size: 56 KiB |
@@ -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 |
@@ -1,2 +1,4 @@
|
||||
#pragma once
|
||||
#define IDI_KSWITCHER 101
|
||||
#define IDI_SCREENSHOT_QUESTION 102
|
||||
#define IDC_SCREENSHOT_PENCIL 500
|
||||
|
||||
|
After Width: | Height: | Size: 71 KiB |
@@ -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 |
|
After Width: | Height: | Size: 70 KiB |
@@ -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 |
@@ -3,14 +3,196 @@
|
||||
[string]$CurrentVersion, [string]$ExpectedVersion,
|
||||
[string]$InstallDirectory, [string]$WorkDirectory,
|
||||
[int]$ParentProcessId, [string]$ReadyEvent,
|
||||
[switch]$ShowProgress,
|
||||
[switch]$Library
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
$script:progressWindow = $null
|
||||
|
||||
function Start-UpdateProgress {
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
if (!('KSwitcherUpdateProgress' -as [type])) {
|
||||
Add-Type -ReferencedAssemblies System.Windows.Forms,System.Drawing -TypeDefinition @'
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Threading;
|
||||
using System.Windows.Forms;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Runtime.InteropServices;
|
||||
public sealed class UpdateMeter : Control {
|
||||
public int Percent = -1;
|
||||
private int position;
|
||||
private readonly System.Windows.Forms.Timer timer = new System.Windows.Forms.Timer { Interval = 35 };
|
||||
public UpdateMeter() {
|
||||
DoubleBuffered = true;
|
||||
timer.Tick += delegate { if (Percent < 0) { position = (position + 5) % Math.Max(1,Width); Invalidate(); } };
|
||||
timer.Start();
|
||||
}
|
||||
protected override void OnPaint(PaintEventArgs e) {
|
||||
using(var track = new SolidBrush(Color.FromArgb(232,234,241))) e.Graphics.FillRectangle(track,0,0,Width,Height);
|
||||
using(var fill = new SolidBrush(Color.FromArgb(79,70,229))) {
|
||||
if(Percent < 0) e.Graphics.FillRectangle(fill,position-Width/4,0,Width/4,Height);
|
||||
else e.Graphics.FillRectangle(fill,0,0,Width*Percent/100,Height);
|
||||
}
|
||||
}
|
||||
protected override void Dispose(bool disposing) { if(disposing) timer.Dispose(); base.Dispose(disposing); }
|
||||
}
|
||||
public sealed class UpdateSteps : Control {
|
||||
public int Current = -1;
|
||||
public bool Complete;
|
||||
public UpdateSteps() { DoubleBuffered = true; }
|
||||
protected override void OnPaint(PaintEventArgs e) {
|
||||
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
string[] names = {"Download","Verify","Install","Restart"};
|
||||
for(int i=0;i<4;i++) {
|
||||
int x = i*(Width-80)/3;
|
||||
Color color = Complete || i<=Current ? Color.FromArgb(79,70,229) : Color.FromArgb(135,141,158);
|
||||
using(var pen = new Pen(Color.FromArgb(231,233,240)))
|
||||
if(i<3) e.Graphics.DrawLine(pen,x+22,8,x+(Width-80)/3-10,8);
|
||||
using(var brush = new SolidBrush(color)) {
|
||||
if(Complete || i<Current) {
|
||||
e.Graphics.FillEllipse(brush,x,0,16,16);
|
||||
using(var pen = new Pen(Color.White,1.6f)) e.Graphics.DrawLines(pen,new Point[]{new Point(x+4,8),new Point(x+7,11),new Point(x+12,5)});
|
||||
} else {
|
||||
using(var pen = new Pen(color,i==Current?2:1)) e.Graphics.DrawEllipse(pen,x+1,1,14,14);
|
||||
if(i==Current) e.Graphics.FillEllipse(brush,x+5,5,6,6);
|
||||
}
|
||||
e.Graphics.DrawString(names[i],Font,brush,x,26);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
public sealed class KSwitcherUpdateProgress : IDisposable {
|
||||
private Form form;
|
||||
private Label stage, detail, percentage, footer;
|
||||
private UpdateMeter progress;
|
||||
private UpdateSteps steps;
|
||||
private Button done, close;
|
||||
private bool closing;
|
||||
private Exception failure;
|
||||
private readonly ManualResetEvent ready = new ManualResetEvent(false);
|
||||
private readonly Thread thread;
|
||||
[DllImport("user32.dll")] private static extern bool ReleaseCapture();
|
||||
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr h,int m,IntPtr w,IntPtr l);
|
||||
[DllImport("dwmapi.dll")] private static extern int DwmSetWindowAttribute(IntPtr h,int a,ref int v,int size);
|
||||
private void Drag(object sender,MouseEventArgs e) { if(e.Button==MouseButtons.Left) { ReleaseCapture(); SendMessage(form.Handle,0xA1,(IntPtr)2,IntPtr.Zero); } }
|
||||
public KSwitcherUpdateProgress() {
|
||||
thread = new Thread(delegate() {
|
||||
try {
|
||||
Application.EnableVisualStyles();
|
||||
form = new Form { Text = "KSwitcher Update", ClientSize = new Size(540,350), BackColor = Color.White,
|
||||
ForeColor = Color.FromArgb(32,36,48), StartPosition = FormStartPosition.CenterScreen,
|
||||
FormBorderStyle = FormBorderStyle.None, Font = new Font("Segoe UI",10), AutoScaleMode = AutoScaleMode.Dpi };
|
||||
var logo = new Panel { Location = new Point(28,24), Size = new Size(34,34), BackColor = Color.FromArgb(79,70,229) };
|
||||
logo.Paint += delegate(object sender,PaintEventArgs e) {
|
||||
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
using(var pen = new Pen(Color.White,4)) { e.Graphics.DrawLine(pen,10,8,10,26); e.Graphics.DrawLine(pen,11,17,25,7); }
|
||||
using(var pen = new Pen(Color.FromArgb(94,234,212),4)) e.Graphics.DrawLine(pen,12,17,25,27);
|
||||
};
|
||||
var brand = new Label { Text = "KSwitcher", Location = new Point(74,22), Size = new Size(260,24), Font = new Font("Segoe UI Semibold",11) };
|
||||
var caption = new Label { Text = "Application update", Location = new Point(74,45), Size = new Size(260,20), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
|
||||
var minimize = new Button { Text = "\u2013", AccessibleName = "Minimize", Location = new Point(452,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, TabStop = false };
|
||||
minimize.FlatAppearance.BorderSize = 0; minimize.Click += delegate { form.WindowState = FormWindowState.Minimized; };
|
||||
close = new Button { Text = "\u00d7", AccessibleName = "Close", Location = new Point(490,20), Size = new Size(32,32), FlatStyle = FlatStyle.Flat, Enabled = false };
|
||||
close.FlatAppearance.BorderSize = 0; close.Click += delegate { form.Close(); };
|
||||
stage = new Label { Text = "Checking for updates", Location = new Point(28,98), Size = new Size(414,36), Font = new Font("Segoe UI Semibold",20) };
|
||||
percentage = new Label { Location = new Point(440,104), Size = new Size(72,30), TextAlign = ContentAlignment.MiddleRight, ForeColor = Color.FromArgb(79,70,229), Font = new Font("Segoe UI Semibold",16) };
|
||||
detail = new Label { Text = "Connecting to the release server", Location = new Point(28,148), Size = new Size(484,42), ForeColor = Color.FromArgb(111,117,134) };
|
||||
progress = new UpdateMeter { Location = new Point(28,202), Size = new Size(484,6), AccessibleName = "Update progress" };
|
||||
steps = new UpdateSteps { Location = new Point(28,230), Size = new Size(484,48), Font = new Font("Segoe UI",9) };
|
||||
var bottom = new Panel { Location = new Point(1,296), Size = new Size(538,53), BackColor = Color.FromArgb(247,248,251) };
|
||||
footer = new Label { Text = "Your settings and history stay with you.", Location = new Point(27,18), Size = new Size(350,22), ForeColor = Color.FromArgb(111,117,134), Font = new Font("Segoe UI",9) };
|
||||
done = new Button { Text = "Done", Location = new Point(410,9), Size = new Size(100,34), BackColor = Color.FromArgb(79,70,229), ForeColor = Color.White, FlatStyle = FlatStyle.Flat, Visible = false };
|
||||
done.FlatAppearance.BorderSize = 0; done.Click += delegate { form.Close(); };
|
||||
bottom.Controls.AddRange(new Control[]{footer,done});
|
||||
form.Controls.AddRange(new Control[]{logo,brand,caption,minimize,close,stage,percentage,detail,progress,steps,bottom});
|
||||
form.MouseDown += Drag; logo.MouseDown += Drag; brand.MouseDown += Drag; caption.MouseDown += Drag;
|
||||
form.Paint += delegate(object sender,PaintEventArgs e) { using(var pen = new Pen(Color.FromArgb(220,223,232))) e.Graphics.DrawRectangle(pen,0,0,form.ClientSize.Width-1,form.ClientSize.Height-1); };
|
||||
form.FormClosing += delegate(object sender, FormClosingEventArgs e) { if (!closing) e.Cancel = true; };
|
||||
form.Shown += delegate { int rounded=2; try { DwmSetWindowAttribute(form.Handle,33,ref rounded,4); } catch { } ready.Set(); };
|
||||
Application.Run(form);
|
||||
} catch(Exception e) { failure = e; ready.Set(); }
|
||||
});
|
||||
thread.IsBackground = true;
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
if (!ready.WaitOne(10000)) throw new Exception("Update progress window did not start.");
|
||||
if (failure != null) throw new Exception("Cannot display update progress.",failure);
|
||||
}
|
||||
public void Report(string title, string text, int percent) {
|
||||
if (form == null || form.IsDisposed) return;
|
||||
form.BeginInvoke((Action)delegate {
|
||||
stage.Text = title; detail.Text = text;
|
||||
progress.Percent = percent < 0 ? -1 : Math.Max(0,Math.Min(100,percent));
|
||||
percentage.Text = percent < 0 ? "" : progress.Percent+"%";
|
||||
int separator = text.IndexOf("% - ");
|
||||
if(separator>=0) detail.Text = text.Substring(separator+4)+" downloaded";
|
||||
if(title.StartsWith("Downloading")) steps.Current=0;
|
||||
else if(title.StartsWith("Verifying") || title.StartsWith("Preparing")) steps.Current=1;
|
||||
else if(title.StartsWith("Installing") || title.StartsWith("Closing")) steps.Current=2;
|
||||
else if(title.StartsWith("Restarting")) steps.Current=3;
|
||||
progress.Invalidate(); steps.Invalidate();
|
||||
});
|
||||
}
|
||||
public void SavePreview(string path) {
|
||||
form.Invoke((Action)delegate {
|
||||
using(var bitmap = new Bitmap(form.Width,form.Height)) {
|
||||
form.DrawToBitmap(bitmap,new Rectangle(0,0,bitmap.Width,bitmap.Height));
|
||||
bitmap.Save(path,System.Drawing.Imaging.ImageFormat.Png);
|
||||
}
|
||||
});
|
||||
}
|
||||
public string Status { get { return (string)form.Invoke((Func<string>)delegate { return stage.Text; }); } }
|
||||
public int Percent { get { return (int)form.Invoke((Func<int>)delegate { return progress.Percent; }); } }
|
||||
public void Finish(string title, string text, bool success) {
|
||||
form.Invoke((Action)delegate {
|
||||
closing = true;
|
||||
stage.Text = title; detail.Text = text;
|
||||
progress.Percent = success ? 100 : 0; percentage.Text = success ? "100%" : "";
|
||||
steps.Complete = success;
|
||||
if(!success) {
|
||||
detail.Visible = false; steps.Visible = false;
|
||||
var result = new TextBox { Text = text, Location = new Point(28,148), Size = new Size(484,126),
|
||||
Multiline = true, ReadOnly = true, BorderStyle = BorderStyle.None,
|
||||
BackColor = Color.White, ScrollBars = ScrollBars.Vertical };
|
||||
form.Controls.Add(result); result.BringToFront(); progress.Visible = false;
|
||||
}
|
||||
footer.Text = success ? "Your settings and history are preserved." : "Review the details above.";
|
||||
close.Enabled = true; done.Visible = true;
|
||||
form.AcceptButton = done; form.CancelButton = done;
|
||||
progress.Invalidate(); steps.Invalidate();
|
||||
});
|
||||
}
|
||||
public void WaitForClose() { thread.Join(); }
|
||||
public void Dispose() {
|
||||
if (form != null && !form.IsDisposed)
|
||||
form.BeginInvoke((Action)delegate { closing = true; form.Close(); });
|
||||
thread.Join(3000);
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
$script:progressWindow = New-Object KSwitcherUpdateProgress
|
||||
}
|
||||
function Show-UpdateProgress([string]$Stage,[string]$Detail,[int]$Percent = -1) {
|
||||
if ($script:progressWindow) {
|
||||
try { $script:progressWindow.Report($Stage,$Detail,$Percent) }
|
||||
catch { Write-Warning 'Could not refresh update progress.' }
|
||||
}
|
||||
}
|
||||
function Complete-UpdateProgress([string]$Title,[string]$Detail,[bool]$Success) {
|
||||
if ($script:progressWindow) {
|
||||
try { $script:progressWindow.Finish($Title,$Detail,$Success); return $true }
|
||||
catch { Write-Warning 'Could not display the update result.' }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-StableVersion([string]$Tag) {
|
||||
if ($Tag -notmatch '^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
||||
try { return [version]($Tag -replace '^v','') } catch { return $null }
|
||||
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
||||
try { return [version]($Tag -replace '^(?:release-)?v','') } catch { return $null }
|
||||
}
|
||||
function Assert-ReleaseUrl([string]$Url) {
|
||||
$uri = [uri]$Url
|
||||
@@ -20,7 +202,7 @@ function Assert-ReleaseUrl([string]$Url) {
|
||||
throw 'Release asset URL is outside the KSwitcher repository.'
|
||||
}
|
||||
}
|
||||
function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
|
||||
function Receive-File([string]$Url,[string]$Path,[long]$Limit,[string]$ProgressLabel = '') {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$request = [Net.HttpWebRequest]::Create($Url)
|
||||
$request.AllowAutoRedirect = $false
|
||||
@@ -36,11 +218,20 @@ function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
|
||||
$buffer = New-Object byte[] 65536
|
||||
$total = 0L
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
||||
$lastProgress = [DateTime]::MinValue
|
||||
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
|
||||
$total += $count
|
||||
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
|
||||
$outputStream.Write($buffer,0,$count)
|
||||
if ($ProgressLabel -and ([DateTime]::UtcNow - $lastProgress).TotalMilliseconds -ge 100) {
|
||||
$percent = if ($response.ContentLength -gt 0) { [int][Math]::Min(100,100.0*$total/$response.ContentLength) } else { -1 }
|
||||
$detail = if ($percent -ge 0) { '{0}% - {1:N1} / {2:N1} MB' -f $percent,($total/1MB),($response.ContentLength/1MB) } else { '{0:N1} MB received' -f ($total/1MB) }
|
||||
Show-UpdateProgress $ProgressLabel $detail $percent
|
||||
$lastProgress = [DateTime]::UtcNow
|
||||
}
|
||||
}
|
||||
if ($response.ContentLength -ge 0 -and $total -ne $response.ContentLength) { throw 'The download ended before the complete file was received.' }
|
||||
if ($ProgressLabel) { Show-UpdateProgress $ProgressLabel ('Downloaded {0:N1} MB' -f ($total/1MB)) 100 }
|
||||
} finally {
|
||||
if ($outputStream) { $outputStream.Dispose() }
|
||||
if ($inputStream) { $inputStream.Dispose() }
|
||||
@@ -63,9 +254,15 @@ function Assert-Checksum([string]$Archive,[string]$Checksum) {
|
||||
$matchesFound = @([IO.File]::ReadAllLines($Checksum) | Where-Object { $_ -cmatch '^([a-fA-F0-9]{64})\s+\*?KSwitcher-win-x64\.zip$' })
|
||||
if ($matchesFound.Count -ne 1) { throw 'Missing or ambiguous archive checksum.' }
|
||||
$expected = ($matchesFound[0] -split '\s+')[0]
|
||||
if ((Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash -ne $expected) { throw 'Release checksum verification failed.' }
|
||||
# Do not depend on PowerShell module autoloading: hosts can inherit a
|
||||
# PowerShell 7 PSModulePath while this helper runs in Windows PowerShell 5.1.
|
||||
$stream = [IO.File]::OpenRead($Archive)
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try { $actual = [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','') }
|
||||
finally { $sha.Dispose(); $stream.Dispose() }
|
||||
if ($actual -ne $expected) { throw 'Release checksum verification failed.' }
|
||||
}
|
||||
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
|
||||
function Expand-LegacyRelease([string]$Archive,[string]$Destination,[version]$Expected) {
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
|
||||
try {
|
||||
@@ -74,7 +271,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
|
||||
foreach ($entry in $zip.Entries) {
|
||||
$name = $entry.FullName.Replace('\','/')
|
||||
if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue }
|
||||
if ($name -cnotmatch '^(KSwitcher(?:\.Config)?\.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"
|
||||
@@ -92,7 +289,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
|
||||
throw 'Incomplete release: KSwitcher.Config.exe'
|
||||
}
|
||||
} finally { $zip.Dispose() }
|
||||
foreach ($binary in @('KSwitcher.exe','KSwitcher.Config.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)
|
||||
@@ -106,6 +303,75 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
|
||||
[BitConverter]::ToUInt16($bytes,$pe+4) -ne 0x8664) { throw 'Release is not a Windows x64 executable.' }
|
||||
}
|
||||
}
|
||||
function Get-Sha256([IO.Stream]$Stream) {
|
||||
$sha=[Security.Cryptography.SHA256]::Create()
|
||||
try { return [BitConverter]::ToString($sha.ComputeHash($Stream)).Replace('-','').ToLowerInvariant() }
|
||||
finally { $sha.Dispose() }
|
||||
}
|
||||
function Assert-PackagePath([string]$Name) {
|
||||
if (!$Name -or $Name.Length -gt 220 -or $Name -match '[\\:<>"|?*]' -or $Name.StartsWith('/')) { throw "Unsafe package path: $Name" }
|
||||
$parts=$Name.Split('/')
|
||||
foreach ($part in $parts) {
|
||||
if (!$part -or $part -in @('.','..') -or $part -match '[. ]$|[\x00-\x1f]' -or
|
||||
$part -match '^(?i:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\.|$)') { throw "Unsafe package path: $Name" }
|
||||
}
|
||||
if ($parts[0] -ieq 'data' -or $parts[0] -like '.kswitcher-*') { throw "Protected user path: $Name" }
|
||||
}
|
||||
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$zip=[IO.Compression.ZipFile]::OpenRead($Archive)
|
||||
try {
|
||||
$manifests=@($zip.Entries | Where-Object FullName -CEQ 'release-files.json')
|
||||
if (!$manifests.Count) {
|
||||
if ($Expected -gt [version]'0.4.2') { throw 'Release manifest is missing.' }
|
||||
$zip.Dispose(); $zip=$null
|
||||
Expand-LegacyRelease $Archive $Destination $Expected
|
||||
return
|
||||
}
|
||||
if ($manifests.Count -ne 1 -or $manifests[0].Length -gt 256KB) { throw 'Invalid release manifest.' }
|
||||
$reader=[IO.StreamReader]::new($manifests[0].Open())
|
||||
try { $manifest=$reader.ReadToEnd() | ConvertFrom-Json } finally { $reader.Dispose() }
|
||||
if ($manifest.schema -ne 1 -or (Get-StableVersion $manifest.version) -ne $Expected -or
|
||||
@($manifest.files).Count -lt 1 -or @($manifest.files).Count -gt 1000) { throw 'Unsupported or mismatched release manifest.' }
|
||||
$declared=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($file in $manifest.files) {
|
||||
Assert-PackagePath $file.path
|
||||
if ($file.path -ieq 'release-files.json' -or $declared.ContainsKey($file.path) -or
|
||||
$file.sha256 -notmatch '^[a-fA-F0-9]{64}$' -or $file.size -lt 0 -or $file.size -gt 100MB) { throw 'Invalid manifest file entry.' }
|
||||
$declared.Add($file.path,$file)
|
||||
}
|
||||
if (!$declared.ContainsKey('KSwitcher.exe')) { throw 'Main executable is missing from the manifest.' }
|
||||
$seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
$total=0L
|
||||
foreach ($entry in $zip.Entries) {
|
||||
$name=$entry.FullName.Replace('\','/')
|
||||
if ($name.EndsWith('/')) { Assert-PackagePath $name.TrimEnd('/'); continue }
|
||||
Assert-PackagePath $name
|
||||
if (!$seen.Add($name) -or (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000) { throw 'Duplicate or linked archive entry.' }
|
||||
if ($name -ceq 'release-files.json') { continue }
|
||||
if (!$declared.ContainsKey($name)) { throw "Undeclared package file: $name" }
|
||||
$file=$declared[$name]
|
||||
if ($entry.Length -ne $file.size) { throw "Package file size mismatch: $name" }
|
||||
$total+=$entry.Length
|
||||
if ($total -gt 100MB -or $zip.Entries.Count -gt 1100) { throw 'Release expands beyond allowed limits.' }
|
||||
$stream=$entry.Open()
|
||||
try { $hash=Get-Sha256 $stream } finally { $stream.Dispose() }
|
||||
if ($hash -ne $file.sha256) { throw "Package file checksum mismatch: $name" }
|
||||
}
|
||||
if ($seen.Count -ne $declared.Count+1) { throw 'Archive does not contain every manifest file.' }
|
||||
[IO.Directory]::CreateDirectory($Destination) | Out-Null
|
||||
Assert-PlainDirectory $Destination
|
||||
foreach ($entry in $zip.Entries) {
|
||||
if ($entry.FullName.EndsWith('/')) { continue }
|
||||
$target=Join-Path $Destination $entry.FullName
|
||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($target)) | Out-Null
|
||||
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($target))
|
||||
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry,$target,$false)
|
||||
}
|
||||
$info=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $Destination 'KSwitcher.exe'))
|
||||
if ($info.ProductName -ne 'KSwitcher' -or (Get-StableVersion $info.FileVersion) -ne $Expected) { throw 'Main executable identity/version mismatch.' }
|
||||
} finally { if ($zip) { $zip.Dispose() } }
|
||||
}
|
||||
function Assert-PlainDirectory([string]$Directory) {
|
||||
$item = Get-Item -LiteralPath $Directory -Force
|
||||
while ($item) {
|
||||
@@ -118,7 +384,7 @@ function Install-Files([string]$Stage,[string]$Target,[string]$Backup,[Collectio
|
||||
foreach ($file in @(Get-ChildItem -LiteralPath $Stage -File -Recurse)) {
|
||||
$relative = $file.FullName.Substring($Stage.Length+1)
|
||||
$destination = Join-Path $Target $relative
|
||||
if ($relative.StartsWith('dictionaries\') -and (Test-Path -LiteralPath $destination)) { continue }
|
||||
if ($relative.StartsWith('dictionaries\',[StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $destination)) { continue }
|
||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
|
||||
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($destination))
|
||||
if (Test-Path -LiteralPath $destination) {
|
||||
@@ -145,8 +411,9 @@ function Write-Result($Value) {
|
||||
if ($Library) { return }
|
||||
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
|
||||
$changed = [Collections.Generic.List[string]]::new()
|
||||
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null
|
||||
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null; $uiFinished = $false
|
||||
try {
|
||||
if ($ShowProgress -or $Mode -eq 'Install') { Start-UpdateProgress }
|
||||
$current = Get-StableVersion $CurrentVersion
|
||||
if (!$current) { throw 'Invalid installed version.' }
|
||||
if ($Mode -eq 'Install') {
|
||||
@@ -167,23 +434,35 @@ try {
|
||||
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
|
||||
[IO.Directory]::CreateDirectory($stage) | Out-Null
|
||||
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt'
|
||||
Receive-File $release.zip $archive 50MB
|
||||
Show-UpdateProgress 'Downloading update' $release.version 0
|
||||
Receive-File $release.zip $archive 50MB 'Downloading update'
|
||||
Show-UpdateProgress 'Verifying download' 'Checking the archive SHA-256 checksum'
|
||||
Receive-File $release.checksum $checksum 64KB
|
||||
Assert-Checksum $archive $checksum
|
||||
Show-UpdateProgress 'Preparing installation' 'Validating and unpacking the release files'
|
||||
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
||||
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
|
||||
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
|
||||
Write-Result @{state='prepared';version=$release.version}
|
||||
Show-UpdateProgress 'Closing KSwitcher' 'Waiting for the running application to exit safely'
|
||||
$ready.Set() | Out-Null
|
||||
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
||||
$parentExited = $true
|
||||
Show-UpdateProgress 'Installing update' 'Replacing application files; keeping your personal data'
|
||||
Install-Files $stage $InstallDirectory $backup $changed
|
||||
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
||||
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
|
||||
Show-UpdateProgress 'Restarting KSwitcher' 'Waiting for the updated application to confirm startup'
|
||||
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
|
||||
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
|
||||
$success = $true
|
||||
if ($script:progressWindow) {
|
||||
$uiFinished = Complete-UpdateProgress 'Update completed' "KSwitcher $(Get-StableVersion $release.version) is now running." $true
|
||||
}
|
||||
} catch {
|
||||
$message = $_.Exception.Message
|
||||
if ($parentExited) {
|
||||
Show-UpdateProgress 'Restoring previous version' 'The update failed; recovering the previous installation'
|
||||
try {
|
||||
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
|
||||
Undo-Files $InstallDirectory $backup $changed
|
||||
@@ -196,8 +475,12 @@ try {
|
||||
}
|
||||
Write-Result @{state='error';error=$message}
|
||||
if ($ready -and $ready.WaitOne(0)) {
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
|
||||
if ($script:progressWindow) {
|
||||
$uiFinished = Complete-UpdateProgress 'Update failed' $message $false
|
||||
} else {
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
|
||||
}
|
||||
}
|
||||
exit 1
|
||||
} finally {
|
||||
@@ -214,4 +497,8 @@ try {
|
||||
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
if ($script:progressWindow) {
|
||||
if ($uiFinished) { $script:progressWindow.WaitForClose() }
|
||||
$script:progressWindow.Dispose(); $script:progressWindow = $null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "autocorrect.h"
|
||||
#include "storage.h"
|
||||
#include "update.h"
|
||||
#include "sync.h"
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
@@ -14,8 +15,9 @@ namespace ks
|
||||
inline constexpr UINT TrayMessage = WM_APP + 1, ErrorMessage = WM_APP + 2, HistoryMessage = WM_APP + 3,
|
||||
SnippetMessage = WM_APP + 4;
|
||||
inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, AutoCorrect = 6,
|
||||
Direct = 100;
|
||||
Screenshot = 7, Direct = 100;
|
||||
inline constexpr UINT AutoCorrectMessage = WM_APP + 6;
|
||||
inline constexpr UINT ReplayTypingMessage = WM_APP + 30;
|
||||
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
|
||||
struct LastTransform
|
||||
{
|
||||
@@ -37,6 +39,7 @@ class App
|
||||
Settings settings;
|
||||
std::unique_ptr<History> history;
|
||||
AutoCorrector autoCorrector;
|
||||
AutoCorrector autoPredictor; // Preloaded, hook-thread-only lookup; no file access while typing.
|
||||
std::atomic<bool> busy{false}, suspended{false};
|
||||
std::atomic<DWORD> ownSequence{0};
|
||||
std::atomic<uint64_t> inputVersion{0};
|
||||
@@ -61,6 +64,7 @@ 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();
|
||||
@@ -68,12 +72,16 @@ class App
|
||||
void applySettings(const Settings &);
|
||||
void reloadLayouts();
|
||||
void clearHistory();
|
||||
void clearLog(bool previous);
|
||||
void setHotkeyCapture(HWND);
|
||||
bool isHotkey(DWORD, UINT);
|
||||
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
||||
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
|
||||
void resetInput();
|
||||
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
|
||||
std::wstring syncCommand(int action = 0, const std::vector<SyncChoice> &choices = {});
|
||||
Bytes syncConflictData();
|
||||
void pollSync();
|
||||
static App *current;
|
||||
|
||||
private:
|
||||
@@ -83,23 +91,50 @@ 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;
|
||||
std::thread syncWorker;
|
||||
std::atomic<bool> syncDone{false};
|
||||
bool syncRunning{};
|
||||
ULONGLONG nextSync{}, syncRetryAfter{};
|
||||
SyncResult syncResult;
|
||||
SyncConfig syncConnection;
|
||||
SyncConfig conflictConnection;
|
||||
std::vector<SyncConflict> pendingSyncConflicts;
|
||||
Bytes syncSnapshot;
|
||||
std::wstring syncStatus = L"Not connected.";
|
||||
bool updateRunning{}, installingUpdate{};
|
||||
std::wstring availableUpdate;
|
||||
std::wstring availableUpdate, notifiedUpdate;
|
||||
bool (*updateNotifier)(const std::wstring &){};
|
||||
bool stop{};
|
||||
UINT taskbarCreated{};
|
||||
uint64_t pendingAutoVersion{};
|
||||
uint64_t transformAttempt{}, previousAttemptVersion{};
|
||||
ULONGLONG previousAttemptAt{};
|
||||
HWND previousAttemptWindow{};
|
||||
int previousAttemptCommand{}, repeatAttempts{};
|
||||
std::vector<std::pair<int, Hotkey>> hookBindings;
|
||||
HotkeyHook hotkeyHook;
|
||||
HWND typingTarget{};
|
||||
bool autoPending{};
|
||||
int snippetPending = -1;
|
||||
bool snippetTyping{};
|
||||
std::vector<INPUT> deferredTyping;
|
||||
bool beginTypingDeferral(HWND, uint64_t, bool snippet = false);
|
||||
void replayTyping();
|
||||
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
||||
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
||||
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
|
||||
};
|
||||
void showHistory(App &);
|
||||
void showHistory(App &, bool visible = true);
|
||||
void launchScreenshot(App &);
|
||||
void launchScreenshotEditor(App &, const Clip &);
|
||||
void refreshHistory(App &);
|
||||
void showSettings(App &, bool visible = true);
|
||||
void showLogs(App &);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Included by app.cpp; all App mutations happen on its window thread.
|
||||
Bytes App::syncConflictData()
|
||||
{
|
||||
if (loadSyncConfig(data) != conflictConnection) return encodeSyncConflicts({});
|
||||
return encodeSyncConflicts(pendingSyncConflicts);
|
||||
}
|
||||
std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices)
|
||||
{
|
||||
if (action == 0) return syncStatus;
|
||||
if (syncRunning) return L"Synchronization is already running.";
|
||||
auto c = loadSyncConfig(data);
|
||||
if (!c.enabled) return syncStatus = L"Synchronization is disabled.";
|
||||
validateSyncConfig(c);
|
||||
if (action == 4 && (c != conflictConnection || choices.empty()))
|
||||
throw std::runtime_error("Connection changed or no choices selected. Sync again.");
|
||||
if (busy) return L"Wait for the current text operation to finish.";
|
||||
syncRetryAfter = 0; // Explicit retries may bypass the automatic error backoff.
|
||||
if (syncWorker.joinable()) syncWorker.join();
|
||||
syncConnection = c;
|
||||
syncSnapshot = encodeSettings(settings);
|
||||
auto snapshot = settings;
|
||||
auto availableLayouts = layouts;
|
||||
syncRunning = true;
|
||||
syncDone = false;
|
||||
syncStatus = L"Connecting to OpenBao...";
|
||||
nextSync = GetTickCount64() + SyncIntervalMs;
|
||||
try
|
||||
{
|
||||
syncWorker = std::thread([this, c, snapshot, availableLayouts, action, choices] {
|
||||
SyncResult result;
|
||||
try
|
||||
{
|
||||
auto provider = makeSyncProvider(c);
|
||||
// An explicit choice also repairs a missing/corrupt local baseline.
|
||||
auto base = action == 1 || action == 4 ? loadSyncBase(data, c) : Bytes{};
|
||||
result = synchronize(*provider, base, sharedSyncSettings(snapshot),
|
||||
action == 2 ? SyncAction::Download : action == 3 ? SyncAction::Upload : SyncAction::Automatic,
|
||||
[&](const Bytes &b) { restoreSyncSettings(b, snapshot, availableLayouts, c.keepLocalLayouts); }, choices,
|
||||
c.keepLocalLayouts);
|
||||
if (result.success && c.keepLocalLayouts)
|
||||
result.status += L" Partial sync: this PC's layouts and their shortcuts were kept; server layouts were preserved.";
|
||||
}
|
||||
catch (const nlohmann::json::exception &) { result.status = L"Invalid sync data or server response."; }
|
||||
catch (const std::exception &e) { result.status = wide(e.what()); }
|
||||
syncResult = std::move(result);
|
||||
syncDone.store(true);
|
||||
});
|
||||
}
|
||||
catch (...) { syncRunning = false; throw; }
|
||||
return syncStatus;
|
||||
}
|
||||
void App::pollSync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (syncRunning && syncDone.load())
|
||||
{
|
||||
if (busy) return;
|
||||
syncWorker.join();
|
||||
syncRunning = false;
|
||||
syncDone = false;
|
||||
syncStatus = syncResult.status;
|
||||
if (syncResult.success)
|
||||
{
|
||||
if (loadSyncConfig(data) != syncConnection || encodeSettings(settings) != syncSnapshot)
|
||||
{
|
||||
syncStatus = L"Local settings or connection changed during sync. Checking again shortly.";
|
||||
nextSync = GetTickCount64() + 2000;
|
||||
return;
|
||||
}
|
||||
auto next = restoreSyncSettings(syncResult.settings, settings, layouts, syncConnection.keepLocalLayouts);
|
||||
if (encodeSettings(next) != syncSnapshot)
|
||||
{
|
||||
atomicWrite(data / L"settings-before-sync.bin", protect(syncSnapshot));
|
||||
applySettings(next);
|
||||
}
|
||||
saveSyncBase(data, syncConnection, syncResult.base.empty() ? syncResult.settings : syncResult.base);
|
||||
pendingSyncConflicts = syncResult.conflicts;
|
||||
conflictConnection = syncConnection;
|
||||
SYSTEMTIME time{}; GetLocalTime(&time);
|
||||
wchar_t stamp[32]{};
|
||||
swprintf(stamp, std::size(stamp), L" (%02u:%02u:%02u)", time.wHour, time.wMinute, time.wSecond);
|
||||
syncStatus += stamp;
|
||||
}
|
||||
// Back off after errors (including bad credentials) instead of repeatedly
|
||||
// hitting userpass lockout. The explicit Sync button remains available.
|
||||
syncRetryAfter = syncResult.success ? 0 : GetTickCount64() + 15 * 60000;
|
||||
nextSync = syncResult.success ? GetTickCount64() + SyncIntervalMs : syncRetryAfter;
|
||||
}
|
||||
if (!syncRunning && GetTickCount64() >= nextSync)
|
||||
{
|
||||
nextSync = GetTickCount64() + SyncIntervalMs;
|
||||
if (loadSyncConfig(data).enabled) syncCommand(1);
|
||||
}
|
||||
}
|
||||
catch (const nlohmann::json::exception &) { syncStatus = L"Invalid local sync configuration or cached profile."; nextSync = GetTickCount64() + SyncIntervalMs; }
|
||||
catch (const std::exception &e) { syncStatus = wide(e.what()); nextSync = GetTickCount64() + SyncIntervalMs; }
|
||||
}
|
||||
@@ -70,9 +70,18 @@ AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path)
|
||||
}
|
||||
if (offsets.empty())
|
||||
throw std::runtime_error("Empty auto-correction dictionary");
|
||||
for (size_t i = 1; i < offsets.size(); ++i)
|
||||
if (word(i - 1) >= word(i))
|
||||
throw std::runtime_error("Dictionary must be uniquely sorted");
|
||||
auto entry = [&](uint32_t offset) { return std::string_view(bytes).substr(offset, bytes.find('\t', offset) - offset); };
|
||||
auto frequency = [&](uint32_t offset)
|
||||
{
|
||||
int rank{};
|
||||
auto start = offset + entry(offset).size() + 1;
|
||||
std::from_chars(bytes.data() + start, bytes.data() + bytes.size(), rank);
|
||||
return rank;
|
||||
};
|
||||
std::sort(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
|
||||
{ return entry(a) == entry(b) ? frequency(a) < frequency(b) : entry(a) < entry(b); });
|
||||
offsets.erase(std::unique(offsets.begin(), offsets.end(), [&](uint32_t a, uint32_t b)
|
||||
{ return entry(a) == entry(b); }), offsets.end());
|
||||
offsets.shrink_to_fit();
|
||||
bytes.shrink_to_fit();
|
||||
}
|
||||
@@ -143,7 +152,11 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
||||
suffix.insert(suffix.begin(), word.back());
|
||||
word.pop_back();
|
||||
}
|
||||
if (!safeAutoWord(word, settings.autoMinLength) || suffix.size() > 6 || rejected.contains(lower(word)))
|
||||
// Punctuation keys can be letters in the target layout (for example ',' -> 'б').
|
||||
// Validate letters and capitalization after conversion, not before it.
|
||||
if (settings.autoMinLength < 1 || settings.autoMinLength > 32 ||
|
||||
word.size() < (size_t)settings.autoMinLength || word.size() > 32 ||
|
||||
suffix.size() > 6 || rejected.contains(lower(word)))
|
||||
return {};
|
||||
for (auto &never : settings.autoNever)
|
||||
if (lower(never) == lower(word))
|
||||
@@ -151,12 +164,42 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
||||
auto sourceLanguage = layoutLanguage(layouts[source]);
|
||||
if (!dictionaries.contains(sourceLanguage))
|
||||
return {};
|
||||
const std::wstring alphabet = sourceLanguage == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
|
||||
sourceLanguage == L"uk" ? L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя" : L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя";
|
||||
auto small = lower(word);
|
||||
bool sameLanguage = safeAutoWord(word, settings.autoMinLength) &&
|
||||
small.find_first_not_of(alphabet) == small.npos &&
|
||||
std::find(settings.cycle.begin(), settings.cycle.end(), layouts[source].id) != settings.cycle.end();
|
||||
auto sameLayout = [&](std::wstring text)
|
||||
{
|
||||
if (word != small) text[0] = upper(text.substr(0, 1))[0];
|
||||
return AutoSuggestion{source, source, prefix + text + suffix};
|
||||
};
|
||||
if (settings.autoReplace && sameLanguage)
|
||||
{
|
||||
auto replacement = settings.autoReplacements.find(small);
|
||||
if (replacement != settings.autoReplacements.end() &&
|
||||
replacement->second.find_first_not_of(alphabet) == small.npos)
|
||||
return sameLayout(replacement->second);
|
||||
}
|
||||
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
|
||||
{
|
||||
auto found = settings.autoWords.find(language);
|
||||
return found != settings.autoWords.end() &&
|
||||
std::any_of(found->second.begin(), found->second.end(), [&](const std::wstring &known)
|
||||
{ return lower(known) == lower(candidate); });
|
||||
};
|
||||
// Valid words from either selected language are never changed.
|
||||
for (auto &[language, d] : dictionaries)
|
||||
if (d.rank(word))
|
||||
if (d.rank(word) || personal(language, word))
|
||||
return {};
|
||||
std::optional<AutoSuggestion> candidate;
|
||||
int matches = 0;
|
||||
std::optional<std::wstring> matchedText;
|
||||
auto priority = [&](int layout)
|
||||
{
|
||||
auto language = layoutLanguage(layouts[layout]);
|
||||
return std::find(settings.autoLanguages.begin(), settings.autoLanguages.end(), language) - settings.autoLanguages.begin();
|
||||
};
|
||||
for (int i = 0; i < (int)layouts.size(); ++i)
|
||||
{
|
||||
if (i == source || !layouts[i].convertible ||
|
||||
@@ -168,15 +211,64 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
||||
auto converted = convert(word, layouts[source], layouts[i], true);
|
||||
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
|
||||
continue;
|
||||
int rank = dictionary->second.rank(converted);
|
||||
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
|
||||
if (!rank)
|
||||
continue;
|
||||
if (++matches > 1)
|
||||
if (matchedText && *matchedText != converted)
|
||||
return {};
|
||||
if (settings.autoMaxRank == 0 || rank <= settings.autoMaxRank)
|
||||
matchedText = converted;
|
||||
if ((settings.autoMaxRank == 0 || rank <= settings.autoMaxRank) &&
|
||||
(!candidate || priority(i) < priority(candidate->target)))
|
||||
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
|
||||
}
|
||||
return candidate;
|
||||
if (candidate || matchedText || !settings.autoTypos || !sameLanguage || word.size() < 4)
|
||||
return candidate;
|
||||
// Generate only single edits. Stop at a second distinct dictionary hit,
|
||||
// including rare hits: frequency limits must never hide ambiguity.
|
||||
std::wstring found;
|
||||
int foundRank = 0;
|
||||
std::set<std::wstring> personalTargets;
|
||||
if (auto it = settings.autoWords.find(sourceLanguage); it != settings.autoWords.end())
|
||||
for (const auto &text : it->second) personalTargets.insert(lower(text));
|
||||
auto consider = [&](const std::wstring &text)
|
||||
{
|
||||
if (text == small || text == found || text.size() < 4 || text.size() > 32) return true;
|
||||
int rank = personalTargets.contains(text) ? 1 : dictionaries.at(sourceLanguage).rank(text);
|
||||
if (!rank) return true;
|
||||
if (!found.empty()) return false;
|
||||
found = text;
|
||||
foundRank = rank;
|
||||
return true;
|
||||
};
|
||||
for (size_t i = 0; i < small.size(); ++i)
|
||||
{
|
||||
auto text = small;
|
||||
text.erase(i, 1);
|
||||
if (!consider(text)) return {};
|
||||
if (i + 1 < small.size())
|
||||
{
|
||||
text = small;
|
||||
std::swap(text[i], text[i + 1]);
|
||||
if (!consider(text)) return {};
|
||||
}
|
||||
for (wchar_t c : alphabet)
|
||||
{
|
||||
text = small;
|
||||
text[i] = c;
|
||||
if (!consider(text)) return {};
|
||||
}
|
||||
}
|
||||
if (small.size() < 32)
|
||||
for (size_t i = 0; i <= small.size(); ++i)
|
||||
for (wchar_t c : alphabet)
|
||||
{
|
||||
auto text = small;
|
||||
text.insert(i, 1, c);
|
||||
if (!consider(text)) return {};
|
||||
}
|
||||
if (!found.empty() && (settings.autoMaxRank == 0 || foundRank <= settings.autoMaxRank))
|
||||
return sameLayout(found);
|
||||
return {};
|
||||
}
|
||||
int matchAutoInput(const Run &run, const std::wstring &text, const std::vector<Layout> &layouts,
|
||||
const Layout &physical, const Settings &settings)
|
||||
|
||||
@@ -57,6 +57,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
||||
try
|
||||
{
|
||||
if (wp == 21) showSnippets(*app);
|
||||
else if (wp == 22) showLogs(*app);
|
||||
else showSettings(*app);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
@@ -69,6 +70,7 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (app->settingsWindow) DestroyWindow(app->settingsWindow);
|
||||
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
|
||||
if (app->logWindow) DestroyWindow(app->logWindow);
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
}
|
||||
@@ -80,12 +82,14 @@ LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
||||
App::App(HINSTANCE h, bool, const std::filesystem::path &) : instance(h)
|
||||
{
|
||||
current = this;
|
||||
data = dataDirectory();
|
||||
layouts = readLayouts();
|
||||
}
|
||||
App::~App()
|
||||
{
|
||||
if (settingsWindow) DestroyWindow(settingsWindow);
|
||||
if (snippetsWindow) DestroyWindow(snippetsWindow);
|
||||
if (logWindow) DestroyWindow(logWindow);
|
||||
if (window) DestroyWindow(window);
|
||||
current = nullptr;
|
||||
}
|
||||
@@ -107,6 +111,13 @@ void App::reloadLayouts()
|
||||
settings = decodeSettings(bytes, layouts);
|
||||
}
|
||||
void App::clearHistory() { request(*this, ConfigClearHistory); }
|
||||
Bytes App::syncConflictData() { return request(*this, ConfigSyncConflicts); }
|
||||
std::wstring App::syncCommand(int action, const std::vector<SyncChoice> &choices)
|
||||
{
|
||||
auto result = action == 4 ? request(*this, ConfigSyncResolve, encodeSyncChoices(choices)) :
|
||||
request(*this, ConfigSync, Bytes{static_cast<unsigned char>(action)});
|
||||
return wide(std::string(result.begin(), result.end()));
|
||||
}
|
||||
void App::setHotkeyCapture(HWND field)
|
||||
{
|
||||
hotkeyCapture = field;
|
||||
@@ -120,9 +131,9 @@ void App::setHotkeyCapture(HWND field)
|
||||
if (field) MessageBoxW(settingsWindow, wide(e.what()).c_str(), L"Shortcut capture unavailable", MB_OK | MB_ICONWARNING);
|
||||
}
|
||||
}
|
||||
void showLogs(App &app) { request(app, ConfigLogs); }
|
||||
void App::clearLog(bool previous) { request(*this, ConfigClearLog, Bytes{static_cast<unsigned char>(previous)}); }
|
||||
|
||||
int runConfigurator(HINSTANCE instance, bool snippets)
|
||||
int runConfigurator(HINSTANCE instance, bool snippets, bool logs)
|
||||
{
|
||||
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
|
||||
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock.");
|
||||
@@ -140,7 +151,7 @@ int runConfigurator(HINSTANCE instance, bool snippets)
|
||||
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);
|
||||
PostMessageW(window, WM_APP + 20, logs ? 22 : snippets ? 21 : 20, 0);
|
||||
return 0;
|
||||
}
|
||||
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
||||
@@ -169,17 +180,25 @@ int runConfigurator(HINSTANCE instance, bool snippets)
|
||||
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);
|
||||
setLoggingEnabled(app.settings.writeLog);
|
||||
if (logs) showLogs(app); else if (snippets) showSnippets(app); else showSettings(app);
|
||||
MSG message;
|
||||
while ((app.settingsWindow || app.snippetsWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
||||
while ((app.settingsWindow || app.snippetsWindow || app.logWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
||||
{
|
||||
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
|
||||
{
|
||||
HWND popup = GetLastActivePopup(app.settingsWindow);
|
||||
while (!IsWindowEnabled(popup))
|
||||
{
|
||||
HWND next = GetLastActivePopup(popup);
|
||||
if (next == popup) break;
|
||||
popup = next;
|
||||
}
|
||||
if (popup != app.settingsWindow && IsDialogMessageW(popup, &message)) continue;
|
||||
}
|
||||
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
|
||||
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
|
||||
if (app.logWindow && IsDialogMessageW(app.logWindow, &message)) continue;
|
||||
TranslateMessage(&message); DispatchMessageW(&message);
|
||||
}
|
||||
return 0;
|
||||
@@ -193,8 +212,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
||||
{
|
||||
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
|
||||
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
|
||||
bool logs = count > 1 && std::wstring(args[1]) == L"--logs";
|
||||
LocalFree(args);
|
||||
result = ks::runConfigurator(instance, snippets);
|
||||
result = ks::runConfigurator(instance, snippets, logs);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
|
||||
@@ -14,12 +14,12 @@ bool configurationPeer(HWND window, const wchar_t *filename)
|
||||
auto expected = std::filesystem::path(executable()).parent_path() / filename;
|
||||
return ok && _wcsicmp(path, expected.c_str()) == 0;
|
||||
}
|
||||
void launchConfiguration(App &app, bool snippets)
|
||||
void launchConfiguration(App &app, bool snippets, bool logs)
|
||||
{
|
||||
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
|
||||
if (!std::filesystem::exists(path))
|
||||
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package.");
|
||||
auto command = L"\"" + path.wstring() + L"\"" + (snippets ? L" --snippets" : L"");
|
||||
auto command = L"\"" + path.wstring() + L"\"" + (logs ? L" --logs" : snippets ? L" --snippets" : L"");
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
||||
@@ -40,6 +40,25 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
|
||||
{
|
||||
switch (message.dwData)
|
||||
{
|
||||
case ConfigSyncConflicts:
|
||||
reply = app.syncConflictData();
|
||||
break;
|
||||
case ConfigSyncResolve:
|
||||
{
|
||||
if (!message.cbData) throw std::runtime_error("Empty conflict choices.");
|
||||
auto first = static_cast<const unsigned char *>(message.lpData);
|
||||
auto status = utf8(app.syncCommand(4, decodeSyncChoices(Bytes(first, first + message.cbData))));
|
||||
reply.assign(status.begin(), status.end());
|
||||
break;
|
||||
}
|
||||
case ConfigSync:
|
||||
{
|
||||
if (message.cbData != 1 || *static_cast<const unsigned char *>(message.lpData) > 3)
|
||||
throw std::runtime_error("Invalid sync command.");
|
||||
auto status = utf8(app.syncCommand(*static_cast<const unsigned char *>(message.lpData)));
|
||||
reply.assign(status.begin(), status.end());
|
||||
break;
|
||||
}
|
||||
case ConfigSave:
|
||||
{
|
||||
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
|
||||
@@ -80,6 +99,11 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
|
||||
case ConfigLogs:
|
||||
PostMessageW(app.window, WM_HOTKEY, 22, 0);
|
||||
break;
|
||||
case ConfigClearLog:
|
||||
if (message.cbData != 1 || *static_cast<const unsigned char *>(message.lpData) > 1)
|
||||
throw std::runtime_error("Invalid log selection.");
|
||||
app.clearLog(*static_cast<const unsigned char *>(message.lpData) != 0);
|
||||
break;
|
||||
default: return FALSE;
|
||||
}
|
||||
ok = true;
|
||||
|
||||
@@ -5,9 +5,11 @@ 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;
|
||||
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7,
|
||||
ConfigSync = ConfigRead + 8, ConfigSyncConflicts = ConfigRead + 9,
|
||||
ConfigSyncResolve = ConfigRead + 10, ConfigEnd = ConfigRead + 11;
|
||||
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
||||
bool configurationPeer(HWND, const wchar_t *filename);
|
||||
void launchConfiguration(App &, bool snippets = false);
|
||||
void launchConfiguration(App &, bool snippets = false, bool logs = false);
|
||||
LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
|
||||
}
|
||||
|
||||
@@ -148,6 +148,26 @@ std::wstring convertTo(const std::wstring &text, const std::vector<Layout> &layo
|
||||
}
|
||||
return best < 0 ? text : convert(text, layouts[best], to);
|
||||
}
|
||||
int nextConversionLayout(const std::wstring &text, const std::vector<Layout> &layouts,
|
||||
const std::vector<int> &cycle, int hint)
|
||||
{
|
||||
if (cycle.empty())
|
||||
return -1;
|
||||
int source = detect(text, layouts, hint);
|
||||
if (source < 0)
|
||||
source = hint;
|
||||
auto it = std::find(cycle.begin(), cycle.end(), source);
|
||||
size_t start = it == cycle.end() ? 0 : (size_t(it - cycle.begin()) + 1) % cycle.size();
|
||||
// Compare every candidate with the original input, never an intermediate conversion.
|
||||
// Bound the search so digits, whitespace and identical layouts cannot loop forever.
|
||||
for (size_t offset = 0; offset < cycle.size(); ++offset)
|
||||
{
|
||||
int target = cycle[(start + offset) % cycle.size()];
|
||||
if (target != source && convertTo(text, layouts, target, hint) != text)
|
||||
return target;
|
||||
}
|
||||
return cycle[start];
|
||||
}
|
||||
std::wstring cycleCase(const std::wstring &text)
|
||||
{
|
||||
if (text == upper(text))
|
||||
@@ -261,7 +281,17 @@ Template parseTemplate(const std::wstring &text)
|
||||
size_t cursor = std::wstring::npos;
|
||||
for (size_t i = 0; i < text.size();)
|
||||
{
|
||||
if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0)
|
||||
if (text.compare(i, 9, L"{{enter}}") == 0)
|
||||
{
|
||||
t.text += L"{enter}";
|
||||
i += 9;
|
||||
}
|
||||
else if (text.compare(i, 7, L"{enter}") == 0)
|
||||
{
|
||||
++t.enters;
|
||||
i += 7;
|
||||
}
|
||||
else if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0)
|
||||
{
|
||||
t.text += L"{cursor}";
|
||||
i += 10;
|
||||
@@ -449,6 +479,14 @@ void Run::backspace(HWND w, ULONGLONG now)
|
||||
keys.pop_back();
|
||||
at = now;
|
||||
}
|
||||
int Run::matchSuffix(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
||||
{
|
||||
if (text.empty() || text.size() >= keys.size() || overflow)
|
||||
return -1;
|
||||
Run tail = *this;
|
||||
tail.keys = keys.substr(keys.size() - text.size());
|
||||
return tail.match(text, layouts, physical);
|
||||
}
|
||||
int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
|
||||
{
|
||||
if (text.size() != keys.size() || overflow)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -23,16 +24,22 @@ std::wstring upper(std::wstring text);
|
||||
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
|
||||
int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1);
|
||||
std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint);
|
||||
int nextConversionLayout(const std::wstring &, const std::vector<Layout> &,
|
||||
const std::vector<int> &cycle, int hint);
|
||||
std::wstring cycleCase(const std::wstring &);
|
||||
std::wstring transliterate(const std::wstring &);
|
||||
struct Snippet
|
||||
{
|
||||
std::wstring trigger, text;
|
||||
std::wstring id;
|
||||
};
|
||||
std::wstring snippetId(const Snippet &);
|
||||
std::wstring newSnippetId();
|
||||
struct Template
|
||||
{
|
||||
std::wstring text, suffix;
|
||||
int cursors = 0;
|
||||
int enters = 0;
|
||||
};
|
||||
Template parseTemplate(const std::wstring &);
|
||||
struct Hotkey
|
||||
@@ -47,22 +54,31 @@ struct Settings
|
||||
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
||||
bool elevatedAutostart = false;
|
||||
bool autoCorrect = false;
|
||||
bool autoReplace = false, autoTypos = false;
|
||||
std::map<std::wstring, std::wstring> autoReplacements;
|
||||
bool writeLog = true;
|
||||
bool screenshotAutoOpenEditor = false;
|
||||
bool useTesseract = false;
|
||||
std::wstring tesseractPath;
|
||||
std::wstring tesseractLanguages = L"ukr+eng+rus";
|
||||
int autoMaxRank = 0;
|
||||
int autoMinLength = 3;
|
||||
std::wstring autoPrefixChars = L"([{«“‘\"";
|
||||
std::wstring autoSuffixChars = L")]}»”’\".,!?:;";
|
||||
std::vector<std::wstring> autoLanguages{L"en", L"uk"};
|
||||
std::vector<std::wstring> autoNever;
|
||||
std::map<std::wstring, std::vector<std::wstring>> autoWords;
|
||||
std::vector<std::wstring> autoExcluded{
|
||||
L"Code", L"rider64", L"devenv", L"WindowsTerminal", L"cmd", L"powershell", L"pwsh",
|
||||
L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||
int maxItems = 500, maxMiB = 200, maxImageMiB = 20;
|
||||
std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C",
|
||||
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Ctrl+Shift+V";
|
||||
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Win+V",
|
||||
screenshot = L"Win+Shift+S";
|
||||
std::vector<std::wstring> cycle, direct, directIds;
|
||||
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||
std::vector<Snippet> snippets;
|
||||
std::vector<std::wstring> snippetTombstones;
|
||||
};
|
||||
// Consume repeats and key-up for intercepted chords, even if settings change.
|
||||
struct HotkeyHook
|
||||
@@ -84,5 +100,6 @@ struct Run
|
||||
void record(wchar_t, HWND, HKL, ULONGLONG);
|
||||
void backspace(HWND, ULONGLONG);
|
||||
int match(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
|
||||
int matchSuffix(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
|
||||
};
|
||||
} // namespace ks
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#pragma once
|
||||
#include "../vendor/json.hpp"
|
||||
#include "platform.h"
|
||||
#include <sstream>
|
||||
|
||||
namespace ks
|
||||
{
|
||||
using DiagnosticJson = nlohmann::json;
|
||||
struct OperationLogRow
|
||||
{
|
||||
std::wstring time, process, command, source, target, outcome, details;
|
||||
bool problem = true;
|
||||
};
|
||||
inline std::vector<OperationLogRow> parseOperationLog(const std::wstring &contents)
|
||||
{
|
||||
std::vector<OperationLogRow> rows;
|
||||
std::wistringstream stream(contents);
|
||||
std::wstring line;
|
||||
OperationLogRow *legacy = nullptr;
|
||||
while (std::getline(stream, line))
|
||||
{
|
||||
if (!line.empty() && line.back() == L'\r')
|
||||
line.pop_back();
|
||||
if (line.empty())
|
||||
continue;
|
||||
auto marker = line.find(L" Operation {");
|
||||
if (marker != line.npos)
|
||||
{
|
||||
try
|
||||
{
|
||||
auto j = DiagnosticJson::parse(utf8(line.substr(marker + 11)));
|
||||
auto field = [&](const char *key) { return wide(j.value(key, std::string{})); };
|
||||
OperationLogRow row{line.substr(0, marker), field("process"), field("command"),
|
||||
field("source"), field("target"), field("outcome")};
|
||||
row.problem = j.value("problem", true);
|
||||
row.details =
|
||||
L"Operation: " + field("attempt") + L"\r\nResult: " + row.outcome +
|
||||
L"\r\nVerification: " + field("verification") + L"\r\nElapsed: " +
|
||||
std::to_wstring(j.value("elapsedMs", 0ULL)) + L" ms\r\n\r\nOriginal:\r\n" +
|
||||
field("original") + L"\r\n\r\nExpected selection:\r\n" + field("selectionExpected") +
|
||||
L"\r\n\r\nExpected result / verification target:\r\n" + field("expected") +
|
||||
L"\r\n\r\nRead back:\r\n" + field("actual") + L"\r\n\r\nForeground unchanged: " +
|
||||
(j.value("foregroundSame", false) ? L"yes" : L"no") + L"\r\nInput context unchanged: " +
|
||||
(j.value("inputSame", false) ? L"yes" : L"no") + L"\r\nText truncated: " +
|
||||
(j.value("textTruncated", false) ? L"yes (4096 UTF-16 units per field)" : L"no") +
|
||||
L"\r\n\r\nContext and stages:\r\n" + field("context");
|
||||
for (size_t at = 0; (at = row.details.find(L'\n', at)) != row.details.npos; ++at)
|
||||
if (at == 0 || row.details[at - 1] != L'\r')
|
||||
row.details.insert(at++, 1, L'\r');
|
||||
rows.push_back(std::move(row));
|
||||
legacy = nullptr;
|
||||
continue;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
} // Preserve malformed and old records for diagnosis.
|
||||
}
|
||||
if (line.front() != L' ' || !legacy)
|
||||
{
|
||||
rows.push_back({L"", L"", L"Legacy / event", L"", L"", L"Unstructured event", line});
|
||||
legacy = &rows.back();
|
||||
}
|
||||
else
|
||||
legacy->details += L"\r\n" + line;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
inline std::wstring conversionReadbackOutcome(const std::wstring &actual, const std::wstring &original,
|
||||
const std::wstring &expected)
|
||||
{
|
||||
if (actual == expected)
|
||||
return L"Paste verified";
|
||||
if (actual == original)
|
||||
return L"Original text remained";
|
||||
return L"Readback differs";
|
||||
}
|
||||
} // namespace ks
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
namespace ks {
|
||||
// Keep the visible working set even in unusually tall windows. Never evict a
|
||||
// visible preview to admit another visible preview: that creates a paint loop.
|
||||
template<class T>
|
||||
void trimHistoryPreviews(std::map<std::wstring,T> &cache,
|
||||
const std::set<std::wstring> &visible, size_t limit=32)
|
||||
{
|
||||
for(auto it=cache.begin(); cache.size()>limit && it!=cache.end();)
|
||||
if(!visible.contains(it->first)) it=cache.erase(it);
|
||||
else ++it;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -186,6 +186,43 @@ Pixels decodeImage(const Clip &clip, UINT maxWidth, UINT maxHeight)
|
||||
check(converter->CopyPixels(nullptr, pixels.width * 4, (UINT)pixels.bgra.size(), pixels.bgra.data()));
|
||||
return pixels;
|
||||
}
|
||||
Bytes encodePng(const Pixels &p)
|
||||
{
|
||||
if (!p.width || !p.height || uint64_t(p.width) * p.height * 4 != p.bgra.size() ||
|
||||
p.bgra.size() > UINT_MAX || p.width > UINT_MAX / 4)
|
||||
throw std::runtime_error("Invalid PNG image dimensions");
|
||||
Com<IWICImagingFactory> factory;
|
||||
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
|
||||
IID_PPV_ARGS(factory.out())));
|
||||
Com<IStream> stream;
|
||||
check(CreateStreamOnHGlobal(nullptr, TRUE, stream.out()));
|
||||
Com<IWICBitmapEncoder> encoder;
|
||||
check(factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.out()));
|
||||
check(encoder->Initialize(stream.p, WICBitmapEncoderNoCache));
|
||||
Com<IWICBitmapFrameEncode> frame;
|
||||
check(encoder->CreateNewFrame(frame.out(), nullptr));
|
||||
check(frame->Initialize(nullptr));
|
||||
check(frame->SetSize(p.width, p.height));
|
||||
auto format = GUID_WICPixelFormat32bppBGRA;
|
||||
check(frame->SetPixelFormat(&format));
|
||||
if (format != GUID_WICPixelFormat32bppBGRA)
|
||||
throw std::runtime_error("Unsupported PNG pixel format");
|
||||
check(frame->WritePixels(p.height, p.width * 4, (UINT)p.bgra.size(),
|
||||
const_cast<BYTE *>(p.bgra.data())));
|
||||
check(frame->Commit());
|
||||
check(encoder->Commit());
|
||||
STATSTG stat{};
|
||||
check(stream->Stat(&stat, STATFLAG_NONAME));
|
||||
if (stat.cbSize.QuadPart > UINT_MAX)
|
||||
throw std::runtime_error("PNG image is too large");
|
||||
check(stream->Seek({}, STREAM_SEEK_SET, nullptr));
|
||||
Bytes bytes((size_t)stat.cbSize.QuadPart);
|
||||
ULONG read{};
|
||||
check(stream->Read(bytes.data(), (ULONG)bytes.size(), &read));
|
||||
if (read != bytes.size())
|
||||
throw std::runtime_error("Cannot read encoded PNG image");
|
||||
return bytes;
|
||||
}
|
||||
Format imageDib(const Pixels &p)
|
||||
{
|
||||
BITMAPINFOHEADER h{};
|
||||
@@ -271,4 +308,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
|
||||
|
||||