Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c326db7dc5 | |||
| 8b5f632ad7 | |||
| 5c786e12a9 | |||
| 107d2f8852 | |||
| 95c7199027 | |||
| 8fedb54dfe | |||
| bbf20e8270 | |||
| b8bb14a985 | |||
| d28e28c03c | |||
| 6b180798f5 | |||
| 21a09e33be | |||
| 3dbd7317f1 | |||
| 510d0ed211 | |||
| dd9629f8fd | |||
| ccaa8dad3f | |||
| d1eabfca88 | |||
| dc615d0223 |
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.3.0 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.6.11 LANGUAGES CXX RC)
|
||||||
if(NOT WIN32)
|
if(NOT WIN32)
|
||||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||||
endif()
|
endif()
|
||||||
@@ -8,8 +8,8 @@ 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)
|
||||||
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
||||||
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
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)
|
target_link_libraries(kscore PUBLIC user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||||
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
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_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
|
||||||
target_link_libraries(KSwitcher PRIVATE kscore)
|
target_link_libraries(KSwitcher PRIVATE kscore)
|
||||||
add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||||
@@ -18,20 +18,36 @@ target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
|
|||||||
target_link_libraries(KSwitcherConfig PRIVATE kscore)
|
target_link_libraries(KSwitcherConfig PRIVATE kscore)
|
||||||
add_executable(KSwitcherTests tests/tests.cpp)
|
add_executable(KSwitcherTests tests/tests.cpp)
|
||||||
target_link_libraries(KSwitcherTests 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)
|
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)
|
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)
|
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
|
||||||
if(MSVC)
|
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 KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
target_compile_options(kscore PRIVATE /W4 /utf-8)
|
target_compile_options(kscore PRIVATE /W4 /utf-8)
|
||||||
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
|
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
|
||||||
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
|
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
|
||||||
|
target_compile_options(KSwitcherScreenshot PRIVATE /W4 /utf-8)
|
||||||
else()
|
else()
|
||||||
target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
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(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(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
target_link_options(KSwitcherIntegration 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(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||||
@@ -40,9 +56,10 @@ endif()
|
|||||||
enable_testing()
|
enable_testing()
|
||||||
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
||||||
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
||||||
|
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
|
||||||
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
|
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}")
|
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 .)
|
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")
|
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,
|
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
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
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.
|
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.
|
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
|
## Run
|
||||||
|
|
||||||
@@ -16,12 +20,25 @@ Settings and the snippet editor run on demand in **KSwitcher.Config.exe**, besid
|
|||||||
Repeated launches activate the existing configurator; closing its last window exits that process.
|
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
|
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
|
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.
|
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
|
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 configurator to load the latest settings.
|
||||||
The first upgrade from v0.2.3 or earlier to this two-executable build requires manual extraction:
|
Updates from v0.3.x–v0.4.1 first install the compatible **v0.4.2 bridge** through
|
||||||
the older updater rejects additional executable files. Exit KSwitcher, replace both executables,
|
Update in the tray menu. Select Update again to install the full current release;
|
||||||
and retain `data/` and customized dictionaries. Subsequent updates support both executables.
|
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**
|
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
|
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;
|
the elevated task requests UAC approval if the app is not already elevated. No password is stored;
|
||||||
@@ -53,12 +70,129 @@ keys cannot trigger recursive commands or overwrite the recorded typing run. Oth
|
|||||||
is accepted, including key-up events and VK-only keyboard events. Unicode packets do not provide
|
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.
|
physical key positions and therefore reset recent-word tracking; selection conversion still works.
|
||||||
|
|
||||||
|
### Screen capture and annotations
|
||||||
|
|
||||||
|
Press **Win+Shift+S**, choose **Capture screen region…** in the tray menu, or run
|
||||||
|
`KSwitcher.exe --capture`. Drag a rectangle on the dimmed, frozen desktop; the selected area stays
|
||||||
|
at normal brightness. Escape cancels. Selection and annotation frames are buffered to avoid flicker.
|
||||||
|
The selected area is copied immediately, the fullscreen selection is hidden, and a Windows notification
|
||||||
|
is sent. Click the notification to open the editor. Enable **Open editor after screen capture** in settings
|
||||||
|
to open it automatically; this option is off by default. An immediate notification error still opens the editor.
|
||||||
|
Existing saved preferences are preserved. Windows Do Not Disturb can send notifications directly to the
|
||||||
|
notification center without showing a banner.
|
||||||
|
The preference applies to each new capture, including while other editors are open.
|
||||||
|
KSwitcher also sends a notification with a thumbnail. Notification identity and activation are registered
|
||||||
|
at resident startup and refreshed before sending; registration/submission errors are written to the log.
|
||||||
|
Click the notification to reopen the original screenshot, including after the screenshot process has exited.
|
||||||
|
Originals in the local notification cache are DPAPI-encrypted. Thumbnail PNGs live in a separate
|
||||||
|
per-user cache under ProgramData, readable only by that user and SYSTEM, so the Windows notification
|
||||||
|
broker can load them even when the profile directory is inaccessible to it.
|
||||||
|
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
|
||||||
|
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
|
||||||
|
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
|
||||||
|
The editor starts with **Pencil / Freehand (F)** for drawing. Choose **Pointer (V)** to select objects, move them, and edit geometry, thickness and
|
||||||
|
other properties using handles and controls. Clicking empty space clears selection; dragging empty
|
||||||
|
space adds nothing. Choose a drawing tool to create objects, then return to **V** to edit them.
|
||||||
|
The cursor identifies the active tool: pointer, text caret, pencil, or a crosshair with the drawing
|
||||||
|
tool symbol. Move/resize/thickness/rotation handles override it with their action cursor. Custom
|
||||||
|
cursors scale with display DPI and use a contrasting outline for light and dark images.
|
||||||
|
To annotate: **N** places sequential numbered red markers with a click, and **A** draws
|
||||||
|
red arrows by dragging from the tail to the tip. With **Pointer (V)**, click an existing marker or arrow to select it;
|
||||||
|
drag its body to move it. Drag the square handle on a number's right edge to change its diameter
|
||||||
|
(the text scales with it), or either endpoint handle of an arrow to change its length and direction.
|
||||||
|
Select an arrow and drag the extra handle beside its midpoint perpendicular to the line to change
|
||||||
|
its thickness from 1 to 30 pixels. The arrowhead scales with the line. Minus/plus keys also work.
|
||||||
|
Alternatively, scroll the mouse wheel over an arrow to change its thickness, or over a number to
|
||||||
|
change its diameter. Wheel up increases the size; wheel down decreases it. These edits support Undo
|
||||||
|
and update linked arrows. **Ctrl+D** clones the selected object with a small offset. A cloned number
|
||||||
|
gets the smallest available positive number, just like a newly placed number: gaps left by deletion
|
||||||
|
are filled first, without renumbering existing objects. A cloned arrow keeps its style but starts without endpoint links.
|
||||||
|
Arrow and line endpoints can attach to numbered markers, rectangles, circles, ellipses, text blocks,
|
||||||
|
and other arrows or strokes. In Arrow or Straight line mode, hold **Ctrl** and drag from one object
|
||||||
|
to another to draw over them and automatically attach both endpoints. Without Ctrl, existing objects
|
||||||
|
can still be selected and edited as before. With Pointer, drag an existing endpoint onto a target to attach it. Attached endpoints follow movement and resizing; text attachments
|
||||||
|
also follow rotation. Arrow-to-arrow attachments retain their relative position along the target line.
|
||||||
|
Clicking a connector or adding/editing its caption preserves its links. Moving the whole connector
|
||||||
|
intentionally detaches its endpoints; moving one endpoint detaches only that end. Deleting a target
|
||||||
|
leaves connected endpoints at their last position. Self-links and circular dependency chains are rejected.
|
||||||
|
The compact toolbar uses [Lucide](https://lucide.dev) icons with hover tooltips showing each action
|
||||||
|
and its keyboard shortcut.
|
||||||
|
Drop either arrow endpoint onto a number to link it to the circle's edge. Linked endpoints follow
|
||||||
|
the number when it moves or changes diameter. Drag an endpoint away to unlink it; moving the whole
|
||||||
|
arrow unlinks both ends. In Arrow mode, hold **Ctrl** while dragging to start a new arrow directly
|
||||||
|
from a number. Deleting a number leaves its arrows in place and removes their links; Undo restores them.
|
||||||
|
**Delete** removes the selected object. **Ctrl+Z** (Undo) reverses additions, moves, resizes and deletions
|
||||||
|
(up to 100 actions). **Ctrl+Y** or **Ctrl+Shift+Z** (Redo) restores undone edits; a new edit clears the
|
||||||
|
redo history. Undo and Redo buttons are disabled when unavailable. Enabled toolbar buttons use a
|
||||||
|
hand cursor. Selection handles are visible only in the editor, never in the copied image.
|
||||||
|
The **palette** changes the selected object's color: arrow, numbered marker, or highlight.
|
||||||
|
It also sets the color for newly drawn objects of every type. With nothing selected, only the
|
||||||
|
color for new objects changes. Number text switches between dark and white for readability.
|
||||||
|
**Highlight (H)** paints a translucent freehand marker stroke. Dark text remains readable beneath
|
||||||
|
the highlight. Drag a selected stroke
|
||||||
|
to move it, scroll over it to change width (4–100 pixels), or use Delete / Ctrl+D / Undo / Redo.
|
||||||
|
Hold Ctrl while drawing to paint a new stroke over an existing object. Highlights render beneath
|
||||||
|
arrows and numbered markers. The palette's color indicator reflects the selected object, or the
|
||||||
|
color for new objects when nothing is selected.
|
||||||
|
The editor status bar shows contextual tool, selection, rotation and text-editing hints.
|
||||||
|
Hover a toolbar button to see its action and shortcut; the title stays **KSwitcher — Screenshot**.
|
||||||
|
**Text (T)** adds text directly on the image. Enter inserts a line break; Ctrl+Enter or a click
|
||||||
|
outside commits the edit, and Escape cancels it. Double-click text or press F2 to edit it again.
|
||||||
|
The **Aa** icon opens the standard font dialog for family, size (6–200 pt), bold and italic.
|
||||||
|
The mouse wheel over text adjusts its font size. Text supports color, dragging, Ctrl+D,
|
||||||
|
Delete and Undo/Redo. Arrow keys move the selected object by one source-image pixel;
|
||||||
|
selecting an arrow caption moves only that caption. Repositioning a caption preserves its
|
||||||
|
automatic angle; only dragging the rotation handle switches to a fixed angle.
|
||||||
|
New standalone text starts horizontally, even after editing an angled arrow caption. Font styling
|
||||||
|
is reused; rotation belongs to each individual object.
|
||||||
|
Text has a rotation handle above its selection. Drag to rotate; hold Shift to snap to 15-degree steps.
|
||||||
|
Double-click an arrow, press F2, or use the label icon to add/edit its caption. Captions follow
|
||||||
|
the arrow when moved or resized; drag the caption to adjust its offset. By default, the caption
|
||||||
|
angle follows the line (or middle curve segment) and stays upright. Rotating its handle manually
|
||||||
|
fixes its angle; Shift snaps to 15-degree steps. Undo restores automatic mode. The Aa icon changes
|
||||||
|
the selected caption font, and the mouse wheel over it changes its size. Captions and rotation
|
||||||
|
are included in clipboard export, cloning and Undo/Redo.
|
||||||
|
**Straight line (L)** creates a line without arrowheads. **Rectangle (R)**, **Circle (O)** and
|
||||||
|
**Ellipse (E)** create transparent shapes with colored outlines. Drag the two corner handles to
|
||||||
|
resize; circles keep equal width and height. Move shapes by their outlines, adjust thickness with
|
||||||
|
the mouse wheel or side handle, recolor, clone with Ctrl+D, and undo/redo as with other objects.
|
||||||
|
Hold Ctrl to start drawing over an existing object. Line style applies to outlines; Ends is disabled
|
||||||
|
for shapes.
|
||||||
|
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
|
||||||
|
move it, change its color or thickness, clone it, or drag its endpoints. Hold Ctrl to start a new
|
||||||
|
stroke over an existing object. The **Line style** selector supports Solid, Dotted, Dashed and
|
||||||
|
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
|
||||||
|
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
|
||||||
|
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
|
||||||
|
The window close button asks for confirmation before discarding an uncopied screenshot.
|
||||||
|
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy keeps the editor open and publishes the image
|
||||||
|
at its original pixel size, ready to paste. The preview fits the window without changing export size.
|
||||||
|
The shortcut is configurable in Settings under **Screen capture**.
|
||||||
|
Each capture opens an independent editor available through Alt+Tab; use the shortcut again to take another screenshot while
|
||||||
|
keeping earlier editors and their annotations open.
|
||||||
|
Captures use ordinary clipboard history settings and limits. Protected video and secure desktops cannot be captured.
|
||||||
|
|
||||||
### Updates
|
### Updates
|
||||||
|
|
||||||
Select **Update** in the tray menu to check the stable Releases of
|
Select **Update** in the tray menu to check the stable Releases of
|
||||||
[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases).
|
[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases).
|
||||||
A startup check also reports an available version, but never installs it automatically.
|
A background check runs shortly after startup and every 6 hours while KSwitcher is running.
|
||||||
Confirm the offered version to download and install it. The updater uses the existing
|
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
|
`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
|
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;
|
before the application exits. If replacement or startup fails, it restores the previous files;
|
||||||
@@ -72,9 +206,12 @@ intact and is reported when Update is selected. Startup network failures remain
|
|||||||
The helper uses Windows' built-in Windows PowerShell 5.1; no additional runtime is installed.
|
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,
|
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
|
controls and fonts. Settings has a fixed form size, without manual resizing or maximization; other
|
||||||
changes, and provide scrollbars when their content does not fit. Tab navigation scrolls the focused
|
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.
|
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
|
## Implemented
|
||||||
|
|
||||||
@@ -99,16 +236,33 @@ field into view; Shift+wheel scrolls horizontally.
|
|||||||
stylesheet rules are not reproduced; preview text uses the list's colors for readability.
|
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
|
- Consecutive screenshot copies are compared by image content, ignoring changing OLE transport
|
||||||
metadata (including Snipping Tool's repeated clipboard publication).
|
metadata (including Snipping Tool's repeated clipboard publication).
|
||||||
- Windows OCR, entirely native and offline. Right-click an image in history and select
|
- New images are recognized automatically in a separate background worker using native, offline
|
||||||
**Copy text from image**. Requires a Windows OCR language installed for the user profile.
|
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.
|
- History exclusion flags and excluded processes, pause/resume, count/size limits.
|
||||||
- Per-user Windows DPAPI encryption for both history payloads and the preview index.
|
- 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.
|
- Autostart, keep-screen-awake, idle-only F15 activity signal, settings import/export.
|
||||||
- Single instance and `--history`, `--convert`, `--case`, `--transliterate`, `--settings`,
|
- Single instance and `--history`, `--convert`, `--case`, `--transliterate`, `--settings`,
|
||||||
`--snippets`, `--exit` commands. `--convert` cycles layouts.
|
`--snippets`, `--exit` commands. `--convert` cycles layouts.
|
||||||
|
|
||||||
Default shortcuts match [Typlo](https://github.com/bitcodepro/Typlo): Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
|
Default text 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).
|
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,
|
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.
|
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
|
||||||
@@ -197,14 +351,23 @@ 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
|
**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
|
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.
|
protect already valid input, and ambiguous alternatives remain unchanged regardless of this limit.
|
||||||
|
**My dictionary** provides separate English, Ukrainian and Russian fields for correct words missing
|
||||||
|
from the bundled dictionaries. Enter words separated by commas or new lines, then click OK and Save
|
||||||
|
in Settings. Case and duplicates are normalized automatically; no file editing or ranks are needed.
|
||||||
|
These entries are stored in settings and survive dictionary updates. They protect correct input and
|
||||||
|
are eligible as correction targets at any frequency limit. The language and target layout must still
|
||||||
|
be enabled. **Never correct these words** is a separate exception list and takes precedence.
|
||||||
**Open dictionary folder** opens the files beside the running executable. After editing, click OK
|
**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.
|
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
|
Use UTF-8, one lowercase word followed by a Tab and a positive rank per line. Entries can be appended;
|
||||||
unique. For a personal addition, rank 1 makes it eligible at every positive limit. Back up your edited
|
loading sorts them and merges duplicates using the lowest rank. Back up your edited
|
||||||
files before replacing dictionaries with a new distribution.
|
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
|
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.
|
dictionaries. For example, `ghbdsn ` can become `привіт `. Continuing to type before selection cancels
|
||||||
|
pending work. During the replacement itself, key presses and releases are queued and 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 exactly one alternative is a dictionary word.
|
It only corrects unknown words when exactly one alternative is a dictionary word.
|
||||||
**Minimum word length** defaults to 3 and can be set from 1 to 32 letters. Surrounding punctuation
|
**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.
|
does not count toward this minimum; the maximum word length is 32 letters.
|
||||||
@@ -273,3 +436,15 @@ 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`.
|
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.
|
Dictionary data has its own CC BY-SA 4.0 license as described above.
|
||||||
|
|
||||||
|
The screenshot process lifecycle can be checked interactively with `tests/screenshot_process.ps1`
|
||||||
|
against a staged build. It briefly opens real selection/editor windows without changing the clipboard.
|
||||||
|
|
||||||
|
### Release channel rule
|
||||||
|
|
||||||
|
Keep `v0.4.2` as the last legacy `vX.Y.Z` release. Publish full packages as
|
||||||
|
`release-vX.Y.Z` (stable, not prerelease), including `release-files.json`,
|
||||||
|
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt`. Never publish a manifest package under a
|
||||||
|
legacy version tag: clients that have not installed the bridge would select it.
|
||||||
|
Keep the compatibility bridge in the first 100 releases returned by Gitea while
|
||||||
|
supporting old clients; their immutable update code only reads that page.
|
||||||
|
|||||||
@@ -1,32 +1,27 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.3.0 — 2026-10-28
|
## v0.6.11 — 2026-10-29
|
||||||
|
|
||||||
- Move Settings and the snippet editor into the on-demand `KSwitcher.Config.exe` process.
|
## Changes
|
||||||
- Repeated launches activate the existing configurator. Closing its last window releases the process and its memory.
|
- Show an update progress window for manual checks and installation. Downloads display percentage and received/total size; verification, unpacking, installation and restart have distinct status messages.
|
||||||
- Apply settings immediately through validated IPC with the resident process; reject conflicting saves instead of overwriting newer settings.
|
- Keep the progress window running independently while KSwitcher exits and restarts. After the new application confirms startup, show the installed version and a Done button in the same window, without a separate completion notification.
|
||||||
- Keep shortcut capture in the resident hook, with no extra global hooks in the configurator.
|
- Show recovery and installation errors in the same window after the application exits. Background update checks remain quiet.
|
||||||
- Support packaging, validation and rollback of both executables. About now displays the actual application version.
|
|
||||||
|
|
||||||
Verified: 170 native checks, 51 input/layout checks, 28 updater/metadata checks and 21 configurator process checks.
|
The progress UI is provided by the currently installed updater. Updating from v0.6.10 to this release still uses the older interface; subsequent updates use the new window.
|
||||||
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.
|
|
||||||
|
|
||||||
**Upgrade from v0.2.3 or earlier: install this release manually once.** The old updater rejects the additional EXE.
|
## Validation
|
||||||
Exit KSwitcher, extract the entire ZIP into the installation folder, and retain `data/` and any customized dictionaries.
|
214 native regression checks, 466 input/UI checks, screenshot editor UI checks, isolated notification registration, 45 updater regression checks, and progress-window checks for download, installation, recovery and completion states. Network tests verify real download progress and release download/checksum validation. Startup acknowledgement tests prevent reporting success for ordinary startup or a missing updater handshake.
|
||||||
Keep `KSwitcher.exe`, `KSwitcher.Config.exe` and `dictionaries/` together. Launch `KSwitcher.exe`.
|
|
||||||
Subsequent updates support both executables through the tray Update menu.
|
|
||||||
|
|
||||||
Download **KSwitcher-win-x64.zip**. The archive contains both applications, offline dictionaries,
|
**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.
|
||||||
documentation and license notices. It does not contain personal settings or clipboard history.
|
For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||||
`SHA256SUMS.txt` contains the archive checksum.
|
|
||||||
|
|
||||||
This folder holds the latest compiled version; older versions remain in Git history and Releases.
|
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, Tesseract installation or language models.
|
||||||
To refresh the tracked package from source:
|
|
||||||
|
Build and package:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\build.ps1 -Stage -Test
|
.\build.ps1 -Stage -Test
|
||||||
.\package.ps1 -PublishRelease
|
.\package.ps1 -PublishRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
Commit the ZIP and checksum together with their source changes. Publish both files as assets of
|
Commit the archive, checksum and sources together. Publish both assets on a stable `release-vX.Y.Z` tag matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
||||||
one stable release tagged with the executable version, so the updater can verify it.
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
104c79b64bb30a2161e3ec2f5108a731da0d274e015d53d8835ea7e64f431ee1 KSwitcher-win-x64.zip
|
d12994e3038e3a10f76b6eb4dba4aca0fac4c4d5f3805ba16f132f890a941bcf KSwitcher-win-x64.zip
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
|
|||||||
Push-Location $root
|
Push-Location $root
|
||||||
try {
|
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')
|
$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')
|
$libs = @('-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','app','ui','main','configuration','config_main')) {
|
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
||||||
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
||||||
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
||||||
}
|
}
|
||||||
@@ -24,34 +24,54 @@ try {
|
|||||||
if ($LASTEXITCODE) { throw 'Configurator UI compilation failed' }
|
if ($LASTEXITCODE) { throw 'Configurator UI compilation failed' }
|
||||||
& windres --codepage=65001 resources/app.rc -O coff -o build/app-res.o
|
& windres --codepage=65001 resources/app.rc -O coff -o build/app-res.o
|
||||||
if ($LASTEXITCODE) { throw 'Resource compilation failed' }
|
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
|
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/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' }
|
if ($LASTEXITCODE) { throw 'Link failed' }
|
||||||
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
||||||
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
|
$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/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' }
|
if ($LASTEXITCODE) { throw 'Configurator link failed' }
|
||||||
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
|
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) {
|
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/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Test build failed' }
|
if ($LASTEXITCODE) { throw 'Test build failed' }
|
||||||
& .\build\KSwitcherTests.exe
|
& .\build\KSwitcherTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Tests failed' }
|
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/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' }
|
if ($LASTEXITCODE) { throw 'Input test build failed' }
|
||||||
& .\build\KSwitcherInputTests.exe
|
& .\build\KSwitcherInputTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
||||||
|
& $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
|
||||||
|
& .\build\KSwitcherScreenshotUITests.exe
|
||||||
|
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
|
||||||
|
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
|
||||||
|
try {
|
||||||
|
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
|
||||||
|
if ($LASTEXITCODE) { throw 'Screenshot registration test build failed' }
|
||||||
|
& $registrationTest
|
||||||
|
if ($LASTEXITCODE) { throw 'Screenshot registration tests failed' }
|
||||||
|
} finally { Remove-Item -LiteralPath $registrationTest -ErrorAction SilentlyContinue }
|
||||||
|
|
||||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
|
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
|
||||||
if ($LASTEXITCODE) { throw 'Updater tests failed' }
|
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) {
|
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/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' }
|
if ($LASTEXITCODE) { throw 'Integration test build failed' }
|
||||||
& .\build\KSwitcherIntegration.exe
|
& .\build\KSwitcherIntegration.exe
|
||||||
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
||||||
}
|
}
|
||||||
if ($Ocr) {
|
if ($Ocr) {
|
||||||
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/ocr.o @libs -static -s -o build/KSwitcherOcrTests.exe
|
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherOcrTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'OCR test build failed' }
|
if ($LASTEXITCODE) { throw 'OCR test build failed' }
|
||||||
& .\build\KSwitcherOcrTests.exe
|
& .\build\KSwitcherOcrTests.exe
|
||||||
if ($LASTEXITCODE) { throw 'OCR tests failed' }
|
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
|
hashes. The lists contain word forms from subtitles, not a complete spelling
|
||||||
or grammar dictionary. Recognition can miss names, jargon and uncommon forms.
|
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
|
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.
|
(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.
|
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.
|
Back up edited files before replacing dictionaries with a new distribution.
|
||||||
|
|||||||
@@ -9,9 +9,12 @@
|
|||||||
"uk": {
|
"uk": {
|
||||||
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
|
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/uk/uk_50k.txt",
|
||||||
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
|
"sourceSha256": "9548b79a608d33f39c5546037a69117d679730b1fda08dd0ce67c043dbf3335a",
|
||||||
"words": 42549,
|
"words": 42550,
|
||||||
"bytes": 898210,
|
"bytes": 898227,
|
||||||
"sha256": "b4104898dcccbc1331ec01e9cd690e30285fe58481460e47438e973964d09802"
|
"sha256": "c94605e6be676d2d64f89a3bc16f92b9128f9977d226a97b103cf22a3e6835e3",
|
||||||
|
"supplementalWords": {
|
||||||
|
"\u0440\u0435\u043b\u0456\u0437": 50001
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"ru": {
|
"ru": {
|
||||||
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/ru/ru_50k.txt",
|
"source": "https://raw.githubusercontent.com/hermitdave/FrequencyWords/525f9b560de45753a5ea01069454e72e9aa541c6/content/2016/ru/ru_50k.txt",
|
||||||
|
|||||||
@@ -31207,6 +31207,7 @@
|
|||||||
релігію 26138
|
релігію 26138
|
||||||
релігія 22202
|
релігія 22202
|
||||||
релігії 23427
|
релігії 23427
|
||||||
|
реліз 50001
|
||||||
рембо 28603
|
рембо 28603
|
||||||
ремень 23872
|
ремень 23872
|
||||||
ремені 28385
|
ремені 28385
|
||||||
|
|||||||
@@ -5,12 +5,22 @@ $destination = Join-Path $root 'dist\KSwitcher'
|
|||||||
New-Item -ItemType Directory -Force $destination | Out-Null
|
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.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.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 'LICENSE') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -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 'README.md') -Destination $destination
|
||||||
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
|
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
|
||||||
# Enumerate the distributable files explicitly; do not include a user's data directory.
|
# 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'
|
$archive = Join-Path $root 'dist\KSwitcher-win-x64.zip'
|
||||||
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
|
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
|
||||||
Get-FileHash -LiteralPath $archive -Algorithm SHA256
|
Get-FileHash -LiteralPath $archive -Algorithm SHA256
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
#include "resource.h"
|
#include "resource.h"
|
||||||
IDI_KSWITCHER ICON "kswitcher.ico"
|
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"
|
1 RT_MANIFEST "app.manifest"
|
||||||
201 RCDATA "update.ps1"
|
201 RCDATA "update.ps1"
|
||||||
1 VERSIONINFO
|
1 VERSIONINFO
|
||||||
FILEVERSION 0,3,0,0
|
FILEVERSION 0,6,11,0
|
||||||
PRODUCTVERSION 0,3,0,0
|
PRODUCTVERSION 0,6,11,0
|
||||||
FILEOS VOS_NT_WINDOWS32
|
FILEOS VOS_NT_WINDOWS32
|
||||||
FILETYPE VFT_APP
|
FILETYPE VFT_APP
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -16,7 +20,7 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||||
VALUE "ProductName", "KSwitcher"
|
VALUE "ProductName", "KSwitcher"
|
||||||
VALUE "FileVersion", "0.3.0"
|
VALUE "FileVersion", "0.6.11"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
@@ -24,3 +28,54 @@ BEGIN
|
|||||||
VALUE "Translation", 0x409, 1200
|
VALUE "Translation", 0x409, 1200
|
||||||
END
|
END
|
||||||
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
|
#pragma once
|
||||||
#define IDI_KSWITCHER 101
|
#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]$CurrentVersion, [string]$ExpectedVersion,
|
||||||
[string]$InstallDirectory, [string]$WorkDirectory,
|
[string]$InstallDirectory, [string]$WorkDirectory,
|
||||||
[int]$ParentProcessId, [string]$ReadyEvent,
|
[int]$ParentProcessId, [string]$ReadyEvent,
|
||||||
|
[switch]$ShowProgress,
|
||||||
[switch]$Library
|
[switch]$Library
|
||||||
)
|
)
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
Set-StrictMode -Version Latest
|
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) {
|
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 }
|
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 '^v','') } catch { return $null }
|
try { return [version]($Tag -replace '^(?:release-)?v','') } catch { return $null }
|
||||||
}
|
}
|
||||||
function Assert-ReleaseUrl([string]$Url) {
|
function Assert-ReleaseUrl([string]$Url) {
|
||||||
$uri = [uri]$Url
|
$uri = [uri]$Url
|
||||||
@@ -20,7 +202,7 @@ function Assert-ReleaseUrl([string]$Url) {
|
|||||||
throw 'Release asset URL is outside the KSwitcher repository.'
|
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
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
$request = [Net.HttpWebRequest]::Create($Url)
|
$request = [Net.HttpWebRequest]::Create($Url)
|
||||||
$request.AllowAutoRedirect = $false
|
$request.AllowAutoRedirect = $false
|
||||||
@@ -36,11 +218,20 @@ function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
|
|||||||
$buffer = New-Object byte[] 65536
|
$buffer = New-Object byte[] 65536
|
||||||
$total = 0L
|
$total = 0L
|
||||||
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
||||||
|
$lastProgress = [DateTime]::MinValue
|
||||||
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
|
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
|
||||||
$total += $count
|
$total += $count
|
||||||
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
|
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
|
||||||
$outputStream.Write($buffer,0,$count)
|
$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 {
|
} finally {
|
||||||
if ($outputStream) { $outputStream.Dispose() }
|
if ($outputStream) { $outputStream.Dispose() }
|
||||||
if ($inputStream) { $inputStream.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$' })
|
$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.' }
|
if ($matchesFound.Count -ne 1) { throw 'Missing or ambiguous archive checksum.' }
|
||||||
$expected = ($matchesFound[0] -split '\s+')[0]
|
$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
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
|
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
|
||||||
try {
|
try {
|
||||||
@@ -74,7 +271,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
|
|||||||
foreach ($entry in $zip.Entries) {
|
foreach ($entry in $zip.Entries) {
|
||||||
$name = $entry.FullName.Replace('\','/')
|
$name = $entry.FullName.Replace('\','/')
|
||||||
if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue }
|
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
|
$name.EndsWith('/.') -or $name.EndsWith('/..') -or $name.EndsWith('.') -or
|
||||||
(($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($name)) {
|
(($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($name)) {
|
||||||
throw "Unexpected or unsafe archive entry: $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'
|
throw 'Incomplete release: KSwitcher.Config.exe'
|
||||||
}
|
}
|
||||||
} finally { $zip.Dispose() }
|
} 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
|
$exe = Join-Path $Destination $binary
|
||||||
if (!(Test-Path -LiteralPath $exe)) { continue }
|
if (!(Test-Path -LiteralPath $exe)) { continue }
|
||||||
$version = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe)
|
$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.' }
|
[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) {
|
function Assert-PlainDirectory([string]$Directory) {
|
||||||
$item = Get-Item -LiteralPath $Directory -Force
|
$item = Get-Item -LiteralPath $Directory -Force
|
||||||
while ($item) {
|
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)) {
|
foreach ($file in @(Get-ChildItem -LiteralPath $Stage -File -Recurse)) {
|
||||||
$relative = $file.FullName.Substring($Stage.Length+1)
|
$relative = $file.FullName.Substring($Stage.Length+1)
|
||||||
$destination = Join-Path $Target $relative
|
$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
|
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
|
||||||
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($destination))
|
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($destination))
|
||||||
if (Test-Path -LiteralPath $destination) {
|
if (Test-Path -LiteralPath $destination) {
|
||||||
@@ -145,8 +411,9 @@ function Write-Result($Value) {
|
|||||||
if ($Library) { return }
|
if ($Library) { return }
|
||||||
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
|
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
|
||||||
$changed = [Collections.Generic.List[string]]::new()
|
$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 {
|
try {
|
||||||
|
if ($ShowProgress -or $Mode -eq 'Install') { Start-UpdateProgress }
|
||||||
$current = Get-StableVersion $CurrentVersion
|
$current = Get-StableVersion $CurrentVersion
|
||||||
if (!$current) { throw 'Invalid installed version.' }
|
if (!$current) { throw 'Invalid installed version.' }
|
||||||
if ($Mode -eq 'Install') {
|
if ($Mode -eq 'Install') {
|
||||||
@@ -167,23 +434,35 @@ try {
|
|||||||
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
|
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
|
||||||
[IO.Directory]::CreateDirectory($stage) | Out-Null
|
[IO.Directory]::CreateDirectory($stage) | Out-Null
|
||||||
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt'
|
$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
|
Receive-File $release.checksum $checksum 64KB
|
||||||
Assert-Checksum $archive $checksum
|
Assert-Checksum $archive $checksum
|
||||||
|
Show-UpdateProgress 'Preparing installation' 'Validating and unpacking the release files'
|
||||||
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
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}
|
Write-Result @{state='prepared';version=$release.version}
|
||||||
|
Show-UpdateProgress 'Closing KSwitcher' 'Waiting for the running application to exit safely'
|
||||||
$ready.Set() | Out-Null
|
$ready.Set() | Out-Null
|
||||||
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
||||||
$parentExited = $true
|
$parentExited = $true
|
||||||
|
Show-UpdateProgress 'Installing update' 'Replacing application files; keeping your personal data'
|
||||||
Install-Files $stage $InstallDirectory $backup $changed
|
Install-Files $stage $InstallDirectory $backup $changed
|
||||||
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
||||||
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
|
$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
|
$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.' }
|
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
|
||||||
$success = $true
|
$success = $true
|
||||||
|
if ($script:progressWindow) {
|
||||||
|
$uiFinished = Complete-UpdateProgress 'Update completed' "KSwitcher $(Get-StableVersion $release.version) is now running." $true
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
$message = $_.Exception.Message
|
$message = $_.Exception.Message
|
||||||
if ($parentExited) {
|
if ($parentExited) {
|
||||||
|
Show-UpdateProgress 'Restoring previous version' 'The update failed; recovering the previous installation'
|
||||||
try {
|
try {
|
||||||
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
|
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
|
||||||
Undo-Files $InstallDirectory $backup $changed
|
Undo-Files $InstallDirectory $backup $changed
|
||||||
@@ -196,9 +475,13 @@ try {
|
|||||||
}
|
}
|
||||||
Write-Result @{state='error';error=$message}
|
Write-Result @{state='error';error=$message}
|
||||||
if ($ready -and $ready.WaitOne(0)) {
|
if ($ready -and $ready.WaitOne(0)) {
|
||||||
|
if ($script:progressWindow) {
|
||||||
|
$uiFinished = Complete-UpdateProgress 'Update failed' $message $false
|
||||||
|
} else {
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
|
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
exit 1
|
exit 1
|
||||||
} finally {
|
} finally {
|
||||||
if ($ready) { $ready.Dispose() }
|
if ($ready) { $ready.Dispose() }
|
||||||
@@ -214,4 +497,8 @@ try {
|
|||||||
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
|
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 "../resources/resource.h"
|
#include "../resources/resource.h"
|
||||||
#include "ocr.h"
|
#include "ocr.h"
|
||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
|
#include "screenshot_notifications.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
#include <set>
|
#include <set>
|
||||||
@@ -17,7 +18,8 @@ std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
|
|||||||
{Case, s.casing},
|
{Case, s.casing},
|
||||||
{Translit, s.translit},
|
{Translit, s.translit},
|
||||||
{Undo, s.undo},
|
{Undo, s.undo},
|
||||||
{ShowHistory, s.clipboard}};
|
{ShowHistory, s.clipboard},
|
||||||
|
{Screenshot, s.screenshot}};
|
||||||
for (size_t i = 0; i < s.direct.size(); ++i)
|
for (size_t i = 0; i < s.direct.size(); ++i)
|
||||||
out.emplace_back(Direct + (int)i, s.direct[i]);
|
out.emplace_back(Direct + (int)i, s.direct[i]);
|
||||||
return out;
|
return out;
|
||||||
@@ -231,6 +233,7 @@ class Transaction
|
|||||||
} // namespace
|
} // namespace
|
||||||
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
|
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
|
||||||
{
|
{
|
||||||
|
updateNotifier = notifyUpdateAvailable;
|
||||||
current = this;
|
current = this;
|
||||||
data = dataRoot.empty() ? dataDirectory() : dataRoot;
|
data = dataRoot.empty() ? dataDirectory() : dataRoot;
|
||||||
std::filesystem::create_directories(data);
|
std::filesystem::create_directories(data);
|
||||||
@@ -267,8 +270,15 @@ App::~App()
|
|||||||
std::lock_guard lock(jobsMutex);
|
std::lock_guard lock(jobsMutex);
|
||||||
stop = true;
|
stop = true;
|
||||||
jobs.clear();
|
jobs.clear();
|
||||||
|
pendingHistorySearch = {};
|
||||||
}
|
}
|
||||||
jobsReady.notify_all();
|
jobsReady.notify_all();
|
||||||
|
historySearchReady.notify_all();
|
||||||
|
historyOcrReady.notify_all();
|
||||||
|
if (historyOcrWorker.joinable())
|
||||||
|
historyOcrWorker.join();
|
||||||
|
if (historySearchWorker.joinable())
|
||||||
|
historySearchWorker.join();
|
||||||
if (worker.joinable())
|
if (worker.joinable())
|
||||||
worker.join();
|
worker.join();
|
||||||
RemoveClipboardFormatListener(window);
|
RemoveClipboardFormatListener(window);
|
||||||
@@ -289,6 +299,83 @@ void App::enqueue(std::function<void()> job)
|
|||||||
}
|
}
|
||||||
jobsReady.notify_one();
|
jobsReady.notify_one();
|
||||||
}
|
}
|
||||||
|
void App::enqueueHistorySearch(std::function<void()> job)
|
||||||
|
{
|
||||||
|
{
|
||||||
|
std::lock_guard lock(jobsMutex);
|
||||||
|
if (stop) return;
|
||||||
|
// Only the latest query matters. Clipboard capture and OCR must not delay it.
|
||||||
|
pendingHistorySearch = std::move(job);
|
||||||
|
if (!historySearchWorker.joinable())
|
||||||
|
historySearchWorker = std::thread([this]
|
||||||
|
{
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
std::function<void()> searchJob;
|
||||||
|
{
|
||||||
|
std::unique_lock lock(jobsMutex);
|
||||||
|
historySearchReady.wait(lock, [&] { return stop || bool(pendingHistorySearch); });
|
||||||
|
if (stop) return;
|
||||||
|
searchJob = std::move(pendingHistorySearch);
|
||||||
|
pendingHistorySearch = {};
|
||||||
|
}
|
||||||
|
try { searchJob(); }
|
||||||
|
catch (const std::exception &e) { report(wide(e.what())); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
historySearchReady.notify_one();
|
||||||
|
}
|
||||||
|
void App::scheduleHistoryOcr(const Settings &config)
|
||||||
|
{
|
||||||
|
{
|
||||||
|
std::lock_guard lock(jobsMutex);
|
||||||
|
if (stop || !history || !config.history) return;
|
||||||
|
historyOcrSettings = config;
|
||||||
|
historyOcrRequested = true;
|
||||||
|
if (!historyOcrWorker.joinable())
|
||||||
|
historyOcrWorker = std::thread([this]
|
||||||
|
{
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
Settings config;
|
||||||
|
{
|
||||||
|
std::unique_lock lock(jobsMutex);
|
||||||
|
historyOcrReady.wait(lock, [&] { return stop || historyOcrRequested; });
|
||||||
|
if (stop) return;
|
||||||
|
historyOcrRequested = false;
|
||||||
|
config = historyOcrSettings;
|
||||||
|
}
|
||||||
|
for (const auto &entry : history->entries())
|
||||||
|
{
|
||||||
|
{ std::lock_guard lock(jobsMutex); if (stop) return; }
|
||||||
|
auto profile = ocrProfile(config);
|
||||||
|
if (!entry.image || (entry.ocrState != 1 && entry.ocrProfile == profile)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
auto text = recognizeText(history->load(entry.id), config);
|
||||||
|
std::lock_guard lock(jobsMutex);
|
||||||
|
if (stop || ocrProfile(historyOcrSettings) != profile) continue;
|
||||||
|
if (history->saveOcr(entry.id, text, config))
|
||||||
|
PostMessageW(window, HistoryMessage, 0, 0);
|
||||||
|
}
|
||||||
|
catch (const std::exception &e)
|
||||||
|
{
|
||||||
|
// Automatic OCR never changes the clipboard or opens error dialogs.
|
||||||
|
log(L"History OCR: " + wide(e.what()));
|
||||||
|
try {
|
||||||
|
std::lock_guard lock(jobsMutex);
|
||||||
|
if (!stop && ocrProfile(historyOcrSettings) == profile)
|
||||||
|
history->saveOcr(entry.id, std::nullopt, config);
|
||||||
|
}
|
||||||
|
catch (const std::exception &saveError) { log(L"History OCR save: " + wide(saveError.what())); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
historyOcrReady.notify_one();
|
||||||
|
}
|
||||||
void App::report(const std::wstring &text)
|
void App::report(const std::wstring &text)
|
||||||
{
|
{
|
||||||
log(text);
|
log(text);
|
||||||
@@ -331,6 +418,7 @@ void App::applySettings(const Settings &s)
|
|||||||
{
|
{
|
||||||
bool autostartChanged = false;
|
bool autostartChanged = false;
|
||||||
validateSettings(s);
|
validateSettings(s);
|
||||||
|
validateTesseract(s);
|
||||||
if (s.autoCorrect)
|
if (s.autoCorrect)
|
||||||
{
|
{
|
||||||
AutoCorrector probe;
|
AutoCorrector probe;
|
||||||
@@ -373,6 +461,7 @@ void App::applySettings(const Settings &s)
|
|||||||
}
|
}
|
||||||
// Clear on the worker so the next check reloads edits to dictionary files.
|
// Clear on the worker so the next check reloads edits to dictionary files.
|
||||||
enqueue([this] { autoCorrector.configure({}, {}); });
|
enqueue([this] { autoCorrector.configure({}, {}); });
|
||||||
|
scheduleHistoryOcr(s);
|
||||||
SetThreadExecutionState(ES_CONTINUOUS |
|
SetThreadExecutionState(ES_CONTINUOUS |
|
||||||
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
||||||
}
|
}
|
||||||
@@ -509,6 +598,27 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
|||||||
// down AND up events use exactly the same path as physical keyboard input.
|
// down AND up events use exactly the same path as physical keyboard input.
|
||||||
if (ownKeyboardInput(key) || installingUpdate)
|
if (ownKeyboardInput(key) || installingUpdate)
|
||||||
return false;
|
return false;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(stateMutex);
|
||||||
|
if (typingTarget && GetForegroundWindow() == typingTarget)
|
||||||
|
{
|
||||||
|
INPUT input{};
|
||||||
|
input.type = INPUT_KEYBOARD;
|
||||||
|
input.ki.wVk = static_cast<WORD>(key.vkCode);
|
||||||
|
input.ki.wScan = static_cast<WORD>(key.scanCode);
|
||||||
|
input.ki.dwFlags = (down ? 0 : KEYEVENTF_KEYUP) |
|
||||||
|
((key.flags & LLKHF_EXTENDED) ? KEYEVENTF_EXTENDEDKEY : 0);
|
||||||
|
if (key.vkCode == VK_PACKET)
|
||||||
|
{
|
||||||
|
input.ki.wVk = 0;
|
||||||
|
input.ki.dwFlags |= KEYEVENTF_UNICODE;
|
||||||
|
}
|
||||||
|
// Replay as ordinary input so tracking and shortcuts see it once,
|
||||||
|
// after the replacement and layout switch have finished.
|
||||||
|
deferredTyping.push_back(input);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
DWORD captureProcess{};
|
DWORD captureProcess{};
|
||||||
if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess);
|
if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess);
|
||||||
if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess))
|
if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess))
|
||||||
@@ -551,6 +661,41 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
|||||||
return true;
|
return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
bool App::beginTypingDeferral(HWND target, uint64_t version)
|
||||||
|
{
|
||||||
|
std::lock_guard lock(stateMutex);
|
||||||
|
if (inputVersion != version || GetForegroundWindow() != target || !modifiersReleased())
|
||||||
|
return false;
|
||||||
|
typingTarget = target;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
void App::replayTyping()
|
||||||
|
{
|
||||||
|
std::vector<INPUT> inputs;
|
||||||
|
HWND target;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(stateMutex);
|
||||||
|
target = typingTarget;
|
||||||
|
typingTarget = nullptr;
|
||||||
|
inputs.swap(deferredTyping);
|
||||||
|
}
|
||||||
|
busy = false;
|
||||||
|
if (inputs.empty())
|
||||||
|
return;
|
||||||
|
if (GetForegroundWindow() != target)
|
||||||
|
{
|
||||||
|
resetInput();
|
||||||
|
report(L"Typing during correction was not replayed because the active window changed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Runs on the hook thread: no physical key can pass between releasing the
|
||||||
|
// gate and submitting the entire ordered batch to the Windows input queue.
|
||||||
|
if (SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT)) != inputs.size())
|
||||||
|
{
|
||||||
|
resetInput();
|
||||||
|
report(L"Windows could not replay all typing buffered during correction.");
|
||||||
|
}
|
||||||
|
}
|
||||||
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
|
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
|
||||||
{
|
{
|
||||||
if (code == HC_ACTION && current &&
|
if (code == HC_ACTION && current &&
|
||||||
@@ -586,6 +731,12 @@ void App::dispatch(int command)
|
|||||||
return;
|
return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (command == 23) { checkUpdate(true); return; }
|
||||||
|
if (command == Screenshot)
|
||||||
|
{
|
||||||
|
if (!busy) { resetInput(); launchScreenshot(*this); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (command == ShowHistory)
|
if (command == ShowHistory)
|
||||||
{
|
{
|
||||||
showHistory(*this);
|
showHistory(*this);
|
||||||
@@ -667,11 +818,16 @@ void App::transform(int command, int snippet)
|
|||||||
struct BusyReset
|
struct BusyReset
|
||||||
{
|
{
|
||||||
std::atomic<bool> &b;
|
std::atomic<bool> &b;
|
||||||
|
HWND window;
|
||||||
|
bool deferred{};
|
||||||
~BusyReset()
|
~BusyReset()
|
||||||
{
|
{
|
||||||
|
if (deferred)
|
||||||
|
PostMessageW(window, ReplayTypingMessage, 0, 0);
|
||||||
|
else
|
||||||
b = false;
|
b = false;
|
||||||
}
|
}
|
||||||
} reset{busy};
|
} reset{busy, window};
|
||||||
auto start = GetTickCount64();
|
auto start = GetTickCount64();
|
||||||
std::wstring reason = L"cancelled-before-selection";
|
std::wstring reason = L"cancelled-before-selection";
|
||||||
std::wstring text, result, expected;
|
std::wstring text, result, expected;
|
||||||
@@ -754,6 +910,21 @@ void App::transform(int command, int snippet)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reason = L"clipboard-snapshot";
|
reason = L"clipboard-snapshot";
|
||||||
|
if (command == AutoCorrect)
|
||||||
|
{
|
||||||
|
if (!beginTypingDeferral(target, version))
|
||||||
|
return;
|
||||||
|
reset.deferred = true;
|
||||||
|
}
|
||||||
|
// Collapse a temporary selection on every failed auto-correction,
|
||||||
|
// including clipboard exceptions, before queued typing is replayed.
|
||||||
|
bool autoSelected = false;
|
||||||
|
struct SelectionReset
|
||||||
|
{
|
||||||
|
bool &selected;
|
||||||
|
std::function<bool()> valid;
|
||||||
|
~SelectionReset() { if (selected && valid()) chord(VK_RIGHT); }
|
||||||
|
} selectionReset{autoSelected, valid};
|
||||||
Transaction tx(*this, target, version);
|
Transaction tx(*this, target, version);
|
||||||
reason = L"selection-unavailable";
|
reason = L"selection-unavailable";
|
||||||
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
|
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
|
||||||
@@ -770,6 +941,7 @@ void App::transform(int command, int snippet)
|
|||||||
// conversion, automatic correction must never fall back to a suffix.
|
// conversion, automatic correction must never fall back to a suffix.
|
||||||
if (!tx.valid() || !chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
if (!tx.valid() || !chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
||||||
return;
|
return;
|
||||||
|
autoSelected = true;
|
||||||
Sleep(25);
|
Sleep(25);
|
||||||
text = tx.copy();
|
text = tx.copy();
|
||||||
// Some editors first select only ") ". Extend by whole-word
|
// Some editors first select only ") ". Extend by whole-word
|
||||||
@@ -786,7 +958,10 @@ void App::transform(int command, int snippet)
|
|||||||
if (!suggestion || !tx.valid() || !autoCorrectionAllowed(target, config))
|
if (!suggestion || !tx.valid() || !autoCorrectionAllowed(target, config))
|
||||||
{
|
{
|
||||||
if (tx.valid())
|
if (tx.valid())
|
||||||
|
{
|
||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
|
autoSelected = false;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
to = suggestion->target;
|
to = suggestion->target;
|
||||||
@@ -923,6 +1098,8 @@ void App::transform(int command, int snippet)
|
|||||||
sent = tx.paste(textClip(result));
|
sent = tx.paste(textClip(result));
|
||||||
else if (automatic && tx.valid())
|
else if (automatic && tx.valid())
|
||||||
chord(VK_RIGHT);
|
chord(VK_RIGHT);
|
||||||
|
if (sent || result == text)
|
||||||
|
autoSelected = false;
|
||||||
reason = result == text ? L"conversion-unchanged" :
|
reason = result == text ? L"conversion-unchanged" :
|
||||||
sent ? L"paste-sent-unverified" : L"paste-cancelled";
|
sent ? L"paste-sent-unverified" : L"paste-cancelled";
|
||||||
if (result == text &&
|
if (result == text &&
|
||||||
@@ -1044,6 +1221,7 @@ void App::captureHistory()
|
|||||||
!f.data[1] && !f.data[2] && !f.data[3])
|
!f.data[1] && !f.data[2] && !f.data[3])
|
||||||
return;
|
return;
|
||||||
history->add(clip, app, config);
|
history->add(clip, app, config);
|
||||||
|
scheduleHistoryOcr(config);
|
||||||
PostMessageW(window, HistoryMessage, 0, 0);
|
PostMessageW(window, HistoryMessage, 0, 0);
|
||||||
}
|
}
|
||||||
catch (const std::exception &e)
|
catch (const std::exception &e)
|
||||||
@@ -1071,7 +1249,9 @@ void App::recognizeEntry(std::wstring id)
|
|||||||
} reset{busy};
|
} reset{busy};
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
auto text = recognizeText(history->load(id));
|
auto cached = history->ocrText(id, ocrProfile(config));
|
||||||
|
auto text = cached ? *cached : recognizeText(history->load(id), config);
|
||||||
|
if (!cached) history->saveOcr(id, text, config);
|
||||||
if (text.empty())
|
if (text.empty())
|
||||||
{
|
{
|
||||||
report(L"No text was found in the image.");
|
report(L"No text was found in the image.");
|
||||||
@@ -1096,6 +1276,7 @@ void App::trayMenu()
|
|||||||
{
|
{
|
||||||
HMENU menu = CreatePopupMenu();
|
HMENU menu = CreatePopupMenu();
|
||||||
AppendMenuW(menu, MF_STRING, 10, L"Clipboard history");
|
AppendMenuW(menu, MF_STRING, 10, L"Clipboard history");
|
||||||
|
AppendMenuW(menu, MF_STRING, 24, L"Capture screen region…");
|
||||||
AppendMenuW(menu, MF_STRING, 11, L"Settings…");
|
AppendMenuW(menu, MF_STRING, 11, L"Settings…");
|
||||||
SetMenuDefaultItem(menu, 11, FALSE);
|
SetMenuDefaultItem(menu, 11, FALSE);
|
||||||
AppendMenuW(menu, MF_STRING, 12, L"Snippets…");
|
AppendMenuW(menu, MF_STRING, 12, L"Snippets…");
|
||||||
@@ -1127,6 +1308,9 @@ void App::trayMenu()
|
|||||||
{
|
{
|
||||||
switch (cmd)
|
switch (cmd)
|
||||||
{
|
{
|
||||||
|
case 24:
|
||||||
|
dispatch(Screenshot);
|
||||||
|
break;
|
||||||
case 10:
|
case 10:
|
||||||
showHistory(*this);
|
showHistory(*this);
|
||||||
break;
|
break;
|
||||||
@@ -1203,7 +1387,7 @@ void App::checkUpdate(bool interactive, bool install, const std::wstring &versio
|
|||||||
updateWorker = std::thread([this, interactive, install, version]
|
updateWorker = std::thread([this, interactive, install, version]
|
||||||
{
|
{
|
||||||
auto result = std::make_unique<UpdateResult>();
|
auto result = std::make_unique<UpdateResult>();
|
||||||
try { *result = runUpdate(install, version); }
|
try { *result = runUpdate(install, version, interactive); }
|
||||||
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
|
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
|
||||||
result->interactive = interactive;
|
result->interactive = interactive;
|
||||||
result->install = install;
|
result->install = install;
|
||||||
@@ -1228,6 +1412,8 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (app->updateWorker.joinable()) app->updateWorker.join();
|
if (app->updateWorker.joinable()) app->updateWorker.join();
|
||||||
app->updateRunning = false;
|
app->updateRunning = false;
|
||||||
app->installingUpdate = false;
|
app->installingUpdate = false;
|
||||||
|
log(L"Update check: " + result->state + (result->version.empty() ? L"" : L" " + result->version) +
|
||||||
|
(result->error.empty() ? L"" : L"; " + result->error));
|
||||||
if (result->state == L"prepared")
|
if (result->state == L"prepared")
|
||||||
{
|
{
|
||||||
// The helper has verified and staged the package; it waits for process exit.
|
// The helper has verified and staged the package; it waits for process exit.
|
||||||
@@ -1243,14 +1429,18 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (MessageBoxW(hwnd, prompt.c_str(), L"KSwitcher Update", MB_YESNO | MB_ICONQUESTION) == IDYES)
|
if (MessageBoxW(hwnd, prompt.c_str(), L"KSwitcher Update", MB_YESNO | MB_ICONQUESTION) == IDYES)
|
||||||
app->checkUpdate(true, true, result->version);
|
app->checkUpdate(true, true, result->version);
|
||||||
}
|
}
|
||||||
|
else if (app->notifiedUpdate != result->version)
|
||||||
|
{
|
||||||
|
if (app->updateNotifier(result->version))
|
||||||
|
{
|
||||||
|
app->notifiedUpdate = result->version;
|
||||||
|
log(L"Update notification submitted to Windows: " + result->version);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
NOTIFYICONDATAW icon{sizeof(icon)};
|
log(L"Update notification failed; retrying in 5 minutes: " + result->version);
|
||||||
icon.hWnd = hwnd; icon.uID = 1; icon.uFlags = NIF_INFO; icon.dwInfoFlags = NIIF_INFO;
|
SetTimer(hwnd, 5, UpdateNotificationRetryMs, nullptr);
|
||||||
wcscpy_s(icon.szInfoTitle, L"KSwitcher update available");
|
}
|
||||||
auto text = result->version + L" is available. Select Update in the tray menu to install.";
|
|
||||||
wcsncpy_s(icon.szInfo, text.c_str(), _TRUNCATE);
|
|
||||||
Shell_NotifyIconW(NIM_MODIFY, &icon);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (result->state == L"current")
|
else if (result->state == L"current")
|
||||||
@@ -1263,12 +1453,15 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
else if (result->interactive)
|
else if (result->interactive)
|
||||||
MessageBoxW(hwnd, (L"Update failed.\n\n" + result->error).c_str(), L"KSwitcher Update", MB_OK | MB_ICONERROR);
|
MessageBoxW(hwnd, (L"Update failed.\n\n" + result->error).c_str(), L"KSwitcher Update", MB_OK | MB_ICONERROR);
|
||||||
else
|
else
|
||||||
diagnosticEvent(L"Startup update check failed: " + result->error);
|
diagnosticEvent(L"Background update check failed: " + result->error);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
case WM_HOTKEY:
|
case WM_HOTKEY:
|
||||||
app->dispatch((int)wp);
|
app->dispatch((int)wp);
|
||||||
return 0;
|
return 0;
|
||||||
|
case ReplayTypingMessage:
|
||||||
|
app->replayTyping();
|
||||||
|
return 0;
|
||||||
case TrayMessage:
|
case TrayMessage:
|
||||||
if (LOWORD(lp) == WM_CONTEXTMENU)
|
if (LOWORD(lp) == WM_CONTEXTMENU)
|
||||||
app->trayMenu();
|
app->trayMenu();
|
||||||
@@ -1327,7 +1520,9 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
case WM_TIMER:
|
case WM_TIMER:
|
||||||
if (wp == 5)
|
if (wp == 5)
|
||||||
{
|
{
|
||||||
KillTimer(hwnd, 5);
|
// After the initial 5-second check, repeat while the resident stays open.
|
||||||
|
// Rearm even when a check is already running or the previous one failed.
|
||||||
|
SetTimer(hwnd, 5, UpdateCheckIntervalMs, nullptr);
|
||||||
app->checkUpdate(false);
|
app->checkUpdate(false);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -1428,6 +1623,8 @@ int App::execute()
|
|||||||
if (!keyboardHook || !mouseHook)
|
if (!keyboardHook || !mouseHook)
|
||||||
report(L"Input tracking could not start. Automatic recent-text conversion is unavailable.");
|
report(L"Input tracking could not start. Automatic recent-text conversion is unavailable.");
|
||||||
AddClipboardFormatListener(window);
|
AddClipboardFormatListener(window);
|
||||||
|
// Resume pending OCR, including older images without a saved OCR state.
|
||||||
|
scheduleHistoryOcr(settings);
|
||||||
addTray();
|
addTray();
|
||||||
SetTimer(window, 1, 60000, nullptr);
|
SetTimer(window, 1, 60000, nullptr);
|
||||||
SetThreadExecutionState(ES_CONTINUOUS |
|
SetThreadExecutionState(ES_CONTINUOUS |
|
||||||
@@ -1438,10 +1635,13 @@ int App::execute()
|
|||||||
SetTimer(window, 2, 10000, nullptr);
|
SetTimer(window, 2, 10000, nullptr);
|
||||||
else
|
else
|
||||||
SetTimer(window, 5, 5000, nullptr);
|
SetTimer(window, 5, 5000, nullptr);
|
||||||
if (keyboardHook && mouseHook)
|
if (keyboardHook && mouseHook && confirmUpdatedStartup(updateStartupEvent))
|
||||||
confirmUpdatedStartup(updateStartupEvent);
|
log(std::wstring(L"Update completed; running version ") + AppVersion);
|
||||||
if (startCommand)
|
if (startCommand)
|
||||||
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
||||||
|
try { registerScreenshotNotifications(); }
|
||||||
|
catch(const std::exception &e) { log(L"Screenshot notification registration: "+wide(e.what())); }
|
||||||
|
auto screenshotActivation=startScreenshotActivationListener();
|
||||||
MSG msg;
|
MSG msg;
|
||||||
while (GetMessageW(&msg, nullptr, 0, 0) > 0)
|
while (GetMessageW(&msg, nullptr, 0, 0) > 0)
|
||||||
{
|
{
|
||||||
@@ -1460,6 +1660,7 @@ int App::execute()
|
|||||||
TranslateMessage(&msg);
|
TranslateMessage(&msg);
|
||||||
DispatchMessageW(&msg);
|
DispatchMessageW(&msg);
|
||||||
}
|
}
|
||||||
|
if(screenshotActivation) CoRevokeClassObject(screenshotActivation);
|
||||||
return (int)msg.wParam;
|
return (int)msg.wParam;
|
||||||
}
|
}
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ namespace ks
|
|||||||
inline constexpr UINT TrayMessage = WM_APP + 1, ErrorMessage = WM_APP + 2, HistoryMessage = WM_APP + 3,
|
inline constexpr UINT TrayMessage = WM_APP + 1, ErrorMessage = WM_APP + 2, HistoryMessage = WM_APP + 3,
|
||||||
SnippetMessage = WM_APP + 4;
|
SnippetMessage = WM_APP + 4;
|
||||||
inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, AutoCorrect = 6,
|
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 AutoCorrectMessage = WM_APP + 6;
|
||||||
|
inline constexpr UINT ReplayTypingMessage = WM_APP + 30;
|
||||||
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
|
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
|
||||||
struct LastTransform
|
struct LastTransform
|
||||||
{
|
{
|
||||||
@@ -61,6 +62,7 @@ class App
|
|||||||
void recognizeEntry(std::wstring);
|
void recognizeEntry(std::wstring);
|
||||||
void captureHistory();
|
void captureHistory();
|
||||||
void enqueue(std::function<void()>);
|
void enqueue(std::function<void()>);
|
||||||
|
void enqueueHistorySearch(std::function<void()>);
|
||||||
void report(const std::wstring &);
|
void report(const std::wstring &);
|
||||||
void trayMenu();
|
void trayMenu();
|
||||||
void addTray();
|
void addTray();
|
||||||
@@ -83,9 +85,18 @@ class App
|
|||||||
std::condition_variable jobsReady;
|
std::condition_variable jobsReady;
|
||||||
std::deque<std::function<void()>> jobs;
|
std::deque<std::function<void()>> jobs;
|
||||||
std::thread worker;
|
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 updateWorker;
|
||||||
bool updateRunning{}, installingUpdate{};
|
bool updateRunning{}, installingUpdate{};
|
||||||
std::wstring availableUpdate;
|
std::wstring availableUpdate, notifiedUpdate;
|
||||||
|
bool (*updateNotifier)(const std::wstring &){};
|
||||||
bool stop{};
|
bool stop{};
|
||||||
UINT taskbarCreated{};
|
UINT taskbarCreated{};
|
||||||
uint64_t pendingAutoVersion{};
|
uint64_t pendingAutoVersion{};
|
||||||
@@ -95,11 +106,16 @@ class App
|
|||||||
int previousAttemptCommand{}, repeatAttempts{};
|
int previousAttemptCommand{}, repeatAttempts{};
|
||||||
std::vector<std::pair<int, Hotkey>> hookBindings;
|
std::vector<std::pair<int, Hotkey>> hookBindings;
|
||||||
HotkeyHook hotkeyHook;
|
HotkeyHook hotkeyHook;
|
||||||
|
HWND typingTarget{};
|
||||||
|
std::vector<INPUT> deferredTyping;
|
||||||
|
bool beginTypingDeferral(HWND, uint64_t);
|
||||||
|
void replayTyping();
|
||||||
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
||||||
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
||||||
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
|
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
|
||||||
};
|
};
|
||||||
void showHistory(App &);
|
void showHistory(App &, bool visible = true);
|
||||||
|
void launchScreenshot(App &);
|
||||||
void refreshHistory(App &);
|
void refreshHistory(App &);
|
||||||
void showSettings(App &, bool visible = true);
|
void showSettings(App &, bool visible = true);
|
||||||
void showLogs(App &);
|
void showLogs(App &);
|
||||||
|
|||||||
@@ -70,9 +70,18 @@ AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path)
|
|||||||
}
|
}
|
||||||
if (offsets.empty())
|
if (offsets.empty())
|
||||||
throw std::runtime_error("Empty auto-correction dictionary");
|
throw std::runtime_error("Empty auto-correction dictionary");
|
||||||
for (size_t i = 1; i < offsets.size(); ++i)
|
auto entry = [&](uint32_t offset) { return std::string_view(bytes).substr(offset, bytes.find('\t', offset) - offset); };
|
||||||
if (word(i - 1) >= word(i))
|
auto frequency = [&](uint32_t offset)
|
||||||
throw std::runtime_error("Dictionary must be uniquely sorted");
|
{
|
||||||
|
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();
|
offsets.shrink_to_fit();
|
||||||
bytes.shrink_to_fit();
|
bytes.shrink_to_fit();
|
||||||
}
|
}
|
||||||
@@ -151,9 +160,16 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
auto sourceLanguage = layoutLanguage(layouts[source]);
|
auto sourceLanguage = layoutLanguage(layouts[source]);
|
||||||
if (!dictionaries.contains(sourceLanguage))
|
if (!dictionaries.contains(sourceLanguage))
|
||||||
return {};
|
return {};
|
||||||
|
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
|
||||||
|
{
|
||||||
|
auto found = settings.autoWords.find(language);
|
||||||
|
return found != settings.autoWords.end() &&
|
||||||
|
std::any_of(found->second.begin(), found->second.end(), [&](const std::wstring &known)
|
||||||
|
{ return lower(known) == lower(candidate); });
|
||||||
|
};
|
||||||
// Valid words from either selected language are never changed.
|
// Valid words from either selected language are never changed.
|
||||||
for (auto &[language, d] : dictionaries)
|
for (auto &[language, d] : dictionaries)
|
||||||
if (d.rank(word))
|
if (d.rank(word) || personal(language, word))
|
||||||
return {};
|
return {};
|
||||||
std::optional<AutoSuggestion> candidate;
|
std::optional<AutoSuggestion> candidate;
|
||||||
int matches = 0;
|
int matches = 0;
|
||||||
@@ -168,7 +184,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
auto converted = convert(word, layouts[source], layouts[i], true);
|
auto converted = convert(word, layouts[source], layouts[i], true);
|
||||||
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
|
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
|
||||||
continue;
|
continue;
|
||||||
int rank = dictionary->second.rank(converted);
|
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second.rank(converted);
|
||||||
if (!rank)
|
if (!rank)
|
||||||
continue;
|
continue;
|
||||||
if (++matches > 1)
|
if (++matches > 1)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <map>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -48,18 +49,24 @@ struct Settings
|
|||||||
bool elevatedAutostart = false;
|
bool elevatedAutostart = false;
|
||||||
bool autoCorrect = false;
|
bool autoCorrect = false;
|
||||||
bool writeLog = true;
|
bool writeLog = true;
|
||||||
|
bool screenshotAutoOpenEditor = false;
|
||||||
|
bool useTesseract = false;
|
||||||
|
std::wstring tesseractPath;
|
||||||
|
std::wstring tesseractLanguages = L"ukr+eng+rus";
|
||||||
int autoMaxRank = 0;
|
int autoMaxRank = 0;
|
||||||
int autoMinLength = 3;
|
int autoMinLength = 3;
|
||||||
std::wstring autoPrefixChars = L"([{«“‘\"";
|
std::wstring autoPrefixChars = L"([{«“‘\"";
|
||||||
std::wstring autoSuffixChars = L")]}»”’\".,!?:;";
|
std::wstring autoSuffixChars = L")]}»”’\".,!?:;";
|
||||||
std::vector<std::wstring> autoLanguages{L"en", L"uk"};
|
std::vector<std::wstring> autoLanguages{L"en", L"uk"};
|
||||||
std::vector<std::wstring> autoNever;
|
std::vector<std::wstring> autoNever;
|
||||||
|
std::map<std::wstring, std::vector<std::wstring>> autoWords;
|
||||||
std::vector<std::wstring> autoExcluded{
|
std::vector<std::wstring> autoExcluded{
|
||||||
L"Code", L"rider64", L"devenv", L"WindowsTerminal", L"cmd", L"powershell", L"pwsh",
|
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"};
|
L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||||
int maxItems = 500, maxMiB = 200, maxImageMiB = 20;
|
int maxItems = 500, maxMiB = 200, maxImageMiB = 20;
|
||||||
std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C",
|
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> cycle, direct, directIds;
|
||||||
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||||
std::vector<Snippet> snippets;
|
std::vector<Snippet> snippets;
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
|||||||
HANDLE mutex = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Native.Singleton");
|
HANDLE mutex = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Native.Singleton");
|
||||||
bool existing = GetLastError() == ERROR_ALREADY_EXISTS;
|
bool existing = GetLastError() == ERROR_ALREADY_EXISTS;
|
||||||
int cmd = ks::ShowHistory;
|
int cmd = ks::ShowHistory;
|
||||||
if (arg == L"--convert")
|
if (arg == L"--capture")
|
||||||
|
cmd = ks::Screenshot;
|
||||||
|
else if (arg == L"--convert")
|
||||||
cmd = ks::Toggle;
|
cmd = ks::Toggle;
|
||||||
else if (arg == L"--case")
|
else if (arg == L"--case")
|
||||||
cmd = ks::Case;
|
cmd = ks::Case;
|
||||||
@@ -43,12 +45,15 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
|||||||
cmd = 21;
|
cmd = 21;
|
||||||
else if (arg == L"--exit")
|
else if (arg == L"--exit")
|
||||||
cmd = 99;
|
cmd = 99;
|
||||||
|
else if (arg == L"--check-update")
|
||||||
|
cmd = 23;
|
||||||
LocalFree(argv);
|
LocalFree(argv);
|
||||||
if (existing)
|
if (existing)
|
||||||
{
|
{
|
||||||
HWND window = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
HWND window = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
||||||
if (window)
|
if (window)
|
||||||
{
|
{
|
||||||
|
if (cmd == 23) { DWORD pid{}; GetWindowThreadProcessId(window, &pid); AllowSetForegroundWindow(pid); }
|
||||||
COPYDATASTRUCT data{1, sizeof(cmd), &cmd};
|
COPYDATASTRUCT data{1, sizeof(cmd), &cmd};
|
||||||
DWORD_PTR result{};
|
DWORD_PTR result{};
|
||||||
SendMessageTimeoutW(window, WM_COPYDATA, 0, (LPARAM)&data, SMTO_ABORTIFHUNG, 1000, &result);
|
SendMessageTimeoutW(window, WM_COPYDATA, 0, (LPARAM)&data, SMTO_ABORTIFHUNG, 1000, &result);
|
||||||
|
|||||||
@@ -271,4 +271,119 @@ std::wstring recognizeText(const Clip &clip)
|
|||||||
WindowsDeleteString(text);
|
WindowsDeleteString(text);
|
||||||
return out;
|
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
|
} // namespace ks
|
||||||
|
|||||||
@@ -10,4 +10,8 @@ struct Pixels
|
|||||||
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
|
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
|
||||||
Format imageDib(const Pixels &);
|
Format imageDib(const Pixels &);
|
||||||
std::wstring recognizeText(const Clip &);
|
std::wstring recognizeText(const Clip &);
|
||||||
|
std::wstring recognizeText(const Clip &, const Settings &);
|
||||||
|
std::filesystem::path tesseractExecutable(const Settings &);
|
||||||
|
void validateTesseract(const Settings &);
|
||||||
|
std::wstring ocrProfile(const Settings &);
|
||||||
} // namespace ks
|
} // namespace ks
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "platform.h"
|
||||||
|
namespace ks
|
||||||
|
{
|
||||||
|
// Only the on-demand screenshot process owns this state; no resident App dependency.
|
||||||
|
struct ScreenshotHost {
|
||||||
|
HINSTANCE instance{};
|
||||||
|
HWND window{};
|
||||||
|
unsigned windows{};
|
||||||
|
bool (*notify)(const Clip &){};
|
||||||
|
bool autoOpenEditor = false;
|
||||||
|
};
|
||||||
|
inline constexpr UINT ScreenshotRequest=WM_APP+71, ScreenshotIdle=WM_APP+72;
|
||||||
|
void showScreenshot(ScreenshotHost &);
|
||||||
|
void showScreenshotEditor(ScreenshotHost &,const Clip &);
|
||||||
|
enum class LineStyle { Solid, Dotted, Dashed, DashDot };
|
||||||
|
enum class LineEnds { End, Start, Both, None };
|
||||||
|
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse };
|
||||||
|
struct ScreenshotOffset { double x, y; };
|
||||||
|
struct ScreenshotLink {
|
||||||
|
unsigned long long target{};
|
||||||
|
double u{}, v{}; // Relative boundary position, or fractional position along a path.
|
||||||
|
};
|
||||||
|
struct ScreenshotMark {
|
||||||
|
POINT from, to; int number; int radius = 15; int thickness = 3;
|
||||||
|
int fromNumber = 0, toNumber = 0;
|
||||||
|
std::vector<POINT> points;
|
||||||
|
COLORREF color = RGB(225,42,50);
|
||||||
|
bool freehand = false;
|
||||||
|
LineStyle style = LineStyle::Solid;
|
||||||
|
LineEnds ends = LineEnds::End;
|
||||||
|
ScreenshotShape shape = ScreenshotShape::None;
|
||||||
|
std::wstring text, fontName=L"Segoe UI";
|
||||||
|
int fontSize=24, fontWeight=FW_NORMAL;
|
||||||
|
bool fontItalic=false;
|
||||||
|
double rotation=0;
|
||||||
|
std::wstring label;
|
||||||
|
ScreenshotOffset labelOffset{0,-24}; // Along the line and perpendicular to it, in image pixels.
|
||||||
|
bool labelRotationManual=false;
|
||||||
|
unsigned long long id{};
|
||||||
|
ScreenshotLink fromLink, toLink;
|
||||||
|
bool isText() const { return !text.empty(); }
|
||||||
|
bool isHighlight() const { return !points.empty() && !freehand; }
|
||||||
|
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
|
||||||
|
};
|
||||||
|
class ScreenshotHistory {
|
||||||
|
std::deque<std::vector<ScreenshotMark>> past, future;
|
||||||
|
public:
|
||||||
|
void remember(const std::vector<ScreenshotMark> &);
|
||||||
|
bool undo(std::vector<ScreenshotMark> &);
|
||||||
|
bool redo(std::vector<ScreenshotMark> &);
|
||||||
|
bool canUndo() const { return !past.empty(); }
|
||||||
|
bool canRedo() const { return !future.empty(); }
|
||||||
|
};
|
||||||
|
void resolveScreenshotLinks(std::vector<ScreenshotMark> &);
|
||||||
|
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
|
||||||
|
std::vector<ScreenshotMark> &, double tolerance);
|
||||||
|
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label };
|
||||||
|
POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16);
|
||||||
|
ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height);
|
||||||
|
int nextScreenshotNumber(const std::vector<ScreenshotMark> &);
|
||||||
|
MarkPart hitScreenshotMark(const ScreenshotMark &, POINT, double tolerance);
|
||||||
|
ScreenshotMark dragScreenshotMark(const ScreenshotMark &, MarkPart, POINT origin, POINT position,
|
||||||
|
int width, int height, bool snapRotation=false);
|
||||||
|
POINT constrainScreenshotShape(ScreenshotShape, POINT anchor, POINT position, int width, int height);
|
||||||
|
RECT screenshotTextBounds(const ScreenshotMark &);
|
||||||
|
POINT screenshotTextRotationHandle(const ScreenshotMark &, double clearance=24);
|
||||||
|
ScreenshotMark screenshotArrowLabel(const ScreenshotMark &);
|
||||||
|
// Render at source resolution; never reads or writes the system clipboard.
|
||||||
|
Clip screenshotClip(HDC source, int width, int height, const std::vector<ScreenshotMark> &marks);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
#include "screenshot_notifications.h"
|
||||||
|
#include <atomic>
|
||||||
|
#include <shlobj.h>
|
||||||
|
#include <propkey.h>
|
||||||
|
#include <propvarutil.h>
|
||||||
|
#include <wrl/client.h>
|
||||||
|
namespace ks {
|
||||||
|
namespace {
|
||||||
|
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
|
||||||
|
constexpr GUID ActivatorId{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}};
|
||||||
|
}
|
||||||
|
namespace {
|
||||||
|
using Microsoft::WRL::ComPtr;
|
||||||
|
void check(HRESULT result) {
|
||||||
|
if(FAILED(result)) throw std::runtime_error("Screenshot registration failed: " + std::to_string((unsigned long)result));
|
||||||
|
}
|
||||||
|
void registryString(const std::wstring &path,const wchar_t *name,const std::wstring &value)
|
||||||
|
{
|
||||||
|
HKEY key{};
|
||||||
|
if(RegCreateKeyExW(HKEY_CURRENT_USER,path.c_str(),0,nullptr,0,KEY_SET_VALUE,nullptr,&key,nullptr)!=ERROR_SUCCESS)
|
||||||
|
throw std::runtime_error("Cannot register screenshot notifications");
|
||||||
|
auto result=RegSetValueExW(key,name,0,REG_SZ,(const BYTE *)value.c_str(),DWORD((value.size()+1)*sizeof(wchar_t)));
|
||||||
|
RegCloseKey(key);
|
||||||
|
if(result!=ERROR_SUCCESS) throw std::runtime_error("Cannot register screenshot notification activation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void registerScreenshotNotifications()
|
||||||
|
{
|
||||||
|
auto exe=(std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe").wstring();
|
||||||
|
if(!std::filesystem::exists(exe)) throw std::runtime_error("Screenshot helper is missing");
|
||||||
|
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",nullptr,L"\""+exe+L"\" --toast-activated");
|
||||||
|
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",L"ServerExecutable",exe);
|
||||||
|
auto identity=std::wstring(L"Software\\Classes\\AppUserModelId\\")+AppId;
|
||||||
|
registryString(identity,L"DisplayName",L"KSwitcher Screenshot");
|
||||||
|
registryString(identity,L"IconUri",exe);
|
||||||
|
registryString(identity,L"CustomActivator",L"{e3f78047-f0ad-49b6-9074-411931706167}");
|
||||||
|
// A shortcut supplies desktop notification identity and its COM activation callback.
|
||||||
|
ComPtr<IShellLinkW> link; check(CoCreateInstance(CLSID_ShellLink,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&link)));
|
||||||
|
check(link->SetPath(exe.c_str())); check(link->SetIconLocation(exe.c_str(),0));
|
||||||
|
ComPtr<IPropertyStore> properties; check(link.As(&properties));
|
||||||
|
PROPVARIANT id{}; check(InitPropVariantFromString(AppId,&id));
|
||||||
|
auto result=properties->SetValue(PKEY_AppUserModel_ID,id); PropVariantClear(&id); check(result);
|
||||||
|
GUID activator=ActivatorId;
|
||||||
|
PROPVARIANT clsid{}; clsid.vt=VT_CLSID; clsid.puuid=&activator;
|
||||||
|
check(properties->SetValue(PKEY_AppUserModel_ToastActivatorCLSID,clsid));
|
||||||
|
check(properties->Commit());
|
||||||
|
PWSTR programs{}; check(SHGetKnownFolderPath(FOLDERID_Programs,0,nullptr,&programs));
|
||||||
|
auto shortcut=std::filesystem::path(programs)/L"KSwitcher Screenshot.lnk"; CoTaskMemFree(programs);
|
||||||
|
ComPtr<IPersistFile> file; check(link.As(&file)); check(file->Save(shortcut.c_str(),TRUE));
|
||||||
|
// Notify the shell that notification identity/activation registration changed.
|
||||||
|
SHChangeNotify(SHCNE_ASSOCCHANGED,SHCNF_IDLIST,nullptr,nullptr);
|
||||||
|
}
|
||||||
|
bool validScreenshotToken(const std::wstring &id)
|
||||||
|
{
|
||||||
|
if(id.size()!=36) return false;
|
||||||
|
for(size_t i=0;i<id.size();++i) {
|
||||||
|
if(i==8 || i==13 || i==18 || i==23) { if(id[i]!=L'-') return false; }
|
||||||
|
else if(!((id[i]>=L'0' && id[i]<=L'9') || (id[i]>=L'a' && id[i]<=L'f') || (id[i]>=L'A' && id[i]<=L'F'))) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Windows SDK INotificationActivationCallback ABI (notificationactivationcallback.h).
|
||||||
|
constexpr GUID CallbackId{0x53e31837,0x6600,0x4a81,{0x93,0x95,0x75,0xcf,0xfe,0x74,0x6f,0x94}};
|
||||||
|
struct NotificationInput { LPCWSTR key,value; };
|
||||||
|
struct NotificationCallback : IUnknown {
|
||||||
|
virtual HRESULT STDMETHODCALLTYPE Activate(LPCWSTR,LPCWSTR,const NotificationInput *,ULONG)=0;
|
||||||
|
};
|
||||||
|
class Activator final : public NotificationCallback {
|
||||||
|
std::atomic<ULONG> references{1};
|
||||||
|
DWORD thread=GetCurrentThreadId();
|
||||||
|
std::wstring target;
|
||||||
|
bool quit;
|
||||||
|
public:
|
||||||
|
Activator(std::wstring path,bool shouldQuit):target(std::move(path)),quit(shouldQuit) {}
|
||||||
|
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID id,void **out) override {
|
||||||
|
if(!out) return E_POINTER;
|
||||||
|
*out=nullptr;
|
||||||
|
if(id!=IID_IUnknown && id!=CallbackId) return E_NOINTERFACE;
|
||||||
|
*out=static_cast<NotificationCallback *>(this); AddRef(); return S_OK;
|
||||||
|
}
|
||||||
|
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
|
||||||
|
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
|
||||||
|
HRESULT STDMETHODCALLTYPE Activate(LPCWSTR app,LPCWSTR args,const NotificationInput *,ULONG) override {
|
||||||
|
if(!app || std::wstring(app)!=AppId || !args) return E_INVALIDARG;
|
||||||
|
const bool update=std::wstring(args)==L"check-update";
|
||||||
|
if(!update && !validScreenshotToken(args)) return E_INVALIDARG;
|
||||||
|
auto path=update ? (std::filesystem::path(target).parent_path()/L"KSwitcher.exe").wstring() : target;
|
||||||
|
auto command=update ? L"\""+path+L"\" --check-update" :
|
||||||
|
L"\""+path+L"\" --open \"kswitcher-screenshot:"+args+L"\"";
|
||||||
|
STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{};
|
||||||
|
auto ok=CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,nullptr,nullptr,&startup,&process);
|
||||||
|
auto result=ok?S_OK:HRESULT_FROM_WIN32(GetLastError());
|
||||||
|
if(ok) {
|
||||||
|
AllowSetForegroundWindow(process.dwProcessId); ResumeThread(process.hThread);
|
||||||
|
CloseHandle(process.hThread); CloseHandle(process.hProcess);
|
||||||
|
}
|
||||||
|
if(quit) PostThreadMessageW(thread,WM_QUIT,0,0);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
class ActivatorFactory final : public IClassFactory {
|
||||||
|
std::atomic<ULONG> references{1};
|
||||||
|
std::wstring target;
|
||||||
|
bool quit;
|
||||||
|
public:
|
||||||
|
ActivatorFactory(std::wstring path,bool shouldQuit):target(std::move(path)),quit(shouldQuit) {}
|
||||||
|
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID id,void **out) override {
|
||||||
|
if(!out) return E_POINTER;
|
||||||
|
*out=nullptr;
|
||||||
|
if(id!=IID_IUnknown && id!=IID_IClassFactory) return E_NOINTERFACE;
|
||||||
|
*out=static_cast<IClassFactory *>(this); AddRef(); return S_OK;
|
||||||
|
}
|
||||||
|
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
|
||||||
|
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
|
||||||
|
HRESULT STDMETHODCALLTYPE CreateInstance(IUnknown *outer,REFIID id,void **out) override {
|
||||||
|
if(outer) return CLASS_E_NOAGGREGATION;
|
||||||
|
auto object=new Activator(target,quit); auto result=object->QueryInterface(id,out); object->Release(); return result;
|
||||||
|
}
|
||||||
|
HRESULT STDMETHODCALLTYPE LockServer(BOOL) override { return S_OK; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
int runScreenshotToastActivator()
|
||||||
|
{
|
||||||
|
if(FAILED(CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED))) return 1;
|
||||||
|
MSG message{}; PeekMessageW(&message,nullptr,0,0,PM_NOREMOVE);
|
||||||
|
auto factory=new ActivatorFactory(executable(),true); DWORD registration{};
|
||||||
|
auto result=CoRegisterClassObject(ActivatorId,factory,CLSCTX_LOCAL_SERVER,REGCLS_MULTIPLEUSE,®istration);
|
||||||
|
factory->Release();
|
||||||
|
if(SUCCEEDED(result)) {
|
||||||
|
auto timer=SetTimer(nullptr,0,60000,[](HWND,UINT,UINT_PTR,DWORD){PostQuitMessage(0);});
|
||||||
|
while(GetMessageW(&message,nullptr,0,0)>0) { TranslateMessage(&message); DispatchMessageW(&message); }
|
||||||
|
if(timer) KillTimer(nullptr,timer);
|
||||||
|
CoRevokeClassObject(registration);
|
||||||
|
}
|
||||||
|
CoUninitialize(); return FAILED(result)?1:0;
|
||||||
|
}
|
||||||
|
DWORD startScreenshotActivationListener()
|
||||||
|
{
|
||||||
|
auto target=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
||||||
|
auto factory=new ActivatorFactory(target.wstring(),false);
|
||||||
|
DWORD registration{};
|
||||||
|
auto result=CoRegisterClassObject(ActivatorId,factory,CLSCTX_LOCAL_SERVER,REGCLS_MULTIPLEUSE,®istration);
|
||||||
|
factory->Release();
|
||||||
|
return SUCCEEDED(result)?registration:0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#include "app.h"
|
||||||
|
|
||||||
|
namespace ks {
|
||||||
|
void launchScreenshot(App &app)
|
||||||
|
{
|
||||||
|
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
||||||
|
if (!std::filesystem::exists(path))
|
||||||
|
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
|
||||||
|
auto command=L"\""+path.wstring()+L"\" --auto-open-editor="+
|
||||||
|
(app.settings.screenshotAutoOpenEditor?L"1":L"0");
|
||||||
|
if(app.settings.writeLog) command+=L" --write-log";
|
||||||
|
STARTUPINFOW startup{sizeof(startup)};
|
||||||
|
PROCESS_INFORMATION process{};
|
||||||
|
// Grant foreground rights before the child's first selection window opens.
|
||||||
|
if (!CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,
|
||||||
|
nullptr,path.parent_path().c_str(),&startup,&process))
|
||||||
|
throw std::runtime_error(utf8(lastError(L"Cannot start screenshot editor")));
|
||||||
|
AllowSetForegroundWindow(process.dwProcessId);
|
||||||
|
ResumeThread(process.hThread);
|
||||||
|
CloseHandle(process.hThread);
|
||||||
|
CloseHandle(process.hProcess);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
#include "screenshot.h"
|
||||||
|
#include "screenshot_notifications.h"
|
||||||
|
#include <commctrl.h>
|
||||||
|
#include <shellapi.h>
|
||||||
|
#include <roapi.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr wchar_t ControllerClass[]=L"KSwitcher.Screenshot.Controller";
|
||||||
|
LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM lp)
|
||||||
|
{
|
||||||
|
auto host=(ks::ScreenshotHost *)GetWindowLongPtrW(window,GWLP_USERDATA);
|
||||||
|
if(message==WM_NCCREATE) {
|
||||||
|
host=(ks::ScreenshotHost *)((CREATESTRUCTW *)lp)->lpCreateParams;
|
||||||
|
SetWindowLongPtrW(window,GWLP_USERDATA,(LONG_PTR)host);
|
||||||
|
}
|
||||||
|
if(host && message==ks::ScreenshotRequest) {
|
||||||
|
host->autoOpenEditor=(wp&1)!=0;
|
||||||
|
ks::setLoggingEnabled((wp&2)!=0);
|
||||||
|
try { ks::showScreenshot(*host); }
|
||||||
|
catch(const std::exception &e) {
|
||||||
|
MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screen capture",MB_OK|MB_ICONERROR);
|
||||||
|
PostMessageW(window,ks::ScreenshotIdle,0,0);
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
if(host && message==WM_COPYDATA) {
|
||||||
|
auto data=(COPYDATASTRUCT *)lp;
|
||||||
|
if(!data || data->dwData!=73 || data->cbData!=37*sizeof(wchar_t) || !data->lpData) return FALSE;
|
||||||
|
std::wstring token((const wchar_t *)data->lpData,36);
|
||||||
|
if(!ks::validScreenshotToken(token)) return FALSE;
|
||||||
|
try { ks::showScreenshotEditor(*host,ks::loadNotifiedScreenshot(token)); }
|
||||||
|
catch(const std::exception &e) {
|
||||||
|
MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screenshot",MB_OK|MB_ICONINFORMATION);
|
||||||
|
PostMessageW(window,ks::ScreenshotIdle,0,0);
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
if(host && message==ks::ScreenshotIdle && !host->windows) {
|
||||||
|
DestroyWindow(window);
|
||||||
|
PostQuitMessage(0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return DefWindowProcW(window,message,wp,lp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
|
||||||
|
{
|
||||||
|
std::wstring token;
|
||||||
|
bool autoOpenEditor=false, writeLog=false;
|
||||||
|
int argc{}; auto argv=CommandLineToArgvW(GetCommandLineW(),&argc);
|
||||||
|
if(argc>1 && std::wstring(argv[argc-1])==L"--write-log") {
|
||||||
|
writeLog=true; --argc;
|
||||||
|
ks::setLoggingEnabled(true);
|
||||||
|
}
|
||||||
|
if(argc>=2 && std::wstring(argv[1])==L"--toast-activated") {
|
||||||
|
LocalFree(argv); return ks::runScreenshotToastActivator();
|
||||||
|
}
|
||||||
|
if(argc==2 && std::wstring(argv[1])==L"--register-notifications") {
|
||||||
|
LocalFree(argv); CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||||
|
int result=0; try { ks::registerScreenshotNotifications(); } catch(...) { result=1; }
|
||||||
|
CoUninitialize(); return result;
|
||||||
|
}
|
||||||
|
if(argc==2 && (std::wstring(argv[1])==L"--auto-open-editor=0" ||
|
||||||
|
std::wstring(argv[1])==L"--auto-open-editor=1")) {
|
||||||
|
autoOpenEditor=std::wstring(argv[1])==L"--auto-open-editor=1";
|
||||||
|
} else if(argc>1) {
|
||||||
|
if(argc==3 && std::wstring(argv[1])==L"--open") {
|
||||||
|
std::wstring uri=argv[2]; const std::wstring prefix=L"kswitcher-screenshot:";
|
||||||
|
if(uri.starts_with(prefix)) token=uri.substr(prefix.size());
|
||||||
|
}
|
||||||
|
if(!ks::validScreenshotToken(token)) { LocalFree(argv); return 1; }
|
||||||
|
}
|
||||||
|
LocalFree(argv);
|
||||||
|
COPYDATASTRUCT open{73,DWORD((token.size()+1)*sizeof(wchar_t)),token.data()};
|
||||||
|
HANDLE mutex=CreateMutexW(nullptr,FALSE,L"Local\\KSwitcher.Screenshot.Singleton");
|
||||||
|
if(!mutex) return 1;
|
||||||
|
// Wait through startup/shutdown races; the controller accepts requests only while alive.
|
||||||
|
bool acquired=false;
|
||||||
|
for(int attempt=0;attempt<100;++attempt) {
|
||||||
|
auto wait=WaitForSingleObject(mutex,0);
|
||||||
|
if(wait==WAIT_OBJECT_0 || wait==WAIT_ABANDONED) { acquired=true; break; }
|
||||||
|
if(auto existing=FindWindowW(ControllerClass,nullptr)) {
|
||||||
|
DWORD pid{}; GetWindowThreadProcessId(existing,&pid);
|
||||||
|
AllowSetForegroundWindow(pid);
|
||||||
|
DWORD_PTR accepted{};
|
||||||
|
if(SendMessageTimeoutW(existing,token.empty()?ks::ScreenshotRequest:WM_COPYDATA,token.empty()?(autoOpenEditor?1:0)|(writeLog?2:0):0,
|
||||||
|
token.empty()?0:(LPARAM)&open,SMTO_ABORTIFHUNG,2000,&accepted) && accepted) {
|
||||||
|
CloseHandle(mutex); return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Sleep(50);
|
||||||
|
}
|
||||||
|
if(!acquired) { CloseHandle(mutex); return 1; }
|
||||||
|
RoInitialize(RO_INIT_SINGLETHREADED);
|
||||||
|
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_BAR_CLASSES};
|
||||||
|
InitCommonControlsEx(&controls);
|
||||||
|
ks::ScreenshotHost host{instance};
|
||||||
|
host.notify=ks::notifyScreenshot;
|
||||||
|
WNDCLASSW cls{}; cls.hInstance=instance; cls.lpfnWndProc=controller; cls.lpszClassName=ControllerClass;
|
||||||
|
RegisterClassW(&cls);
|
||||||
|
host.window=CreateWindowW(ControllerClass,L"KSwitcher Screenshot",0,0,0,0,0,nullptr,nullptr,instance,&host);
|
||||||
|
int result=1;
|
||||||
|
if(host.window) {
|
||||||
|
// Toast protocol activation may run unelevated while the editor was launched by an elevated resident.
|
||||||
|
// WM_COPYDATA accepts only a validated cache GUID, never a path or executable command.
|
||||||
|
ChangeWindowMessageFilterEx(host.window,WM_COPYDATA,MSGFLT_ALLOW,nullptr);
|
||||||
|
if(token.empty()) PostMessageW(host.window,ks::ScreenshotRequest,(autoOpenEditor?1:0)|(writeLog?2:0),0);
|
||||||
|
else SendMessageW(host.window,WM_COPYDATA,0,(LPARAM)&open);
|
||||||
|
MSG message{};
|
||||||
|
while((result=GetMessageW(&message,nullptr,0,0))>0) {
|
||||||
|
TranslateMessage(&message); DispatchMessageW(&message);
|
||||||
|
}
|
||||||
|
result=result<0?1:0;
|
||||||
|
}
|
||||||
|
RoUninitialize();
|
||||||
|
ReleaseMutex(mutex); CloseHandle(mutex);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
#include "screenshot_notifications.h"
|
||||||
|
#include "ocr.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <aclapi.h>
|
||||||
|
#include <sddl.h>
|
||||||
|
#include <shlobj.h>
|
||||||
|
#include <propkey.h>
|
||||||
|
#include <propvarutil.h>
|
||||||
|
#include <roapi.h>
|
||||||
|
#include <winstring.h>
|
||||||
|
#include <wincodec.h>
|
||||||
|
#ifdef __MINGW32__
|
||||||
|
// This SDK aliases BYTE and boolean, producing duplicate template specializations.
|
||||||
|
// Notifications use the boolean specialization; IReference<BYTE> is unused here.
|
||||||
|
#define ____FIReference_1_BYTE_INTERFACE_DEFINED__
|
||||||
|
#endif
|
||||||
|
#include <windows.ui.notifications.h>
|
||||||
|
#include <windows.data.xml.dom.h>
|
||||||
|
#include <wrl/client.h>
|
||||||
|
|
||||||
|
namespace ks {
|
||||||
|
namespace {
|
||||||
|
using Microsoft::WRL::ComPtr;
|
||||||
|
namespace Notifications=ABI::Windows::UI::Notifications;
|
||||||
|
namespace Xml=ABI::Windows::Data::Xml::Dom;
|
||||||
|
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
|
||||||
|
std::string notificationError;
|
||||||
|
void check(HRESULT result) { if(FAILED(result)) { char message[100]; snprintf(message,sizeof(message),"Windows screenshot notification failed (0x%08lx)",(unsigned long)result); throw std::runtime_error(message); } }
|
||||||
|
struct String {
|
||||||
|
HSTRING value{};
|
||||||
|
explicit String(const std::wstring &s) { check(WindowsCreateString(s.c_str(),(UINT32)s.size(),&value)); }
|
||||||
|
~String() { WindowsDeleteString(value); }
|
||||||
|
};
|
||||||
|
std::filesystem::path cacheDirectory()
|
||||||
|
{
|
||||||
|
PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_LocalAppData,0,nullptr,&folder));
|
||||||
|
auto path=std::filesystem::path(folder)/L"KSwitcher"/L"ScreenshotNotifications";
|
||||||
|
CoTaskMemFree(folder); std::filesystem::create_directories(path); return path;
|
||||||
|
}
|
||||||
|
std::filesystem::path previewDirectory()
|
||||||
|
{
|
||||||
|
HANDLE token{};
|
||||||
|
if(!OpenProcessToken(GetCurrentProcess(),TOKEN_QUERY,&token)) check(HRESULT_FROM_WIN32(GetLastError()));
|
||||||
|
DWORD size{}; GetTokenInformation(token,TokenUser,nullptr,0,&size);
|
||||||
|
std::vector<BYTE> data(size);
|
||||||
|
BOOL read=GetTokenInformation(token,TokenUser,data.data(),size,&size);
|
||||||
|
DWORD error=read?ERROR_SUCCESS:GetLastError(); CloseHandle(token); check(HRESULT_FROM_WIN32(error));
|
||||||
|
auto user=reinterpret_cast<TOKEN_USER *>(data.data())->User.Sid;
|
||||||
|
LPWSTR sidText{}; if(!ConvertSidToStringSidW(user,&sidText)) check(HRESULT_FROM_WIN32(GetLastError()));
|
||||||
|
std::wstring sid(sidText); LocalFree(sidText);
|
||||||
|
PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_ProgramData,0,nullptr,&folder));
|
||||||
|
auto path=std::filesystem::path(folder)/(L"KSwitcher.ScreenshotPreviews-"+sid); CoTaskMemFree(folder);
|
||||||
|
// The notification broker cannot load previews from some profile directories.
|
||||||
|
// Keep this cache outside the profile, private to the current user and SYSTEM.
|
||||||
|
auto sddl=L"O:"+sid+L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;"+sid+L")";
|
||||||
|
PSECURITY_DESCRIPTOR security{};
|
||||||
|
if(!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(),SDDL_REVISION_1,&security,nullptr))
|
||||||
|
check(HRESULT_FROM_WIN32(GetLastError()));
|
||||||
|
SECURITY_ATTRIBUTES attributes{sizeof(attributes),security,FALSE};
|
||||||
|
BOOL created=CreateDirectoryW(path.c_str(),&attributes);
|
||||||
|
error=created?ERROR_SUCCESS:GetLastError();
|
||||||
|
LocalFree(security);
|
||||||
|
if(error!=ERROR_ALREADY_EXISTS) check(HRESULT_FROM_WIN32(error));
|
||||||
|
// Do not use a pre-created directory or junction controlled by another user.
|
||||||
|
DWORD flags=GetFileAttributesW(path.c_str());
|
||||||
|
if(flags==INVALID_FILE_ATTRIBUTES || !(flags&FILE_ATTRIBUTE_DIRECTORY) || (flags&FILE_ATTRIBUTE_REPARSE_POINT))
|
||||||
|
throw std::runtime_error("Invalid screenshot preview directory");
|
||||||
|
PSID owner{}; PSECURITY_DESCRIPTOR existing{};
|
||||||
|
error=GetNamedSecurityInfoW(const_cast<wchar_t *>(path.c_str()),SE_FILE_OBJECT,OWNER_SECURITY_INFORMATION,
|
||||||
|
&owner,nullptr,nullptr,nullptr,&existing);
|
||||||
|
check(HRESULT_FROM_WIN32(error));
|
||||||
|
bool owned=EqualSid(owner,user); LocalFree(existing);
|
||||||
|
if(!owned) throw std::runtime_error("Screenshot preview directory belongs to another user");
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
void cleanCache(const std::filesystem::path &path)
|
||||||
|
{
|
||||||
|
std::vector<std::filesystem::directory_entry> files;
|
||||||
|
for(const auto &file:std::filesystem::directory_iterator(path))
|
||||||
|
if(file.is_regular_file() && validScreenshotToken(file.path().stem().wstring()) &&
|
||||||
|
(file.path().extension()==L".capture" || file.path().extension()==L".png")) files.push_back(file);
|
||||||
|
std::sort(files.begin(),files.end(),[](const auto &a,const auto &b){return a.last_write_time()>b.last_write_time();});
|
||||||
|
uintmax_t size=0;
|
||||||
|
for(size_t i=0;i<files.size();++i) {
|
||||||
|
size+=files[i].file_size();
|
||||||
|
if(i>=40 || size>256*1024*1024 || std::filesystem::file_time_type::clock::now()-files[i].last_write_time()>std::chrono::hours(24)) {
|
||||||
|
std::error_code ignored; std::filesystem::remove(files[i].path(),ignored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void savePreview(const Clip &clip,const std::filesystem::path &path)
|
||||||
|
{
|
||||||
|
auto pixels=decodeImage(clip,360,220);
|
||||||
|
for(size_t i=3;i<pixels.bgra.size();i+=4) pixels.bgra[i]=255;
|
||||||
|
ComPtr<IWICImagingFactory> factory; check(CoCreateInstance(CLSID_WICImagingFactory,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&factory)));
|
||||||
|
ComPtr<IWICStream> stream; check(factory->CreateStream(&stream)); check(stream->InitializeFromFilename(path.c_str(),GENERIC_WRITE));
|
||||||
|
ComPtr<IWICBitmapEncoder> encoder; check(factory->CreateEncoder(GUID_ContainerFormatPng,nullptr,&encoder));
|
||||||
|
check(encoder->Initialize(stream.Get(),WICBitmapEncoderNoCache));
|
||||||
|
ComPtr<IWICBitmapFrameEncode> frame; check(encoder->CreateNewFrame(&frame,nullptr)); check(frame->Initialize(nullptr));
|
||||||
|
check(frame->SetSize(pixels.width,pixels.height));
|
||||||
|
auto format=GUID_WICPixelFormat32bppBGRA; check(frame->SetPixelFormat(&format));
|
||||||
|
if(format!=GUID_WICPixelFormat32bppBGRA) throw std::runtime_error("Unsupported preview pixel format");
|
||||||
|
check(frame->WritePixels(pixels.height,pixels.width*4,(UINT)pixels.bgra.size(),pixels.bgra.data()));
|
||||||
|
check(frame->Commit()); check(encoder->Commit());
|
||||||
|
}
|
||||||
|
std::wstring escapeXml(const std::wstring &s)
|
||||||
|
{
|
||||||
|
std::wstring result;
|
||||||
|
for(auto ch:s) { if(ch==L'&') result+=L"&"; else if(ch==L'\"') result+=L"""; else if(ch==L'<') result+=L"<"; else result+=ch; }
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Clip loadNotifiedScreenshot(const std::wstring &id)
|
||||||
|
{
|
||||||
|
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
|
||||||
|
auto directory=cacheDirectory(); cleanCache(directory);
|
||||||
|
auto path=directory/(id+L".capture");
|
||||||
|
if(!std::filesystem::exists(path)) throw std::runtime_error("This screenshot notification has expired. Take a new screenshot.");
|
||||||
|
return Clip{{{CF_DIB,L"",unprotect(readFile(path,160*1024*1024))}}};
|
||||||
|
}
|
||||||
|
bool notifyScreenshot(const Clip &clip)
|
||||||
|
{
|
||||||
|
notificationError.clear();
|
||||||
|
const char *stage="registration";
|
||||||
|
try {
|
||||||
|
registerScreenshotNotifications();
|
||||||
|
check(SetCurrentProcessExplicitAppUserModelID(AppId));
|
||||||
|
stage="notification manager";
|
||||||
|
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
|
||||||
|
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
|
||||||
|
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
|
||||||
|
ComPtr<Notifications::IToastNotifier> notifier; String appId(AppId);
|
||||||
|
stage="notifier";
|
||||||
|
check(manager->CreateToastNotifierWithId(appId.value,¬ifier));
|
||||||
|
stage="settings";
|
||||||
|
Notifications::NotificationSetting setting{};
|
||||||
|
auto settingResult=notifier->get_Setting(&setting);
|
||||||
|
// A first-time sender has no settings record until its first notification.
|
||||||
|
if(settingResult!=HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) check(settingResult);
|
||||||
|
if(setting!=Notifications::NotificationSetting_Enabled) throw std::runtime_error("Screenshot notifications are disabled in Windows settings");
|
||||||
|
stage="cache";
|
||||||
|
auto directory=cacheDirectory(); cleanCache(directory);
|
||||||
|
GUID guid{}; check(CoCreateGuid(&guid)); wchar_t value[40]{}; StringFromGUID2(guid,value,40);
|
||||||
|
std::wstring id(value+1,36);
|
||||||
|
auto dib=std::find_if(clip.formats.begin(),clip.formats.end(),[](const auto &format){return format.id==CF_DIB;});
|
||||||
|
if(dib==clip.formats.end() || dib->data.size()>128*1024*1024) return false;
|
||||||
|
atomicWrite(directory/(id+L".capture"),protect(dib->data));
|
||||||
|
stage="preview";
|
||||||
|
auto previews=previewDirectory(); cleanCache(previews);
|
||||||
|
auto preview=previews/(id+L".png"); savePreview(clip,preview);
|
||||||
|
cleanCache(previews);
|
||||||
|
cleanCache(directory);
|
||||||
|
stage="XML";
|
||||||
|
String documentClass(L"Windows.Data.Xml.Dom.XmlDocument"); ComPtr<IInspectable> documentInstance;
|
||||||
|
check(RoActivateInstance(documentClass.value,&documentInstance));
|
||||||
|
ComPtr<Xml::IXmlDocument> document; check(documentInstance.As(&document));
|
||||||
|
ComPtr<Xml::IXmlDocumentIO> documentIO; check(document.As(&documentIO));
|
||||||
|
auto uri=L"file:///"+preview.generic_wstring();
|
||||||
|
String content(L"<toast launch=\""+id+L"\"><visual><binding template=\"ToastGeneric\">"
|
||||||
|
L"<text>Screenshot copied</text><text>Click to edit and annotate</text><image id=\"1\" placement=\"appLogoOverride\" alt=\"Screenshot preview\" src=\""+escapeXml(uri)+L"\"/></binding></visual><audio silent=\"true\"/></toast>");
|
||||||
|
check(documentIO->LoadXml(content.value));
|
||||||
|
stage="toast factory";
|
||||||
|
ComPtr<Notifications::IToastNotificationFactory> toastFactory; String toastClass(L"Windows.UI.Notifications.ToastNotification");
|
||||||
|
check(RoGetActivationFactory(toastClass.value,IID_PPV_ARGS(&toastFactory)));
|
||||||
|
ComPtr<Notifications::IToastNotification> toast; check(toastFactory->CreateToastNotification(document.Get(),&toast));
|
||||||
|
ComPtr<ABI::Windows::Foundation::IPropertyValueStatics> values;
|
||||||
|
String propertyClass(L"Windows.Foundation.PropertyValue");
|
||||||
|
check(RoGetActivationFactory(propertyClass.value,IID_PPV_ARGS(&values)));
|
||||||
|
FILETIME now{}; GetSystemTimeAsFileTime(&now);
|
||||||
|
ULARGE_INTEGER timestamp{}; timestamp.LowPart=now.dwLowDateTime; timestamp.HighPart=now.dwHighDateTime;
|
||||||
|
ABI::Windows::Foundation::DateTime expires{(INT64)(timestamp.QuadPart+24ull*60*60*10000000)};
|
||||||
|
ComPtr<IInspectable> boxed; check(values->CreateDateTime(expires,&boxed));
|
||||||
|
ComPtr<ABI::Windows::Foundation::IReference<ABI::Windows::Foundation::DateTime>> expiration;
|
||||||
|
check(boxed.As(&expiration)); check(toast->put_ExpirationTime(expiration.Get()));
|
||||||
|
stage="show";
|
||||||
|
check(notifier->Show(toast.Get()));
|
||||||
|
return true;
|
||||||
|
} catch(const std::exception &e) { notificationError=std::string(stage)+": "+e.what(); log(wide(notificationError)); return false; } // Clipboard is already populated; fall back to the editor.
|
||||||
|
}
|
||||||
|
std::string screenshotNotificationError() { return notificationError; }
|
||||||
|
|
||||||
|
std::wstring updateToastXml(const std::wstring &version)
|
||||||
|
{
|
||||||
|
return L"<toast launch=\"check-update\"><visual><binding template=\"ToastGeneric\">"
|
||||||
|
L"<text>KSwitcher update available</text><text>Version " + escapeXml(version) +
|
||||||
|
L" is available. Click to review and install.</text></binding></visual>"
|
||||||
|
L"<actions><action content=\"Review update\" arguments=\"check-update\" activationType=\"foreground\"/>"
|
||||||
|
L"<action content=\"Later\" arguments=\"dismiss\" activationType=\"system\"/></actions></toast>";
|
||||||
|
}
|
||||||
|
bool notifyUpdateAvailable(const std::wstring &version)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Reuse the screenshot notification identity, Windows settings and COM activator.
|
||||||
|
registerScreenshotNotifications();
|
||||||
|
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
|
||||||
|
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
|
||||||
|
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
|
||||||
|
ComPtr<Notifications::IToastNotifier> notifier; String appId(AppId);
|
||||||
|
check(manager->CreateToastNotifierWithId(appId.value,¬ifier));
|
||||||
|
Notifications::NotificationSetting setting{};
|
||||||
|
auto settingsResult=notifier->get_Setting(&setting);
|
||||||
|
if(settingsResult!=HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) check(settingsResult);
|
||||||
|
if(setting!=Notifications::NotificationSetting_Enabled)
|
||||||
|
throw std::runtime_error("Windows notifications for KSwitcher Screenshot are disabled");
|
||||||
|
String documentClass(L"Windows.Data.Xml.Dom.XmlDocument"); ComPtr<IInspectable> instance;
|
||||||
|
check(RoActivateInstance(documentClass.value,&instance));
|
||||||
|
ComPtr<Xml::IXmlDocument> document; check(instance.As(&document));
|
||||||
|
ComPtr<Xml::IXmlDocumentIO> io; check(document.As(&io));
|
||||||
|
String content(updateToastXml(version)); check(io->LoadXml(content.value));
|
||||||
|
ComPtr<Notifications::IToastNotificationFactory> factory; String toastClass(L"Windows.UI.Notifications.ToastNotification");
|
||||||
|
check(RoGetActivationFactory(toastClass.value,IID_PPV_ARGS(&factory)));
|
||||||
|
ComPtr<Notifications::IToastNotification> toast; check(factory->CreateToastNotification(document.Get(),&toast));
|
||||||
|
// Repeated submissions replace the existing update card in Notification Center.
|
||||||
|
ComPtr<Notifications::IToastNotification2> tagged; check(toast.As(&tagged));
|
||||||
|
String tag(L"update"),group(L"KSwitcher"); check(tagged->put_Tag(tag.value)); check(tagged->put_Group(group.value));
|
||||||
|
check(notifier->Show(toast.Get()));
|
||||||
|
return true;
|
||||||
|
} catch(const std::exception &e) { log(L"Update toast: "+wide(e.what())); return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "platform.h"
|
||||||
|
namespace ks {
|
||||||
|
bool notifyScreenshot(const Clip &);
|
||||||
|
std::string screenshotNotificationError();
|
||||||
|
void registerScreenshotNotifications();
|
||||||
|
int runScreenshotToastActivator();
|
||||||
|
DWORD startScreenshotActivationListener();
|
||||||
|
bool validScreenshotToken(const std::wstring &);
|
||||||
|
Clip loadNotifiedScreenshot(const std::wstring &);
|
||||||
|
}
|
||||||