Compare commits
14 Commits
v0.1.0
...
release-v0.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 21a09e33be | |||
| 3dbd7317f1 | |||
| 510d0ed211 | |||
| dd9629f8fd | |||
| ccaa8dad3f | |||
| d1eabfca88 | |||
| dc615d0223 | |||
| e5ff9495e4 | |||
| eb30701e07 | |||
| 855719863e | |||
| 1093bb5b2e | |||
| cf18a0a3c5 | |||
| 076210dc9c | |||
| 5531c49224 |
@@ -1,36 +1,65 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.1.0 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.1 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp)
|
||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp)
|
||||
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
||||
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
||||
target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(kscore PUBLIC user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
|
||||
target_link_libraries(KSwitcher PRIVATE kscore)
|
||||
add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
set_target_properties(KSwitcherConfig PROPERTIES OUTPUT_NAME "KSwitcher.Config")
|
||||
target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP)
|
||||
target_link_libraries(KSwitcherConfig PRIVATE kscore)
|
||||
add_executable(KSwitcherTests tests/tests.cpp)
|
||||
target_link_libraries(KSwitcherTests PRIVATE kscore)
|
||||
add_executable(KSwitcherIntegration tests/integration.cpp src/app.cpp src/ui.cpp resources/app.rc)
|
||||
add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
|
||||
add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcherInputTests PRIVATE kscore)
|
||||
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)
|
||||
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
|
||||
if(MSVC)
|
||||
set_property(TARGET kscore KSwitcher KSwitcherTests KSwitcherIntegration KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
target_compile_options(kscore PRIVATE /W4 /utf-8)
|
||||
target_compile_options(KSwitcher PRIVATE /W4 /utf-8)
|
||||
target_compile_options(KSwitcherConfig PRIVATE /W4 /utf-8)
|
||||
target_compile_options(KSwitcherScreenshot PRIVATE /W4 /utf-8)
|
||||
else()
|
||||
target_link_options(KSwitcher PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherConfig PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherScreenshot PRIVATE -municode -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherIntegration PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherInputTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
enable_testing()
|
||||
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
||||
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
||||
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
|
||||
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
|
||||
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
install(TARGETS KSwitcher RUNTIME DESTINATION .)
|
||||
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .)
|
||||
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
|
||||
|
||||
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
|
||||
|
||||
@@ -19,3 +19,50 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
Third-party toolbar icons: Lucide (https://lucide.dev)
|
||||
|
||||
ISC License
|
||||
|
||||
Copyright (c) 2026 Lucide Icons and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
The following Lucide icons are derived from the Feather project:
|
||||
|
||||
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
|
||||
|
||||
The MIT License (MIT) (for the icons listed above)
|
||||
|
||||
Copyright (c) 2013-present Cole Bemis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# KSwitcher
|
||||
|
||||
Native C++20 / Win32 rewrite of the Windows functionality of Typlo. Windows 10/11 x64.
|
||||
Native C++20 / Win32 rewrite of the Windows functionality of [Typlo](https://github.com/bitcodepro/Typlo). Windows 10/11 x64.
|
||||
No .NET, Avalonia, WebView, Qt, external graphics engine, or separately installed C++ runtime.
|
||||
The original Typlo project and its data remain separate.
|
||||
The original [Typlo](https://github.com/bitcodepro/Typlo) project and its data remain separate.
|
||||
|
||||
**[User guide / Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki)** — setup, features, shortcuts, dictionaries, screenshots and troubleshooting 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
|
||||
|
||||
@@ -10,6 +16,29 @@ Run `build/KSwitcher.exe`, or extract `dist/KSwitcher-win-x64.zip` and run the e
|
||||
The build also refreshes the release copy in `build/release/`. Exit KSwitcher before rebuilding.
|
||||
The application lives in the notification area. Right-click its icon for Settings, Snippets and history.
|
||||
Double-click the tray icon to open Settings, the bold default menu item.
|
||||
Settings and the snippet editor run on demand in **KSwitcher.Config.exe**, beside the main executable.
|
||||
Repeated launches activate the existing configurator; closing its last window exits that process.
|
||||
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
|
||||
load clipboard history, dictionaries or global input hooks. Clipboard history and the log viewer remain
|
||||
in the resident app. Screen capture and all screenshot editors run on demand in
|
||||
**KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same
|
||||
process. Closing the last editor (or cancelling the only selection) exits that process. It does not
|
||||
load resident settings, dictionaries, clipboard history or keyboard hooks. Editor windows have
|
||||
stable numbered titles within that process. Keep all three executables together when copying or
|
||||
updating the application. Close screenshot editors before installing an update.
|
||||
If settings change in the tray while the configurator is open, a conflicting Save is rejected; reopen
|
||||
the configurator to load the latest settings.
|
||||
Updates from v0.3.x–v0.4.1 first install the compatible **v0.4.2 bridge** through
|
||||
Update in the tray menu. Select Update again to install the full current release;
|
||||
manual extraction is not required. Future full releases use stable `release-vX.Y.Z`
|
||||
tags, which the bridge and newer clients recognize. Older clients ignore those tags
|
||||
and continue to find the bridge instead of skipping it. Versions v0.2.3 and earlier
|
||||
still require the previously documented initial configurator migration.
|
||||
|
||||
Modern ZIPs contain `release-files.json` (schema, version, file paths, sizes and SHA-256
|
||||
hashes). The updater validates that every packaged file is declared, all hashes match,
|
||||
and paths stay within the installation directory. `data/` is protected; existing
|
||||
dictionaries are preserved. Adding a new program file no longer needs a new updater.
|
||||
In Settings, enable **Start with Windows** and **Start with Windows as administrator**
|
||||
to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing
|
||||
the elevated task requests UAC approval if the app is not already elevated. No password is stored;
|
||||
@@ -17,7 +46,7 @@ the task runs in the signed-in user's interactive session, including on battery
|
||||
Disabling the administrator option restores ordinary per-user startup; disabling startup stops
|
||||
future automatic launches. Saving unrelated settings does not recreate startup entries.
|
||||
`KSwitcher.exe --settings` opens Settings, including when the app is already running.
|
||||
Exit Typlo when using overlapping shortcuts to avoid competing keyboard hooks.
|
||||
Exit [Typlo](https://github.com/bitcodepro/Typlo) when using overlapping shortcuts to avoid competing keyboard hooks.
|
||||
|
||||
### Change a shortcut
|
||||
|
||||
@@ -34,6 +63,117 @@ combinations are intercepted. Unassigned shortcuts and simulated copy/paste pass
|
||||
Choose another shortcut or exit KSwitcher to restore the original action. Windows protects
|
||||
Ctrl+Alt+Delete and Win+L; these cannot be assigned.
|
||||
|
||||
Keyboard and mouse input forwarded by Microsoft Mouse Without Borders follows the same input
|
||||
path as a local keyboard/mouse, including shortcuts, recent-word conversion and snippets.
|
||||
KSwitcher marks its own generated input so copy/paste, selection, modifier releases and shell-mask
|
||||
keys cannot trigger recursive commands or overwrite the recorded typing run. Other injected input
|
||||
is accepted, including key-up events and VK-only keyboard events. Unicode packets do not provide
|
||||
physical key positions and therefore reset recent-word tracking; selection conversion still works.
|
||||
|
||||
### Screen capture and annotations
|
||||
|
||||
Press **Win+Shift+S**, choose **Capture screen region…** in the tray menu, or run
|
||||
`KSwitcher.exe --capture`. Drag a rectangle on the dimmed, frozen desktop; the selected area stays
|
||||
at normal brightness. Escape cancels. Selection and annotation frames are buffered to avoid flicker.
|
||||
The selected area is copied immediately and Windows shows a notification with a thumbnail.
|
||||
Click the notification to open the editor, including after the screenshot process has exited.
|
||||
If notifications are disabled or cannot be sent, the already-copied screenshot opens in the editor.
|
||||
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.
|
||||
To annotate: **N** places sequential numbered red markers with a click, and **A** draws
|
||||
red arrows by dragging from the tail to the tip. Click an existing marker or arrow to select it;
|
||||
drag its body to move it. Drag the square handle on a number's right edge to change its diameter
|
||||
(the text scales with it), or either endpoint handle of an arrow to change its length and direction.
|
||||
Select an arrow and drag the extra handle beside its midpoint perpendicular to the line to change
|
||||
its thickness from 1 to 30 pixels. The arrowhead scales with the line. Minus/plus keys also work.
|
||||
Alternatively, scroll the mouse wheel over an arrow to change its thickness, or over a number to
|
||||
change its diameter. Wheel up increases the size; wheel down decreases it. These edits support Undo
|
||||
and update linked arrows. **Ctrl+D** clones the selected object with a small offset. A cloned number
|
||||
gets the smallest available positive number, just like a newly placed number: gaps left by deletion
|
||||
are filled first, without renumbering existing objects. A cloned arrow keeps its style but starts without endpoint links.
|
||||
The compact toolbar uses [Lucide](https://lucide.dev) icons with hover tooltips showing each action
|
||||
and its keyboard shortcut.
|
||||
Drop either arrow endpoint onto a number to link it to the circle's edge. Linked endpoints follow
|
||||
the number when it moves or changes diameter. Drag an endpoint away to unlink it; moving the whole
|
||||
arrow unlinks both ends. In Arrow mode, hold **Ctrl** while dragging to start a new arrow directly
|
||||
from a number. Deleting a number leaves its arrows in place and removes their links; Undo restores them.
|
||||
**Delete** removes the selected object. **Ctrl+Z** (Undo) reverses additions, moves, resizes and deletions
|
||||
(up to 100 actions). **Ctrl+Y** or **Ctrl+Shift+Z** (Redo) restores undone edits; a new edit clears the
|
||||
redo history. Undo and Redo buttons are disabled when unavailable. Enabled toolbar buttons use a
|
||||
hand cursor. Selection handles are visible only in the editor, never in the copied image.
|
||||
The **palette** changes the selected object's color: arrow, numbered marker, or highlight.
|
||||
It also sets the color for newly drawn objects of every type. With nothing selected, only the
|
||||
color for new objects changes. Number text switches between dark and white for readability.
|
||||
**Highlight (H)** paints a translucent freehand marker stroke. Dark text remains readable beneath
|
||||
the highlight. Drag a selected stroke
|
||||
to move it, scroll over it to change width (4–100 pixels), or use Delete / Ctrl+D / Undo / Redo.
|
||||
Hold Ctrl while drawing to paint a new stroke over an existing object. Highlights render beneath
|
||||
arrows and numbered markers. The palette's color indicator reflects the selected object, or the
|
||||
color for new objects when nothing is selected.
|
||||
The editor status bar shows contextual tool, selection, rotation and text-editing hints.
|
||||
Hover a toolbar button to see its action and shortcut; the title stays **KSwitcher — Screenshot**.
|
||||
**Text (T)** adds text directly on the image. Enter inserts a line break; Ctrl+Enter or a click
|
||||
outside commits the edit, and Escape cancels it. Double-click text or press F2 to edit it again.
|
||||
The **Aa** icon opens the standard font dialog for family, size (6–200 pt), bold and italic.
|
||||
The mouse wheel over text adjusts its font size. Text supports color, dragging, Ctrl+D,
|
||||
Delete and Undo/Redo. Arrow keys move the selected object by one source-image pixel;
|
||||
selecting an arrow caption moves only that caption. Repositioning a caption preserves its
|
||||
automatic angle; only dragging the rotation handle switches to a fixed angle.
|
||||
Text has a rotation handle above its selection. Drag to rotate; hold Shift to snap to 15-degree steps.
|
||||
Double-click an arrow, press F2, or use the label icon to add/edit its caption. Captions follow
|
||||
the arrow when moved or resized; drag the caption to adjust its offset. By default, the caption
|
||||
angle follows the line (or middle curve segment) and stays upright. Rotating its handle manually
|
||||
fixes its angle; Shift snaps to 15-degree steps. Undo restores automatic mode. The Aa icon changes
|
||||
the selected caption font, and the mouse wheel over it changes its size. Captions and rotation
|
||||
are included in clipboard export, cloning and Undo/Redo.
|
||||
**Straight line (L)** creates a line without arrowheads. **Rectangle (R)**, **Circle (O)** and
|
||||
**Ellipse (E)** create transparent shapes with colored outlines. Drag the two corner handles to
|
||||
resize; circles keep equal width and height. Move shapes by their outlines, adjust thickness with
|
||||
the mouse wheel or side handle, recolor, clone with Ctrl+D, and undo/redo as with other objects.
|
||||
Hold Ctrl to start drawing over an existing object. Line style applies to outlines; Ends is disabled
|
||||
for shapes.
|
||||
**Freehand line (F)** draws an opaque curve by following the mouse. The curve is one object:
|
||||
move it, change its color or thickness, clone it, or drag its endpoints. Hold Ctrl to start a new
|
||||
stroke over an existing object. The **Line style** selector supports Solid, Dotted, Dashed and
|
||||
Dash-dot; **Ends** supports an arrow at the end, start, both ends, or no arrows. These settings
|
||||
apply to selected straight/freehand lines and set defaults for subsequent lines. Both are disabled
|
||||
for numbered markers and highlights unless a line drawing tool is active. Undo/Redo includes style changes.
|
||||
The window close button asks for confirmation before discarding an uncopied screenshot.
|
||||
**Escape** cancels region selection or inline text edits; it does not close the editor. Copy keeps the editor open and publishes the image
|
||||
at its original pixel size, ready to paste. The preview fits the window without changing export size.
|
||||
The shortcut is configurable in Settings under **Screen capture**.
|
||||
Each capture opens an independent editor available through Alt+Tab; use the shortcut again to take another screenshot while
|
||||
keeping earlier editors and their annotations open.
|
||||
Captures use ordinary clipboard history settings and limits. Protected video and secure desktops cannot be captured.
|
||||
|
||||
### Updates
|
||||
|
||||
Select **Update** in the tray menu to check the stable Releases of
|
||||
[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases).
|
||||
A startup check also reports an available version, but never installs it automatically.
|
||||
Confirm the offered version to download and install it. The updater uses the existing
|
||||
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
|
||||
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
|
||||
before the application exits. If replacement or startup fails, it restores the previous files;
|
||||
if recovery itself fails, it reports the retained backup directory.
|
||||
|
||||
Saved settings and clipboard history in `data/` or the local-app-data fallback are preserved.
|
||||
Existing dictionary files are preserved too; missing dictionary files are installed. Save settings
|
||||
edits before updating. The installation directory must be writable at KSwitcher's current privilege
|
||||
level. A blocked network request, incomplete release or checksum mismatch leaves the running version
|
||||
intact and is reported when Update is selected. Startup network failures remain non-interactive.
|
||||
The helper uses Windows' built-in Windows PowerShell 5.1; no additional runtime is installed.
|
||||
|
||||
Settings and the other utility windows use the destination monitor's DPI for their initial frame,
|
||||
controls and fonts. Settings has a fixed form size, without manual resizing or maximization; other
|
||||
utility windows remain resizable. Windows stay within a remaining monitor's work area after display
|
||||
changes and provide scrollbars when their content does not fit. Tab navigation scrolls the focused
|
||||
field into view; Shift+wheel scrolls horizontally.
|
||||
|
||||
## Implemented
|
||||
|
||||
- Installed Windows keyboard layouts, configurable cycle/order and direct shortcuts; IMEs are switch-only.
|
||||
@@ -45,7 +185,7 @@ Ctrl+Alt+Delete and Win+L; these cannot be assigned.
|
||||
- Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks.
|
||||
- Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion.
|
||||
- Text snippets across layouts, multiline insertion and a verified `{cursor}` position;
|
||||
`{{cursor}}` inserts the literal marker. Snippets are stored as plaintext settings, as in Typlo.
|
||||
`{{cursor}}` inserts the literal marker. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo).
|
||||
- Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste;
|
||||
full-text search, pinning, deletion, full text viewing and image previews.
|
||||
- Image thumbnails and text/file icons appear directly in the history list. Only visible rows
|
||||
@@ -65,8 +205,9 @@ Ctrl+Alt+Delete and Win+L; these cannot be assigned.
|
||||
- Single instance and `--history`, `--convert`, `--case`, `--transliterate`, `--settings`,
|
||||
`--snippets`, `--exit` commands. `--convert` cycles layouts.
|
||||
|
||||
Default shortcuts match Typlo: Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
|
||||
Z (undo), 1/2/3 (first layouts), and Ctrl+Shift+V (history).
|
||||
Default text shortcuts match [Typlo](https://github.com/bitcodepro/Typlo): Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
|
||||
Z (undo), and 1/2/3 (first layouts). Clipboard history defaults to **Win+V** for new settings;
|
||||
existing saved shortcuts are preserved. While KSwitcher runs, this binding opens its history instead of Windows clipboard history.
|
||||
|
||||
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
|
||||
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
|
||||
@@ -77,23 +218,43 @@ Pressing the history shortcut a second time pastes the second-most-recent item.
|
||||
Data is in `data/` beside the executable, falling back to `%LOCALAPPDATA%/KSwitcher` when
|
||||
the executable's directory is not writable. Each clipboard payload is an encrypted disk file;
|
||||
only bounded previews/metadata stay in memory. Image data is loaded on demand and released when
|
||||
the history popup is hidden. The log contains operational messages, never copied text or keystrokes.
|
||||
the history popup is hidden. Settings **Write log** (`writeLog`, enabled by default) controls all
|
||||
log writes. Disabling it also clears diagnostic context from memory; existing log files remain.
|
||||
Successful operations stay in a memory buffer of at most 40 diagnostic events for 30 seconds.
|
||||
Only failures flush this context to `kswitcher.log`; flushed
|
||||
context is discarded. Ordinary application errors are also logged when logging is enabled.
|
||||
Incident context contains tracked physical input, the expected word, copied text, conversion result,
|
||||
and layout IDs, correlated by request ID even when verification fails. Text fields use quoted,
|
||||
escaped UTF-16 (`\uXXXX` for non-ASCII and control characters) for exact reproduction, with
|
||||
original length and a truncation flag; each field is limited to 512 UTF-16 code units.
|
||||
Manual conversion attempts in `kswitcher.log` have request IDs and explicit outcomes, including
|
||||
busy, empty copy, verification mismatch, unchanged conversion, and cancelled paste. Requests
|
||||
record the process, window, input version, and repeated attempts within ten seconds without new
|
||||
input. Successful repeated commands do not flush the log; intentional layout cycling is normal.
|
||||
An unchanged result containing no letters, or a direct command
|
||||
to the detected current layout, is treated as expected. `paste-sent-unverified` means Windows
|
||||
accepted the paste shortcut; it does not prove that the editor applied the replacement.
|
||||
The previous 256 KiB log is retained as `kswitcher.log.1` when the current log rotates.
|
||||
Open **View log…** from Settings or the tray menu to read logs inside KSwitcher.
|
||||
Choose the current or previous log and press **Refresh** to reload. The ListView shows Time,
|
||||
Attempt, Event, and Details columns. Select rows and use **Copy selected** or Ctrl+C to copy
|
||||
tab-separated entries; Ctrl+A selects all rows. Viewing saved logs also works when logging is disabled.
|
||||
**Clear log** clears only the selected current or previous file and refreshes the table.
|
||||
Clearing the current log also discards buffered diagnostic context; new problems can still be logged.
|
||||
|
||||
Settings **Import** accepts a Typlo `settings.json`, preserving supported Windows layout IDs,
|
||||
hotkeys, snippets, exclusions and limits. Import is staged until Save. Existing Typlo files are
|
||||
never modified. Typlo's encrypted SQLite history is **not migrated** to the new DPAPI file format.
|
||||
Settings **Import** accepts a [Typlo](https://github.com/bitcodepro/Typlo) `settings.json`, preserving supported Windows layout IDs,
|
||||
hotkeys, snippets, exclusions and limits. Import is staged until Save. Existing [Typlo](https://github.com/bitcodepro/Typlo) files are
|
||||
never modified. [Typlo](https://github.com/bitcodepro/Typlo)'s encrypted SQLite history is **not migrated** to the new DPAPI file format.
|
||||
DPAPI data belongs to the Windows account/machine; copying the portable folder is not a cross-account backup.
|
||||
|
||||
## Differences still to port
|
||||
|
||||
This is a working native implementation, not a claim of complete UI/release parity:
|
||||
|
||||
- UI currently uses English and the native Windows control appearance. Typlo's ten-language UI and
|
||||
- UI currently uses English and the native Windows control appearance. [Typlo](https://github.com/bitcodepro/Typlo)'s ten-language UI and
|
||||
explicit light/dark theme selector are not yet ported.
|
||||
- Automatic release download/replacement is not enabled. KSwitcher has no configured release feed;
|
||||
using Typlo's feed would install the wrong application. Updates are manual.
|
||||
- No import of old encrypted clipboard history. Export/import applies to settings.
|
||||
- The clipboard popup is fixed-size with per-monitor DPI scaling; PNG/DIB previews use Windows WIC/GDI.
|
||||
- The clipboard popup uses per-monitor DPI scaling and scrolls when space is limited; PNG/DIB previews use Windows WIC/GDI.
|
||||
|
||||
The Windows focus/injection rules still apply: an unelevated process cannot edit an elevated app.
|
||||
Conversion is cancelled rather than pasting into a different window or replacing unverified recent text.
|
||||
@@ -175,8 +336,21 @@ Run `./package.ps1 -PublishRelease` after building and testing to refresh the tr
|
||||
.\build.ps1 -Test # Pure/native regression checks; does not touch the user's clipboard
|
||||
.\build.ps1 -Ocr # Render a synthetic image, decode it with WIC and recognize it with Windows OCR
|
||||
.\build.ps1 -Integration # Interactive, isolated test editor; avoid input for about 10 seconds
|
||||
.\tests\configuration.ps1 # Separate-process settings tests; close KSwitcher first
|
||||
```
|
||||
|
||||
`-Test` also checks remote/own input separation, hidden Settings layout and scrolling, and updater
|
||||
package validation, file preservation and rollback in temporary fixture directories. It does not
|
||||
replace a running installation. Optional read-only network checks:
|
||||
|
||||
```powershell
|
||||
.\build\KSwitcherInputTests.exe --update-check
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\tests\update.ps1 -Network
|
||||
```
|
||||
|
||||
These validate the live release metadata/download/checksum in a temporary directory. They do not
|
||||
prove hardware interoperability between two laptops or a physical monitor-disconnection sequence.
|
||||
|
||||
The interactive test requires installed English-US and Russian layouts. It opens its own editor,
|
||||
tests real copy/paste, undo, repeat conversion, recent-input verification and snippet cursor placement,
|
||||
then restores the clipboard. Test data is stored in its own temporary directory. It never sends messages
|
||||
@@ -191,9 +365,22 @@ It still uses the normal instance lock; close a running KSwitcher first if measu
|
||||
- `platform.*`: Win32 layouts, clipboard, input, ownership, DPAPI and file primitives.
|
||||
- `storage.*`: settings and encrypted, disk-backed history.
|
||||
- `app.*`: message loop, hooks, background work and guarded text transactions.
|
||||
- `config_main.cpp`, `configuration.*`: single-instance configurator and validated settings IPC.
|
||||
- `ui.cpp`: native windows and shortcut recording.
|
||||
- `ocr.*`: Windows WIC and OCR via the native Windows Runtime ABI.
|
||||
- `tests/`: regression checks, real editor integration and OCR fixture.
|
||||
|
||||
Application code is MIT licensed; preserves the original Typlo 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.
|
||||
|
||||
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,21 +1,35 @@
|
||||
# Latest Windows release
|
||||
|
||||
Download **KSwitcher-win-x64.zip**, extract the entire archive to a writable folder,
|
||||
and launch `KSwitcher.exe`. Keep `dictionaries/` next to the executable.
|
||||
The archive contains the application, offline dictionaries, documentation and license notices.
|
||||
It does not contain personal settings or clipboard history.
|
||||
## v0.4.1 — 2026-10-28
|
||||
|
||||
For upgrades, exit KSwitcher first. Back up any edited dictionaries before replacing files.
|
||||
Keep your existing `data/` directory to retain settings and clipboard history.
|
||||
`SHA256SUMS.txt` contains the checksum of the archive.
|
||||
- Fix Settings spacing around Screen capture, automatic layout correction and layout controls.
|
||||
- Verify update checksums through .NET SHA-256 without requiring the PowerShell Get-FileHash command. This fixes updates from processes launched with a conflicting PowerShell module environment.
|
||||
- Capture a rectangular screen region with Win+Shift+S (configurable), with dimmed selection and independent editor windows.
|
||||
- Annotate screenshots with numbered markers, arrows, straight/freehand lines, highlights, rectangles, circles, ellipses and inline text.
|
||||
- Edit color, line style, arrowheads, thickness, font and size. Link arrow endpoints to markers and attach captions.
|
||||
- Captions follow line angle and keep their relative offset until manually rotated. Text rotation supports Shift snapping in 15-degree steps.
|
||||
- Move objects one pixel with arrow keys; clone with Ctrl+D; undo/redo edits. Numbered markers reuse gaps.
|
||||
- Compact Lucide toolbar, contextual status bar, pencil cursor and confirmation before discarding an uncopied screenshot. Escape no longer closes the editor.
|
||||
- Preserve the separate on-demand configurator and configurable capture shortcut.
|
||||
|
||||
This folder holds only the latest compiled version; older versions remain in Git history.
|
||||
Verified: 173 native checks, 379 input/rendering checks, screenshot editor UI integration checks,
|
||||
29 updater checks, including missing Get-FileHash coverage, and package validation.
|
||||
|
||||
To refresh it from source:
|
||||
**Upgrade from v0.3.0 or v0.4.0:** use Update in the tray menu. Both executables are supported.
|
||||
If an older updater reports missing `Get-FileHash`, restart KSwitcher normally from Explorer
|
||||
(outside a PowerShell 7 session) or install this ZIP manually once. v0.4.1 removes that dependency.
|
||||
**Upgrade from v0.2.3 or earlier:** install manually once because those updaters reject the additional configurator EXE.
|
||||
Exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
|
||||
|
||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains both executables,
|
||||
offline dictionaries, documentation and license notices; no personal settings or clipboard history.
|
||||
|
||||
Build and package:
|
||||
|
||||
```powershell
|
||||
.\build.ps1 -Stage -Test
|
||||
.\package.ps1 -PublishRelease
|
||||
```
|
||||
|
||||
Commit the updated ZIP and checksum together with the corresponding source changes.
|
||||
Commit the archive, checksum and sources together. Publish both assets on a stable release whose
|
||||
tag matches the executable version.
|
||||
|
||||
@@ -1 +1 @@
|
||||
906de70d92045cc6ff50802841cc357c6b47536fa0cbbb63301d779b33179469 KSwitcher-win-x64.zip
|
||||
9b2e2708eda40531659748c97d8c2d10f59cc98954bad7517b86e824fd889019 KSwitcher-win-x64.zip
|
||||
|
||||
@@ -13,25 +13,49 @@ New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
|
||||
Push-Location $root
|
||||
try {
|
||||
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
||||
$libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','app','ui','main')) {
|
||||
$libs = @('-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
|
||||
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
|
||||
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
|
||||
}
|
||||
& windres resources/app.rc -O coff -o build/app-res.o
|
||||
& $compiler @common -DKS_RESIDENT_APP -c src/ui.cpp -o build/ui-resident.o
|
||||
if ($LASTEXITCODE) { throw 'Resident UI compilation failed' }
|
||||
& $compiler @common -DKS_CONFIG_APP -c src/ui.cpp -o build/ui-config.o
|
||||
if ($LASTEXITCODE) { throw 'Configurator UI compilation failed' }
|
||||
& windres --codepage=65001 resources/app.rc -O coff -o build/app-res.o
|
||||
if ($LASTEXITCODE) { throw 'Resource compilation failed' }
|
||||
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
|
||||
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
|
||||
New-Item -ItemType Directory -Force build/release | Out-Null
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/app.o build/ui.o build/main.o build/app-res.o @libs -o $executablePath
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
|
||||
if ($LASTEXITCODE) { throw 'Link failed' }
|
||||
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
|
||||
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/storage.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
|
||||
if ($LASTEXITCODE) { throw 'Configurator link failed' }
|
||||
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
|
||||
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
|
||||
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
|
||||
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
|
||||
if ($Test) {
|
||||
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o @libs -static -s -o build/KSwitcherTests.exe
|
||||
& $compiler @common tests/tests.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Test build failed' }
|
||||
& .\build\KSwitcherTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Tests failed' }
|
||||
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Input test build failed' }
|
||||
& .\build\KSwitcherInputTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Input tests failed' }
|
||||
& $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
|
||||
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
|
||||
& .\build\KSwitcherScreenshotUITests.exe
|
||||
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
|
||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
|
||||
if ($LASTEXITCODE) { throw 'Updater tests failed' }
|
||||
}
|
||||
if ($Integration) {
|
||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/app.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
||||
if ($LASTEXITCODE) { throw 'Integration test build failed' }
|
||||
& .\build\KSwitcherIntegration.exe
|
||||
if ($LASTEXITCODE) { throw 'Integration tests failed' }
|
||||
|
||||
@@ -4,12 +4,23 @@ $root = $PSScriptRoot
|
||||
$destination = Join-Path $root 'dist\KSwitcher'
|
||||
New-Item -ItemType Directory -Force $destination | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Screenshot.exe') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
|
||||
# Enumerate the distributable files explicitly; do not include a user's data directory.
|
||||
$files = @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
|
||||
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
|
||||
$entries = foreach ($path in $files) {
|
||||
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
|
||||
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
|
||||
}
|
||||
}
|
||||
$version=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $destination 'KSwitcher.exe')).FileVersion
|
||||
$manifest=Join-Path $destination 'release-files.json'
|
||||
@{schema=1; version=$version; files=@($entries)} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifest -Encoding utf8
|
||||
$files += $manifest
|
||||
$archive = Join-Path $root 'dist\KSwitcher-win-x64.zip'
|
||||
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
|
||||
Get-FileHash -LiteralPath $archive -Algorithm SHA256
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
#pragma code_page(65001)
|
||||
#include <windows.h>
|
||||
#include "resource.h"
|
||||
IDI_KSWITCHER ICON "kswitcher.ico"
|
||||
#ifdef KS_SCREENSHOT_RESOURCES
|
||||
IDI_SCREENSHOT_QUESTION ICON "icons/question.ico"
|
||||
IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||
#endif
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,1,0,0
|
||||
PRODUCTVERSION 0,1,0,0
|
||||
FILEVERSION 0,6,1,0
|
||||
PRODUCTVERSION 0,6,1,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
@@ -14,7 +20,7 @@ BEGIN
|
||||
BEGIN
|
||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||
VALUE "ProductName", "KSwitcher"
|
||||
VALUE "FileVersion", "0.1.0"
|
||||
VALUE "FileVersion", "0.6.1"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
@@ -22,3 +28,54 @@ BEGIN
|
||||
VALUE "Translation", 0x409, 1200
|
||||
END
|
||||
END
|
||||
#ifdef KS_SCREENSHOT_RESOURCES
|
||||
300 ICON "icons/lucide/circle-dot-normal.ico"
|
||||
310 ICON "icons/lucide/circle-dot-active.ico"
|
||||
320 ICON "icons/lucide/circle-dot-disabled.ico"
|
||||
301 ICON "icons/lucide/arrow-up-right-normal.ico"
|
||||
311 ICON "icons/lucide/arrow-up-right-active.ico"
|
||||
321 ICON "icons/lucide/arrow-up-right-disabled.ico"
|
||||
302 ICON "icons/lucide/undo-2-normal.ico"
|
||||
312 ICON "icons/lucide/undo-2-active.ico"
|
||||
322 ICON "icons/lucide/undo-2-disabled.ico"
|
||||
303 ICON "icons/lucide/copy-normal.ico"
|
||||
313 ICON "icons/lucide/copy-active.ico"
|
||||
323 ICON "icons/lucide/copy-disabled.ico"
|
||||
304 ICON "icons/lucide/x-normal.ico"
|
||||
314 ICON "icons/lucide/x-active.ico"
|
||||
324 ICON "icons/lucide/x-disabled.ico"
|
||||
305 ICON "icons/lucide/redo-2-normal.ico"
|
||||
315 ICON "icons/lucide/redo-2-active.ico"
|
||||
325 ICON "icons/lucide/redo-2-disabled.ico"
|
||||
306 ICON "icons/lucide/highlighter-normal.ico"
|
||||
316 ICON "icons/lucide/highlighter-active.ico"
|
||||
326 ICON "icons/lucide/highlighter-disabled.ico"
|
||||
307 ICON "icons/lucide/palette-normal.ico"
|
||||
317 ICON "icons/lucide/palette-active.ico"
|
||||
327 ICON "icons/lucide/palette-disabled.ico"
|
||||
308 ICON "icons/lucide/pencil-normal.ico"
|
||||
318 ICON "icons/lucide/pencil-active.ico"
|
||||
328 ICON "icons/lucide/pencil-disabled.ico"
|
||||
|
||||
400 ICON "icons/lucide/slash-normal.ico"
|
||||
410 ICON "icons/lucide/slash-active.ico"
|
||||
420 ICON "icons/lucide/slash-disabled.ico"
|
||||
401 ICON "icons/lucide/square-normal.ico"
|
||||
411 ICON "icons/lucide/square-active.ico"
|
||||
421 ICON "icons/lucide/square-disabled.ico"
|
||||
402 ICON "icons/lucide/circle-normal.ico"
|
||||
412 ICON "icons/lucide/circle-active.ico"
|
||||
422 ICON "icons/lucide/circle-disabled.ico"
|
||||
403 ICON "icons/lucide/ellipse-normal.ico"
|
||||
413 ICON "icons/lucide/ellipse-active.ico"
|
||||
423 ICON "icons/lucide/ellipse-disabled.ico"
|
||||
|
||||
404 ICON "icons/lucide/type-normal.ico"
|
||||
414 ICON "icons/lucide/type-active.ico"
|
||||
424 ICON "icons/lucide/type-disabled.ico"
|
||||
|
||||
405 ICON "icons/lucide/a-large-small-normal.ico"
|
||||
415 ICON "icons/lucide/a-large-small-active.ico"
|
||||
425 ICON "icons/lucide/a-large-small-disabled.ico"
|
||||
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
ISC License
|
||||
|
||||
Copyright (c) 2026 Lucide Icons and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
The following Lucide icons are derived from the Feather project:
|
||||
|
||||
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
|
||||
|
||||
The MIT License (MIT) (for the icons listed above)
|
||||
|
||||
Copyright (c) 2013-present Cole Bemis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,5 @@
|
||||
Lucide https://github.com/lucide-icons/lucide
|
||||
Commit: be09619c1c1124ad09c19fc3e87ee3b939409bbd
|
||||
SVG originals retained. ICO variants generated with tools/generate-toolbar-icons.py.
|
||||
|
||||
Ellipse is adapted from the pinned Lucide circle SVG (r replaced with rx/ry).
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 16 2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16" />
|
||||
<path d="M15.697 14h5.606" />
|
||||
<path d="m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16" />
|
||||
<path d="M3.304 13h6.392" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 391 B |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M7 7h10v10" />
|
||||
<path d="M7 17 17 7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 260 B |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 279 B |
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 244 B |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 339 B |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<ellipse cx="12" cy="12" rx="10" ry="7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m9 11-6 6v3h9l3-3" />
|
||||
<path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 317 B |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 232 B |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 53 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z" />
|
||||
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
|
||||
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
|
||||
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
|
||||
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 571 B |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 256 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 14 5-5-5-5" />
|
||||
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 308 B |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M22 2 2 22" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 234 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 4v16" />
|
||||
<path d="M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2" />
|
||||
<path d="M9 20h6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 310 B |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 14 4 9l5-5" />
|
||||
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 260 B |
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<defs>
|
||||
<linearGradient id="blue" x1="8" y1="4" x2="36" y2="44" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#38bdf8"/>
|
||||
<stop offset=".55" stop-color="#1687e8"/>
|
||||
<stop offset="1" stop-color="#2563eb"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="24" cy="25" r="21" fill="#1e40af" opacity=".12"/>
|
||||
<circle cx="24" cy="23" r="21" fill="url(#blue)"/>
|
||||
<circle cx="24" cy="23" r="20.5" fill="none" stroke="white" stroke-opacity=".25"/>
|
||||
<path d="M18 17.5c0-3.1 2.5-5 6-5s6 2 6 5c0 4.8-6 4.8-6 9" fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<circle cx="24" cy="33" r="2" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 711 B |
@@ -1,2 +1,4 @@
|
||||
#pragma once
|
||||
#define IDI_KSWITCHER 101
|
||||
#define IDI_SCREENSHOT_QUESTION 102
|
||||
#define IDC_SCREENSHOT_PENCIL 500
|
||||
|
||||
|
After Width: | Height: | Size: 71 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="330" viewBox="0 0 960 330">
|
||||
<rect x="1" y="1" width="958" height="328" rx="24" fill="#F5F3FF" stroke="#DDD6FE" stroke-width="2"/>
|
||||
<g font-family="Segoe UI" fill="#241B46">
|
||||
<text x="40" y="58" font-size="20" font-weight="600" fill="#4F46E5">KSwitcher / Support development</text>
|
||||
<text x="40" y="115" font-size="35" font-weight="700">KSwitcher part of your day?</text>
|
||||
<text x="40" y="159" font-size="35" font-weight="700">Buy the developer a coffee.</text>
|
||||
<text x="40" y="202" font-size="20" fill="#655B7C">Your donation helps the tool keep growing.</text>
|
||||
<rect x="40" y="238" width="350" height="54" rx="12" fill="#4F46E5"/>
|
||||
<text x="215" y="272" text-anchor="middle" font-size="20" font-weight="600" fill="white">Support via monobank</text>
|
||||
</g>
|
||||
<g transform="translate(759 48) rotate(10 66 66)">
|
||||
<rect y="8" width="132" height="132" rx="26" fill="#C4B5FD"/>
|
||||
<rect width="132" height="132" rx="26" fill="#4F46E5"/>
|
||||
<g transform="translate(14 14) scale(3.25)"><path d="M8 7H12V25H8ZM11 15L22 6L25 9L14 18Z" fill="white"/><path d="M11 14L25 24L22 27L10 18Z" fill="#5EEAD4"/></g>
|
||||
</g>
|
||||
<g transform="translate(699 172) rotate(-8 67 60)">
|
||||
<rect y="7" width="142" height="115" rx="22" fill="#C4B5FD"/>
|
||||
<rect width="142" height="115" rx="22" fill="white" stroke="#DDD6FE" stroke-width="2"/>
|
||||
<g stroke="#4F46E5" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M43 41H92V66Q92 83 75 83H60Q43 83 43 66Z"/>
|
||||
<path d="M92 46H99Q115 46 111 58Q108 67 92 66M37 92H100M56 28V18M69 28V18M82 28V18"/>
|
||||
</g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 70 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="330" viewBox="0 0 960 330">
|
||||
<rect x="1" y="1" width="958" height="328" rx="24" fill="#F5F3FF" stroke="#DDD6FE" stroke-width="2"/>
|
||||
<g font-family="Segoe UI" fill="#241B46">
|
||||
<text x="40" y="58" font-size="20" font-weight="600" fill="#4F46E5">KSwitcher / Підтримати розробку</text>
|
||||
<text x="40" y="115" font-size="35" font-weight="700">KSwitcher щодня під рукою?</text>
|
||||
<text x="40" y="159" font-size="35" font-weight="700">Пригостіть розробника кавою.</text>
|
||||
<text x="40" y="202" font-size="20" fill="#655B7C">Ваш донат допомагає розвивати інструмент.</text>
|
||||
<rect x="40" y="238" width="350" height="54" rx="12" fill="#4F46E5"/>
|
||||
<text x="215" y="272" text-anchor="middle" font-size="20" font-weight="600" fill="white">Підтримати через monobank</text>
|
||||
</g>
|
||||
<g transform="translate(759 48) rotate(10 66 66)">
|
||||
<rect y="8" width="132" height="132" rx="26" fill="#C4B5FD"/>
|
||||
<rect width="132" height="132" rx="26" fill="#4F46E5"/>
|
||||
<g transform="translate(14 14) scale(3.25)"><path d="M8 7H12V25H8ZM11 15L22 6L25 9L14 18Z" fill="white"/><path d="M11 14L25 24L22 27L10 18Z" fill="#5EEAD4"/></g>
|
||||
</g>
|
||||
<g transform="translate(699 172) rotate(-8 67 60)">
|
||||
<rect y="7" width="142" height="115" rx="22" fill="#C4B5FD"/>
|
||||
<rect width="142" height="115" rx="22" fill="white" stroke="#DDD6FE" stroke-width="2"/>
|
||||
<g stroke="#4F46E5" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M43 41H92V66Q92 83 75 83H60Q43 83 43 66Z"/>
|
||||
<path d="M92 46H99Q115 46 111 58Q108 67 92 66M37 92H100M56 28V18M69 28V18M82 28V18"/>
|
||||
</g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,294 @@
|
||||
param(
|
||||
[ValidateSet('Check','Install')][string]$Mode = 'Check',
|
||||
[string]$CurrentVersion, [string]$ExpectedVersion,
|
||||
[string]$InstallDirectory, [string]$WorkDirectory,
|
||||
[int]$ParentProcessId, [string]$ReadyEvent,
|
||||
[switch]$Library
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Get-StableVersion([string]$Tag) {
|
||||
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
||||
try { return [version]($Tag -replace '^(?:release-)?v','') } catch { return $null }
|
||||
}
|
||||
function Assert-ReleaseUrl([string]$Url) {
|
||||
$uri = [uri]$Url
|
||||
if (!$uri.IsAbsoluteUri -or $uri.Scheme -ne 'https' -or $uri.Host -ne 'gitea.hrynco.com' -or
|
||||
!$uri.IsDefaultPort -or $uri.UserInfo -or $uri.Query -or $uri.Fragment -or
|
||||
!$uri.AbsolutePath.StartsWith('/hrynco/hrynco-kswitcher/releases/download/',[StringComparison]::Ordinal)) {
|
||||
throw 'Release asset URL is outside the KSwitcher repository.'
|
||||
}
|
||||
}
|
||||
function Receive-File([string]$Url,[string]$Path,[long]$Limit) {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$request = [Net.HttpWebRequest]::Create($Url)
|
||||
$request.AllowAutoRedirect = $false
|
||||
$request.Timeout = 15000
|
||||
$request.ReadWriteTimeout = 15000
|
||||
$request.UserAgent = 'KSwitcher-Updater'
|
||||
$response = $null; $inputStream = $null; $outputStream = $null
|
||||
try {
|
||||
$response = $request.GetResponse()
|
||||
if ([int]$response.StatusCode -ne 200 -or $response.ContentLength -gt $Limit) { throw 'Invalid or oversized server response.' }
|
||||
$inputStream = $response.GetResponseStream()
|
||||
$outputStream = [IO.File]::Create($Path)
|
||||
$buffer = New-Object byte[] 65536
|
||||
$total = 0L
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
||||
while (($count = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) {
|
||||
$total += $count
|
||||
if ($total -gt $Limit -or [DateTime]::UtcNow -gt $deadline) { throw 'Download exceeded size or time limit.' }
|
||||
$outputStream.Write($buffer,0,$count)
|
||||
}
|
||||
} finally {
|
||||
if ($outputStream) { $outputStream.Dispose() }
|
||||
if ($inputStream) { $inputStream.Dispose() }
|
||||
if ($response) { $response.Dispose() }
|
||||
}
|
||||
}
|
||||
function Select-Release($Releases,[version]$Current) {
|
||||
$candidates = @($Releases | Where-Object { !$_.draft -and !$_.prerelease -and (Get-StableVersion $_.tag_name) } |
|
||||
Sort-Object { Get-StableVersion $_.tag_name } -Descending)
|
||||
if (!$candidates.Count -or (Get-StableVersion $candidates[0].tag_name) -le $Current) { return $null }
|
||||
$release = $candidates[0]
|
||||
$zip = @($release.assets | Where-Object name -CEQ 'KSwitcher-win-x64.zip')
|
||||
$sum = @($release.assets | Where-Object name -CEQ 'SHA256SUMS.txt')
|
||||
if ($zip.Count -ne 1 -or $sum.Count -ne 1) { throw 'The newest release lacks its x64 ZIP or SHA256SUMS.txt.' }
|
||||
Assert-ReleaseUrl $zip[0].browser_download_url
|
||||
Assert-ReleaseUrl $sum[0].browser_download_url
|
||||
return @{ version=$release.tag_name; zip=$zip[0].browser_download_url; checksum=$sum[0].browser_download_url }
|
||||
}
|
||||
function Assert-Checksum([string]$Archive,[string]$Checksum) {
|
||||
$matchesFound = @([IO.File]::ReadAllLines($Checksum) | Where-Object { $_ -cmatch '^([a-fA-F0-9]{64})\s+\*?KSwitcher-win-x64\.zip$' })
|
||||
if ($matchesFound.Count -ne 1) { throw 'Missing or ambiguous archive checksum.' }
|
||||
$expected = ($matchesFound[0] -split '\s+')[0]
|
||||
# Do not depend on PowerShell module autoloading: hosts can inherit a
|
||||
# PowerShell 7 PSModulePath while this helper runs in Windows PowerShell 5.1.
|
||||
$stream = [IO.File]::OpenRead($Archive)
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try { $actual = [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','') }
|
||||
finally { $sha.Dispose(); $stream.Dispose() }
|
||||
if ($actual -ne $expected) { throw 'Release checksum verification failed.' }
|
||||
}
|
||||
function Expand-LegacyRelease([string]$Archive,[string]$Destination,[version]$Expected) {
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
|
||||
try {
|
||||
$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
$total = 0L
|
||||
foreach ($entry in $zip.Entries) {
|
||||
$name = $entry.FullName.Replace('\','/')
|
||||
if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue }
|
||||
if ($name -cnotmatch '^(KSwitcher(?:\.(?:Config|Screenshot))?\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or
|
||||
$name.EndsWith('/.') -or $name.EndsWith('/..') -or $name.EndsWith('.') -or
|
||||
(($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($name)) {
|
||||
throw "Unexpected or unsafe archive entry: $name"
|
||||
}
|
||||
$total += $entry.Length
|
||||
if ($total -gt 100MB -or $zip.Entries.Count -gt 100) { throw 'Release expands beyond allowed limits.' }
|
||||
$destinationFile = Join-Path $Destination $name
|
||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destinationFile)) | Out-Null
|
||||
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry,$destinationFile,$false)
|
||||
}
|
||||
foreach ($required in @('KSwitcher.exe','LICENSE','json-LICENSE.txt','README.md')) {
|
||||
if (!$seen.Contains($required)) { throw "Incomplete release: $required" }
|
||||
}
|
||||
if ($Expected -gt [version]'0.2.3' -and !$seen.Contains('KSwitcher.Config.exe')) {
|
||||
throw 'Incomplete release: KSwitcher.Config.exe'
|
||||
}
|
||||
} finally { $zip.Dispose() }
|
||||
foreach ($binary in @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe')) {
|
||||
$exe = Join-Path $Destination $binary
|
||||
if (!(Test-Path -LiteralPath $exe)) { continue }
|
||||
$version = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe)
|
||||
if ($version.ProductName -ne 'KSwitcher' -or (Get-StableVersion $version.FileVersion) -ne $Expected) {
|
||||
throw 'Executable identity/version does not match the release.'
|
||||
}
|
||||
$bytes = [IO.File]::ReadAllBytes($exe)
|
||||
if ($bytes.Length -lt 64 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) { throw 'Invalid release executable.' }
|
||||
$pe = [BitConverter]::ToInt32($bytes,60)
|
||||
if ($pe -lt 64 -or $pe -gt $bytes.Length-6 -or [BitConverter]::ToUInt32($bytes,$pe) -ne 0x4550 -or
|
||||
[BitConverter]::ToUInt16($bytes,$pe+4) -ne 0x8664) { throw 'Release is not a Windows x64 executable.' }
|
||||
}
|
||||
}
|
||||
function Get-Sha256([IO.Stream]$Stream) {
|
||||
$sha=[Security.Cryptography.SHA256]::Create()
|
||||
try { return [BitConverter]::ToString($sha.ComputeHash($Stream)).Replace('-','').ToLowerInvariant() }
|
||||
finally { $sha.Dispose() }
|
||||
}
|
||||
function Assert-PackagePath([string]$Name) {
|
||||
if (!$Name -or $Name.Length -gt 220 -or $Name -match '[\\:<>"|?*]' -or $Name.StartsWith('/')) { throw "Unsafe package path: $Name" }
|
||||
$parts=$Name.Split('/')
|
||||
foreach ($part in $parts) {
|
||||
if (!$part -or $part -in @('.','..') -or $part -match '[. ]$|[\x00-\x1f]' -or
|
||||
$part -match '^(?i:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\.|$)') { throw "Unsafe package path: $Name" }
|
||||
}
|
||||
if ($parts[0] -ieq 'data' -or $parts[0] -like '.kswitcher-*') { throw "Protected user path: $Name" }
|
||||
}
|
||||
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$zip=[IO.Compression.ZipFile]::OpenRead($Archive)
|
||||
try {
|
||||
$manifests=@($zip.Entries | Where-Object FullName -CEQ 'release-files.json')
|
||||
if (!$manifests.Count) {
|
||||
if ($Expected -gt [version]'0.4.2') { throw 'Release manifest is missing.' }
|
||||
$zip.Dispose(); $zip=$null
|
||||
Expand-LegacyRelease $Archive $Destination $Expected
|
||||
return
|
||||
}
|
||||
if ($manifests.Count -ne 1 -or $manifests[0].Length -gt 256KB) { throw 'Invalid release manifest.' }
|
||||
$reader=[IO.StreamReader]::new($manifests[0].Open())
|
||||
try { $manifest=$reader.ReadToEnd() | ConvertFrom-Json } finally { $reader.Dispose() }
|
||||
if ($manifest.schema -ne 1 -or (Get-StableVersion $manifest.version) -ne $Expected -or
|
||||
@($manifest.files).Count -lt 1 -or @($manifest.files).Count -gt 1000) { throw 'Unsupported or mismatched release manifest.' }
|
||||
$declared=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($file in $manifest.files) {
|
||||
Assert-PackagePath $file.path
|
||||
if ($file.path -ieq 'release-files.json' -or $declared.ContainsKey($file.path) -or
|
||||
$file.sha256 -notmatch '^[a-fA-F0-9]{64}$' -or $file.size -lt 0 -or $file.size -gt 100MB) { throw 'Invalid manifest file entry.' }
|
||||
$declared.Add($file.path,$file)
|
||||
}
|
||||
if (!$declared.ContainsKey('KSwitcher.exe')) { throw 'Main executable is missing from the manifest.' }
|
||||
$seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
$total=0L
|
||||
foreach ($entry in $zip.Entries) {
|
||||
$name=$entry.FullName.Replace('\','/')
|
||||
if ($name.EndsWith('/')) { Assert-PackagePath $name.TrimEnd('/'); continue }
|
||||
Assert-PackagePath $name
|
||||
if (!$seen.Add($name) -or (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000) { throw 'Duplicate or linked archive entry.' }
|
||||
if ($name -ceq 'release-files.json') { continue }
|
||||
if (!$declared.ContainsKey($name)) { throw "Undeclared package file: $name" }
|
||||
$file=$declared[$name]
|
||||
if ($entry.Length -ne $file.size) { throw "Package file size mismatch: $name" }
|
||||
$total+=$entry.Length
|
||||
if ($total -gt 100MB -or $zip.Entries.Count -gt 1100) { throw 'Release expands beyond allowed limits.' }
|
||||
$stream=$entry.Open()
|
||||
try { $hash=Get-Sha256 $stream } finally { $stream.Dispose() }
|
||||
if ($hash -ne $file.sha256) { throw "Package file checksum mismatch: $name" }
|
||||
}
|
||||
if ($seen.Count -ne $declared.Count+1) { throw 'Archive does not contain every manifest file.' }
|
||||
[IO.Directory]::CreateDirectory($Destination) | Out-Null
|
||||
Assert-PlainDirectory $Destination
|
||||
foreach ($entry in $zip.Entries) {
|
||||
if ($entry.FullName.EndsWith('/')) { continue }
|
||||
$target=Join-Path $Destination $entry.FullName
|
||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($target)) | Out-Null
|
||||
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($target))
|
||||
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry,$target,$false)
|
||||
}
|
||||
$info=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $Destination 'KSwitcher.exe'))
|
||||
if ($info.ProductName -ne 'KSwitcher' -or (Get-StableVersion $info.FileVersion) -ne $Expected) { throw 'Main executable identity/version mismatch.' }
|
||||
} finally { if ($zip) { $zip.Dispose() } }
|
||||
}
|
||||
function Assert-PlainDirectory([string]$Directory) {
|
||||
$item = Get-Item -LiteralPath $Directory -Force
|
||||
while ($item) {
|
||||
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Update directory must not contain junctions or symbolic links.' }
|
||||
$item = $item.Parent
|
||||
}
|
||||
}
|
||||
function Install-Files([string]$Stage,[string]$Target,[string]$Backup,[Collections.Generic.List[string]]$Changed) {
|
||||
Assert-PlainDirectory $Target
|
||||
foreach ($file in @(Get-ChildItem -LiteralPath $Stage -File -Recurse)) {
|
||||
$relative = $file.FullName.Substring($Stage.Length+1)
|
||||
$destination = Join-Path $Target $relative
|
||||
if ($relative.StartsWith('dictionaries\',[StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $destination)) { continue }
|
||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
|
||||
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($destination))
|
||||
if (Test-Path -LiteralPath $destination) {
|
||||
if ((Get-Item -LiteralPath $destination -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Cannot replace a linked file.' }
|
||||
$backupFile = Join-Path $Backup $relative
|
||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($backupFile)) | Out-Null
|
||||
Move-Item -LiteralPath $destination -Destination $backupFile
|
||||
}
|
||||
$Changed.Add($relative)
|
||||
Move-Item -LiteralPath $file.FullName -Destination $destination
|
||||
}
|
||||
}
|
||||
function Undo-Files([string]$Target,[string]$Backup,[Collections.Generic.List[string]]$Changed) {
|
||||
for ($i=$Changed.Count-1; $i -ge 0; --$i) {
|
||||
$destination = Join-Path $Target $Changed[$i]
|
||||
if (Test-Path -LiteralPath $destination) { Remove-Item -LiteralPath $destination -Force }
|
||||
$original = Join-Path $Backup $Changed[$i]
|
||||
if (Test-Path -LiteralPath $original) { Move-Item -LiteralPath $original -Destination $destination }
|
||||
}
|
||||
}
|
||||
function Write-Result($Value) {
|
||||
$Value | ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $WorkDirectory 'result.json') -Encoding UTF8
|
||||
}
|
||||
if ($Library) { return }
|
||||
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
|
||||
$changed = [Collections.Generic.List[string]]::new()
|
||||
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null
|
||||
try {
|
||||
$current = Get-StableVersion $CurrentVersion
|
||||
if (!$current) { throw 'Invalid installed version.' }
|
||||
if ($Mode -eq 'Install') {
|
||||
$parentProcess = Get-Process -Id $ParentProcessId
|
||||
if ([IO.Path]::GetFullPath($parentProcess.Path) -ne [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.exe'))) {
|
||||
throw 'The running executable does not match the update target.'
|
||||
}
|
||||
$ready = [Threading.EventWaitHandle]::OpenExisting($ReadyEvent)
|
||||
}
|
||||
$metadata = Join-Path $WorkDirectory 'releases.json'
|
||||
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' $metadata 2MB
|
||||
$release = Select-Release (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json) $current
|
||||
if (!$release) { Write-Result @{state='current'}; exit 0 }
|
||||
if ($Mode -eq 'Check') { Write-Result @{state='available';version=$release.version}; exit 0 }
|
||||
if ($ExpectedVersion -ne $release.version) { throw 'Release changed since checking. Please select Update again.' }
|
||||
Assert-PlainDirectory $InstallDirectory
|
||||
$transaction = Join-Path $InstallDirectory ('.kswitcher-update-'+[guid]::NewGuid().ToString('N'))
|
||||
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
|
||||
[IO.Directory]::CreateDirectory($stage) | Out-Null
|
||||
$archive = Join-Path $WorkDirectory 'release.zip'; $checksum = Join-Path $WorkDirectory 'SHA256SUMS.txt'
|
||||
Receive-File $release.zip $archive 50MB
|
||||
Receive-File $release.checksum $checksum 64KB
|
||||
Assert-Checksum $archive $checksum
|
||||
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
||||
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
|
||||
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
|
||||
Write-Result @{state='prepared';version=$release.version}
|
||||
$ready.Set() | Out-Null
|
||||
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
||||
$parentExited = $true
|
||||
Install-Files $stage $InstallDirectory $backup $changed
|
||||
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
|
||||
$started = [Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$startedName)
|
||||
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
|
||||
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
|
||||
$success = $true
|
||||
} catch {
|
||||
$message = $_.Exception.Message
|
||||
if ($parentExited) {
|
||||
try {
|
||||
if ($child -and !$child.HasExited) { $child.Kill(); $child.WaitForExit() }
|
||||
Undo-Files $InstallDirectory $backup $changed
|
||||
Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -WorkingDirectory $InstallDirectory -WindowStyle Hidden
|
||||
$message += "`nThe previous version has been restored and restarted."
|
||||
} catch {
|
||||
$preserveBackup = $true
|
||||
$message += "`nRecovery needs attention: $($_.Exception.Message)`nBackup: $backup"
|
||||
}
|
||||
}
|
||||
Write-Result @{state='error';error=$message}
|
||||
if ($ready -and $ready.WaitOne(0)) {
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
[Windows.Forms.MessageBox]::Show($message,'KSwitcher update failed') | Out-Null
|
||||
}
|
||||
exit 1
|
||||
} finally {
|
||||
if ($ready) { $ready.Dispose() }
|
||||
if ($started) { $started.Dispose() }
|
||||
if ($parentProcess) { $parentProcess.Dispose() }
|
||||
# Only remove the uniquely created transaction directory directly under the install directory.
|
||||
if ($transaction -and !$preserveBackup -and [IO.Path]::GetDirectoryName($transaction) -eq [IO.Path]::GetFullPath($InstallDirectory).TrimEnd('\') -and (Test-Path -LiteralPath $transaction)) {
|
||||
Remove-Item -LiteralPath $transaction -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($success) {
|
||||
# The script is loaded in memory; its unique temporary working directory is no longer needed.
|
||||
if ((Split-Path -Leaf $WorkDirectory) -like 'KSwitcher-update-*' -and [IO.Path]::GetDirectoryName($WorkDirectory) -eq [IO.Path]::GetTempPath().TrimEnd('\')) {
|
||||
Remove-Item -LiteralPath $WorkDirectory -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
#include "app.h"
|
||||
#include "../resources/resource.h"
|
||||
#include "ocr.h"
|
||||
#include "configuration.h"
|
||||
#include "screenshot_notifications.h"
|
||||
#include <algorithm>
|
||||
#include <commctrl.h>
|
||||
#include <set>
|
||||
@@ -16,7 +18,8 @@ std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
|
||||
{Case, s.casing},
|
||||
{Translit, s.translit},
|
||||
{Undo, s.undo},
|
||||
{ShowHistory, s.clipboard}};
|
||||
{ShowHistory, s.clipboard},
|
||||
{Screenshot, s.screenshot}};
|
||||
for (size_t i = 0; i < s.direct.size(); ++i)
|
||||
out.emplace_back(Direct + (int)i, s.direct[i]);
|
||||
return out;
|
||||
@@ -125,7 +128,10 @@ class Transaction
|
||||
write(textClip(L"\x2063KSwitcher-" + std::to_wstring(GetTickCount64())));
|
||||
DWORD before = expected;
|
||||
if (!valid() || !chord(L'C', MOD_CONTROL))
|
||||
{
|
||||
diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked");
|
||||
return {};
|
||||
}
|
||||
ULONGLONG deadline = GetTickCount64() + 2000;
|
||||
DWORD changed = before;
|
||||
ULONGLONG stable = 0;
|
||||
@@ -146,7 +152,11 @@ class Transaction
|
||||
Sleep(10);
|
||||
}
|
||||
if (changed == before || !valid())
|
||||
{
|
||||
diagnosticEvent(changed == before ? L"Copy failed: clipboard timeout (2000 ms)" :
|
||||
L"Copy failed: context changed");
|
||||
return {};
|
||||
}
|
||||
if ((GetClipboardOwner() ? !ownsClipboard(target) : changedAgain) ||
|
||||
changed != GetClipboardSequenceNumber())
|
||||
throw std::runtime_error("Operation cancelled: copy did not belong to the target application");
|
||||
@@ -159,13 +169,19 @@ class Transaction
|
||||
{
|
||||
if (f.name == L"VisualStudioEditorOperationsLineCutCopyClipboardTag" ||
|
||||
f.name == L"MSDEVLineSelect")
|
||||
{
|
||||
diagnosticEvent(L"Copy rejected: editor copied line without selection");
|
||||
return {};
|
||||
}
|
||||
if (f.name == L"vscode-editor-data")
|
||||
{
|
||||
std::string json(f.data.begin(), f.data.end());
|
||||
if (json.find("\"isFromEmptySelection\":true") != json.npos ||
|
||||
json.find("\"isFromEmptySelection\": true") != json.npos)
|
||||
{
|
||||
diagnosticEvent(L"Copy rejected: editor reported empty selection");
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
return text;
|
||||
@@ -188,7 +204,7 @@ class Transaction
|
||||
auto text = copy();
|
||||
if (valid() && matches(text))
|
||||
{
|
||||
log(L"Selection: verified word boundary");
|
||||
diagnosticEvent(L"Selection: verified word boundary");
|
||||
return text;
|
||||
}
|
||||
if (!valid() || !chord(VK_RIGHT))
|
||||
@@ -230,6 +246,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
|
||||
settings = loadSettings(data / L"missing-defaults.json", layouts);
|
||||
registrationWarning = L"Settings were not loaded: " + wide(e.what());
|
||||
}
|
||||
setLoggingEnabled(settings.writeLog);
|
||||
try
|
||||
{
|
||||
history = std::make_unique<History>(data / L"history");
|
||||
@@ -242,6 +259,8 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
|
||||
}
|
||||
App::~App()
|
||||
{
|
||||
if (updateWorker.joinable())
|
||||
updateWorker.join();
|
||||
if (keyboardHook)
|
||||
UnhookWindowsHookEx(keyboardHook);
|
||||
if (mouseHook)
|
||||
@@ -349,6 +368,7 @@ void App::applySettings(const Settings &s)
|
||||
{
|
||||
std::lock_guard lock(stateMutex);
|
||||
settings = s;
|
||||
setLoggingEnabled(s.writeLog);
|
||||
run.reset();
|
||||
last.reset();
|
||||
++inputVersion;
|
||||
@@ -381,6 +401,18 @@ void App::reloadLayouts()
|
||||
}
|
||||
registerHotkeys(settings);
|
||||
}
|
||||
void App::clearHistory()
|
||||
{
|
||||
if (busy) throw std::runtime_error("Wait for the current operation to finish.");
|
||||
if (history) history->clear();
|
||||
if (historyWindow) refreshHistory(*this);
|
||||
}
|
||||
void App::setHotkeyCapture(HWND field)
|
||||
{
|
||||
hotkeyCapture = field;
|
||||
hotkeyCaptureProcess = 0;
|
||||
if (field) GetWindowThreadProcessId(field, &hotkeyCaptureProcess);
|
||||
}
|
||||
bool App::isHotkey(DWORD key, UINT mods)
|
||||
{
|
||||
for (auto &[id, text] : shortcuts(settings))
|
||||
@@ -398,23 +430,15 @@ void App::resetInput()
|
||||
run.reset();
|
||||
last.reset();
|
||||
}
|
||||
void App::keyboard(const KBDLLHOOKSTRUCT &k)
|
||||
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
||||
{
|
||||
if (k.flags & LLKHF_INJECTED)
|
||||
if (ownKeyboardInput(k))
|
||||
return;
|
||||
DWORD vk = k.vkCode;
|
||||
if (vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LCONTROL || vk == VK_RCONTROL || vk == VK_LMENU ||
|
||||
if (vk == VK_SHIFT || vk == VK_CONTROL || vk == VK_MENU ||
|
||||
vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LCONTROL || vk == VK_RCONTROL || vk == VK_LMENU ||
|
||||
vk == VK_RMENU || vk == VK_LWIN || vk == VK_RWIN)
|
||||
return;
|
||||
UINT mods = 0;
|
||||
if (GetAsyncKeyState(VK_CONTROL) & 0x8000)
|
||||
mods |= MOD_CONTROL;
|
||||
if (GetAsyncKeyState(VK_MENU) & 0x8000)
|
||||
mods |= MOD_ALT;
|
||||
if (GetAsyncKeyState(VK_SHIFT) & 0x8000)
|
||||
mods |= MOD_SHIFT;
|
||||
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000)
|
||||
mods |= MOD_WIN;
|
||||
std::lock_guard lock(stateMutex);
|
||||
if (isHotkey(vk, mods))
|
||||
return;
|
||||
@@ -433,7 +457,11 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k)
|
||||
return;
|
||||
}
|
||||
bool shift = (mods & MOD_SHIFT) != 0;
|
||||
wchar_t ch = vk == VK_SPACE ? L' ' : physicalKey(k.scanCode, shift);
|
||||
// VK_PACKET carries UTF-16 in scanCode, not a physical key position.
|
||||
DWORD scan = vk == VK_PACKET ? 0 : k.scanCode;
|
||||
if (!scan && vk != VK_PACKET)
|
||||
scan = MapVirtualKeyExW(vk, MAPVK_VK_TO_VSC_EX, layout);
|
||||
wchar_t ch = vk == VK_SPACE ? L' ' : physicalKey(scan, shift);
|
||||
if (ch && letter(ch) && (GetKeyState(VK_CAPITAL) & 1))
|
||||
ch = shift ? lower(std::wstring(1, ch))[0] : upper(std::wstring(1, ch))[0];
|
||||
if (!ch)
|
||||
@@ -477,87 +505,94 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k)
|
||||
}
|
||||
}
|
||||
}
|
||||
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
|
||||
bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
||||
{
|
||||
if (code == HC_ACTION && (((KBDLLHOOKSTRUCT *)lp)->flags & LLKHF_INJECTED) &&
|
||||
((KBDLLHOOKSTRUCT *)lp)->vkCode == 0xE8)
|
||||
return CallNextHookEx(nullptr, code, wp, lp); // Our shell-mask key is never recorded.
|
||||
if (code == HC_ACTION && current && current->hotkeyCapture &&
|
||||
GetForegroundWindow() == GetAncestor(current->hotkeyCapture, GA_ROOT))
|
||||
// Filter before capture, hotkey latching and typing history. External injected
|
||||
// down AND up events use exactly the same path as physical keyboard input.
|
||||
if (ownKeyboardInput(key) || installingUpdate)
|
||||
return false;
|
||||
DWORD captureProcess{};
|
||||
if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess);
|
||||
if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess))
|
||||
setHotkeyCapture(nullptr);
|
||||
if (hotkeyCapture && GetForegroundWindow() == GetAncestor(hotkeyCapture, GA_ROOT))
|
||||
{
|
||||
auto &key = *(KBDLLHOOKSTRUCT *)lp;
|
||||
bool modifier = key.vkCode == VK_LCONTROL || key.vkCode == VK_RCONTROL || key.vkCode == VK_LSHIFT ||
|
||||
bool modifier = key.vkCode == VK_CONTROL || key.vkCode == VK_SHIFT || key.vkCode == VK_MENU ||
|
||||
key.vkCode == VK_LCONTROL || key.vkCode == VK_RCONTROL || key.vkCode == VK_LSHIFT ||
|
||||
key.vkCode == VK_RSHIFT || key.vkCode == VK_LMENU || key.vkCode == VK_RMENU ||
|
||||
key.vkCode == VK_LWIN || key.vkCode == VK_RWIN;
|
||||
if (!modifier)
|
||||
{
|
||||
if (wp == WM_KEYUP || wp == WM_SYSKEYUP)
|
||||
current->hotkeyHook.update(key.vkCode, false, 0, {});
|
||||
if (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN)
|
||||
if (!down)
|
||||
hotkeyHook.update(key.vkCode, false, 0, {});
|
||||
if (down)
|
||||
{
|
||||
UINT mods = 0;
|
||||
if (GetAsyncKeyState(VK_CONTROL) & 0x8000)
|
||||
mods |= MOD_CONTROL;
|
||||
if (GetAsyncKeyState(VK_MENU) & 0x8000)
|
||||
mods |= MOD_ALT;
|
||||
if (GetAsyncKeyState(VK_SHIFT) & 0x8000)
|
||||
mods |= MOD_SHIFT;
|
||||
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000)
|
||||
mods |= MOD_WIN;
|
||||
if (mods & MOD_WIN)
|
||||
chord(0xE8);
|
||||
PostMessageW(current->hotkeyCapture, CaptureHotkeyMessage, key.vkCode, mods);
|
||||
PostMessageW(hotkeyCapture, CaptureHotkeyMessage, key.vkCode, mods);
|
||||
}
|
||||
return 1; // Record the chord without executing a global shortcut or typing into an editor.
|
||||
}
|
||||
}
|
||||
if (code == HC_ACTION && (((KBDLLHOOKSTRUCT *)lp)->flags & LLKHF_INJECTED))
|
||||
return CallNextHookEx(nullptr, code, wp, lp);
|
||||
if (code == HC_ACTION && (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN) && current)
|
||||
if (down)
|
||||
{
|
||||
auto &key = *(KBDLLHOOKSTRUCT *)lp;
|
||||
UINT mods = 0;
|
||||
if (GetAsyncKeyState(VK_CONTROL) & 0x8000)
|
||||
mods |= MOD_CONTROL;
|
||||
if (GetAsyncKeyState(VK_MENU) & 0x8000)
|
||||
mods |= MOD_ALT;
|
||||
if (GetAsyncKeyState(VK_SHIFT) & 0x8000)
|
||||
mods |= MOD_SHIFT;
|
||||
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000)
|
||||
mods |= MOD_WIN;
|
||||
auto action = current->hotkeyHook.update(key.vkCode, true, mods, current->hookBindings);
|
||||
auto action = hotkeyHook.update(key.vkCode, true, mods, hookBindings);
|
||||
if (action > 0)
|
||||
{
|
||||
// Tell the shell that Win participated in a chord, preventing Start
|
||||
// from opening on Win-up after we consumed V. 0xE8 is unassigned.
|
||||
if (mods & (MOD_WIN | MOD_ALT))
|
||||
chord(0xE8);
|
||||
PostMessageW(current->window, WM_HOTKEY, action, 0);
|
||||
PostMessageW(window, WM_HOTKEY, action, 0);
|
||||
}
|
||||
if (action != 0)
|
||||
return 1;
|
||||
current->keyboard(*(KBDLLHOOKSTRUCT *)lp);
|
||||
keyboard(key, mods);
|
||||
}
|
||||
else if (hotkeyHook.update(key.vkCode, false, 0, hookBindings) != 0)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (code == HC_ACTION && current &&
|
||||
(wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN || wp == WM_KEYUP || wp == WM_SYSKEYUP))
|
||||
{
|
||||
// The current event has not updated asynchronous state yet; the preceding
|
||||
// modifier events (including MWB input) have. Own events must still reach Windows.
|
||||
UINT mods = 0;
|
||||
if (GetAsyncKeyState(VK_CONTROL) & 0x8000) mods |= MOD_CONTROL;
|
||||
if (GetAsyncKeyState(VK_MENU) & 0x8000) mods |= MOD_ALT;
|
||||
if (GetAsyncKeyState(VK_SHIFT) & 0x8000) mods |= MOD_SHIFT;
|
||||
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000) mods |= MOD_WIN;
|
||||
if (current->handleKeyboard(*(KBDLLHOOKSTRUCT *)lp,
|
||||
wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN, mods))
|
||||
return 1;
|
||||
}
|
||||
if (code == HC_ACTION && (wp == WM_KEYUP || wp == WM_SYSKEYUP) && current &&
|
||||
current->hotkeyHook.update(((KBDLLHOOKSTRUCT *)lp)->vkCode, false, 0, current->hookBindings) != 0)
|
||||
return 1;
|
||||
return CallNextHookEx(nullptr, code, wp, lp);
|
||||
}
|
||||
LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (code == HC_ACTION && current)
|
||||
{
|
||||
auto *e = (MSLLHOOKSTRUCT *)lp;
|
||||
if (!(e->flags & LLMHF_INJECTED) &&
|
||||
(wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL))
|
||||
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
|
||||
// range just like local clicks, including while a transaction is pending.
|
||||
if (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL)
|
||||
current->resetInput();
|
||||
}
|
||||
return CallNextHookEx(nullptr, code, wp, lp);
|
||||
}
|
||||
void App::dispatch(int command)
|
||||
{
|
||||
if (installingUpdate)
|
||||
return;
|
||||
try
|
||||
{
|
||||
if (command == Screenshot)
|
||||
{
|
||||
if (!busy) { resetInput(); launchScreenshot(*this); }
|
||||
return;
|
||||
}
|
||||
if (command == ShowHistory)
|
||||
{
|
||||
showHistory(*this);
|
||||
@@ -568,6 +603,11 @@ void App::dispatch(int command)
|
||||
showSettings(*this);
|
||||
return;
|
||||
}
|
||||
if (command == 22)
|
||||
{
|
||||
showLogs(*this);
|
||||
return;
|
||||
}
|
||||
if (command == 21)
|
||||
{
|
||||
showSnippets(*this);
|
||||
@@ -587,9 +627,34 @@ void App::dispatch(int command)
|
||||
}
|
||||
void App::transform(int command, int snippet)
|
||||
{
|
||||
if (busy.exchange(true))
|
||||
return;
|
||||
if (installingUpdate) return;
|
||||
const auto attempt = ++transformAttempt;
|
||||
const auto requestedAt = GetTickCount64();
|
||||
HWND target = GetForegroundWindow();
|
||||
const bool manual = command != AutoCorrect && snippet < 0;
|
||||
if (manual)
|
||||
{
|
||||
const auto currentVersion = inputVersion.load();
|
||||
repeatAttempts = target == previousAttemptWindow && command == previousAttemptCommand &&
|
||||
currentVersion == previousAttemptVersion &&
|
||||
requestedAt - previousAttemptAt < 10000
|
||||
? repeatAttempts + 1 : 1;
|
||||
previousAttemptWindow = target;
|
||||
previousAttemptCommand = command;
|
||||
previousAttemptVersion = currentVersion;
|
||||
previousAttemptAt = requestedAt;
|
||||
diagnosticEvent(L"Transform request id=" + std::to_wstring(attempt) + L" command=" +
|
||||
std::to_wstring(command) + L" process=" + processName(target) +
|
||||
L" window=" + std::to_wstring(reinterpret_cast<uintptr_t>(target)) +
|
||||
L" version=" + std::to_wstring(currentVersion) +
|
||||
L" repeat=" + std::to_wstring(repeatAttempts));
|
||||
}
|
||||
if (busy.exchange(true))
|
||||
{
|
||||
if (manual)
|
||||
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true);
|
||||
return;
|
||||
}
|
||||
Run recent;
|
||||
std::optional<LastTransform> previous;
|
||||
Settings config;
|
||||
@@ -604,7 +669,7 @@ void App::transform(int command, int snippet)
|
||||
version = inputVersion;
|
||||
}
|
||||
enqueue(
|
||||
[this, target, recent, previous, config, available, version, command, snippet]
|
||||
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt]
|
||||
{
|
||||
struct BusyReset
|
||||
{
|
||||
@@ -615,6 +680,47 @@ void App::transform(int command, int snippet)
|
||||
}
|
||||
} reset{busy};
|
||||
auto start = GetTickCount64();
|
||||
std::wstring reason = L"cancelled-before-selection";
|
||||
std::wstring text, result, expected;
|
||||
int hint = -1, to = command >= Direct ? command - Direct : -1;
|
||||
if (manual)
|
||||
{
|
||||
std::wstring layoutIds;
|
||||
for (const auto &layout : available)
|
||||
{
|
||||
layoutIds += layout.id + L",";
|
||||
if (layout.handle == recent.layout)
|
||||
for (wchar_t key : recent.keys)
|
||||
expected += key == L' ' ? L' ' : layout.at(physicalLayout().code(key));
|
||||
}
|
||||
diagnosticEvent(L"Transform context id=" + std::to_wstring(attempt) +
|
||||
L" layouts=" + layoutIds + L" physicalKeys=" + diagnosticText(recent.keys) +
|
||||
L" trackedExpected=" + diagnosticText(expected));
|
||||
}
|
||||
struct Audit
|
||||
{
|
||||
App &app;
|
||||
HWND target;
|
||||
uint64_t version, attempt;
|
||||
ULONGLONG start;
|
||||
bool enabled;
|
||||
std::wstring &reason;
|
||||
std::wstring &text, &result, &expected;
|
||||
int &hint, &to;
|
||||
~Audit()
|
||||
{
|
||||
if (enabled)
|
||||
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=" + reason +
|
||||
L" foregroundSame=" + std::to_wstring(GetForegroundWindow() == target) +
|
||||
L" inputSame=" + std::to_wstring(app.inputVersion.load() == version) +
|
||||
L" elapsedMs=" + std::to_wstring(GetTickCount64() - start) +
|
||||
L" source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
|
||||
L" expected=" + diagnosticText(expected) + L" copied=" + diagnosticText(text) +
|
||||
L" result=" + diagnosticText(result),
|
||||
reason != L"paste-sent-unverified" && reason != L"layout-only" &&
|
||||
reason != L"unchanged-expected");
|
||||
}
|
||||
} audit{*this, target, version, attempt, requestedAt, manual, reason, text, result, expected, hint, to};
|
||||
try
|
||||
{
|
||||
auto valid = [&]
|
||||
@@ -627,16 +733,16 @@ void App::transform(int command, int snippet)
|
||||
releaseModifiers();
|
||||
if (!valid())
|
||||
return;
|
||||
int hint = -1;
|
||||
for (int i = 0; i < (int)available.size(); ++i)
|
||||
if (available[i].handle == windowLayout(target))
|
||||
hint = i;
|
||||
int to = command >= Direct ? command - Direct : -1;
|
||||
reason = L"invalid-target-layout";
|
||||
if (to >= (int)available.size())
|
||||
return;
|
||||
if (to >= 0 && !available[to].convertible)
|
||||
{
|
||||
switchWindowLayout(target, available[to].handle, valid);
|
||||
reason = switchWindowLayout(target, available[to].handle, valid)
|
||||
? L"layout-only" : L"layout-switch-failed";
|
||||
return;
|
||||
}
|
||||
if (command == AutoCorrect)
|
||||
@@ -654,12 +760,16 @@ void App::transform(int command, int snippet)
|
||||
!autoCorrectionAllowed(target, config) || !valid())
|
||||
return;
|
||||
}
|
||||
reason = L"clipboard-snapshot";
|
||||
Transaction tx(*this, target, version);
|
||||
log(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
|
||||
reason = L"selection-unavailable";
|
||||
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
|
||||
L" recent=" + std::to_wstring(config.recent) + L" keys=" +
|
||||
std::to_wstring(recent.keys.size()) + L" sameWindow=" +
|
||||
std::to_wstring(recent.window == target));
|
||||
std::wstring text, result;
|
||||
std::to_wstring(recent.window == target) + L" overflow=" +
|
||||
std::to_wstring(recent.overflow) + L" ageMs=" +
|
||||
std::to_wstring(recent.at ? start - recent.at : 0) +
|
||||
L" id=" + std::to_wstring(attempt));
|
||||
bool automatic = false;
|
||||
if (command == AutoCorrect)
|
||||
{
|
||||
@@ -692,6 +802,9 @@ void App::transform(int command, int snippet)
|
||||
}
|
||||
else if (command == Undo)
|
||||
{
|
||||
if (previous)
|
||||
expected = previous->result;
|
||||
reason = L"undo-unavailable";
|
||||
if (!previous || previous->window != target || previous->version != version ||
|
||||
GetTickCount64() - previous->at > 120000)
|
||||
return;
|
||||
@@ -700,6 +813,7 @@ void App::transform(int command, int snippet)
|
||||
{ return newlines(candidate) == newlines(previous->result); });
|
||||
if (newlines(text) != newlines(previous->result))
|
||||
{
|
||||
reason = L"undo-verification-mismatch";
|
||||
if (tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
return;
|
||||
@@ -738,10 +852,11 @@ void App::transform(int command, int snippet)
|
||||
tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate)
|
||||
{ return recent.match(candidate, available, physicalLayout()) >= 0; });
|
||||
hint = recent.match(text, available, physicalLayout());
|
||||
log(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" +
|
||||
diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" +
|
||||
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
|
||||
if (hint < 0)
|
||||
{
|
||||
reason = text.empty() ? L"recent-copy-empty" : L"recent-verification-mismatch";
|
||||
if (tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
return;
|
||||
@@ -751,11 +866,13 @@ void App::transform(int command, int snippet)
|
||||
else if (config.recent && previous && previous->window == target &&
|
||||
previous->version == version)
|
||||
{
|
||||
expected = previous->result;
|
||||
text = tx.copyRange(caretSteps(previous->result), previous->result,
|
||||
[&](const std::wstring &candidate)
|
||||
{ return newlines(candidate) == newlines(previous->result); });
|
||||
if (newlines(text) != newlines(previous->result))
|
||||
{
|
||||
reason = L"previous-result-verification-mismatch";
|
||||
if (tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
return;
|
||||
@@ -765,10 +882,12 @@ void App::transform(int command, int snippet)
|
||||
}
|
||||
else
|
||||
text = tx.copy();
|
||||
reason = L"context-changed-after-copy";
|
||||
if (!tx.valid())
|
||||
return;
|
||||
if (text.empty())
|
||||
{
|
||||
reason = L"copy-empty";
|
||||
if (to >= 0 && config.switchLayout)
|
||||
switchWindowLayout(target, available[to].handle, valid);
|
||||
return;
|
||||
@@ -790,6 +909,7 @@ void App::transform(int command, int snippet)
|
||||
cycle.push_back(i);
|
||||
if (cycle.size() < 2)
|
||||
{
|
||||
reason = L"cycle-has-fewer-than-two-layouts";
|
||||
if (automatic && tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
return;
|
||||
@@ -804,12 +924,20 @@ void App::transform(int command, int snippet)
|
||||
}
|
||||
}
|
||||
bool sent = false;
|
||||
log(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
|
||||
diagnosticEvent(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
|
||||
L" changed=" + std::to_wstring(result != text));
|
||||
if (result != text)
|
||||
sent = tx.paste(textClip(result));
|
||||
else if (automatic && tx.valid())
|
||||
chord(VK_RIGHT);
|
||||
reason = result == text ? L"conversion-unchanged" :
|
||||
sent ? L"paste-sent-unverified" : L"paste-cancelled";
|
||||
if (result == text &&
|
||||
(std::none_of(text.begin(), text.end(), letter) ||
|
||||
(command >= Direct && detect(text, available, hint) == to)))
|
||||
reason = L"unchanged-expected";
|
||||
if (sent && !tx.valid())
|
||||
reason = L"context-changed-after-paste";
|
||||
if (sent && command == Undo && previous && previous->autoCorrected)
|
||||
autoCorrector.reject(previous->original, config);
|
||||
if (tx.valid() && (sent || automatic))
|
||||
@@ -835,18 +963,22 @@ void App::transform(int command, int snippet)
|
||||
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
|
||||
{
|
||||
bool switched = switchWindowLayout(target, available[to].handle, valid);
|
||||
log(L"Layout switch confirmed=" + std::to_wstring(switched));
|
||||
diagnosticEvent(L"Layout switch confirmed=" + std::to_wstring(switched));
|
||||
if (!switched)
|
||||
reason += L";layout-switch-failed";
|
||||
}
|
||||
log(L"Text operation completed in " + std::to_wstring(GetTickCount64() - start) + L" ms");
|
||||
diagnosticEvent(L"Text operation completed in " + std::to_wstring(GetTickCount64() - start) + L" ms");
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
reason = L"exception:" + wide(e.what());
|
||||
report(wide(e.what()));
|
||||
}
|
||||
});
|
||||
}
|
||||
void App::pasteEntry(std::wstring id, bool plain)
|
||||
{
|
||||
if (installingUpdate) return;
|
||||
if (!history || busy.exchange(true))
|
||||
return;
|
||||
HWND target = pasteTarget;
|
||||
@@ -971,6 +1103,7 @@ void App::trayMenu()
|
||||
{
|
||||
HMENU menu = CreatePopupMenu();
|
||||
AppendMenuW(menu, MF_STRING, 10, L"Clipboard history");
|
||||
AppendMenuW(menu, MF_STRING, 24, L"Capture screen region…");
|
||||
AppendMenuW(menu, MF_STRING, 11, L"Settings…");
|
||||
SetMenuDefaultItem(menu, 11, FALSE);
|
||||
AppendMenuW(menu, MF_STRING, 12, L"Snippets…");
|
||||
@@ -984,6 +1117,10 @@ void App::trayMenu()
|
||||
AppendMenuW(pause, MF_STRING, 23, L"Until restart");
|
||||
AppendMenuW(menu, MF_POPUP, (UINT_PTR)pause, L"Pause clipboard history");
|
||||
AppendMenuW(menu, MF_STRING, 15, L"Open data folder");
|
||||
AppendMenuW(menu, MF_STRING, 18, L"View log…");
|
||||
AppendMenuW(menu, MF_STRING | (updateRunning ? MF_GRAYED : 0), 19,
|
||||
updateRunning ? L"Update (checking / preparing…)" :
|
||||
availableUpdate.empty() ? L"Update" : L"Update (new version available)");
|
||||
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(menu, MF_STRING, 16, L"About KSwitcher");
|
||||
AppendMenuW(menu, MF_STRING, 17, L"Exit");
|
||||
@@ -998,6 +1135,9 @@ void App::trayMenu()
|
||||
{
|
||||
switch (cmd)
|
||||
{
|
||||
case 24:
|
||||
dispatch(Screenshot);
|
||||
break;
|
||||
case 10:
|
||||
showHistory(*this);
|
||||
break;
|
||||
@@ -1022,14 +1162,20 @@ void App::trayMenu()
|
||||
break;
|
||||
case 16:
|
||||
MessageBoxW(window,
|
||||
L"KSwitcher 0.1.0\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on "
|
||||
(std::wstring(L"KSwitcher ") + AppVersion + L"\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on "
|
||||
L"Typlo by bitcodepro. MIT license.\nHistory is encrypted for your Windows "
|
||||
L"account.\nNo .NET, WebView or graphics framework.",
|
||||
L"account.\nNo .NET, WebView or graphics framework.").c_str(),
|
||||
L"About KSwitcher", MB_OK);
|
||||
break;
|
||||
case 17:
|
||||
PostMessageW(window, WM_CLOSE, 0, 0);
|
||||
break;
|
||||
case 18:
|
||||
showLogs(*this);
|
||||
break;
|
||||
case 19:
|
||||
checkUpdate(true);
|
||||
break;
|
||||
case 20:
|
||||
pauseUntil = 0;
|
||||
break;
|
||||
@@ -1049,6 +1195,32 @@ void App::trayMenu()
|
||||
report(wide(e.what()));
|
||||
}
|
||||
}
|
||||
void App::checkUpdate(bool interactive, bool install, const std::wstring &version)
|
||||
{
|
||||
if (updateRunning) return;
|
||||
if (install && FindWindowW(L"KSwitcher.Config", nullptr))
|
||||
{
|
||||
report(L"Close the configurator before installing an update.");
|
||||
return;
|
||||
}
|
||||
if (install && busy)
|
||||
{
|
||||
report(L"Wait for the current text operation to finish, then select Update again.");
|
||||
return;
|
||||
}
|
||||
if (updateWorker.joinable()) updateWorker.join();
|
||||
updateRunning = true;
|
||||
installingUpdate = install;
|
||||
updateWorker = std::thread([this, interactive, install, version]
|
||||
{
|
||||
auto result = std::make_unique<UpdateResult>();
|
||||
try { *result = runUpdate(install, version); }
|
||||
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
|
||||
result->interactive = interactive;
|
||||
result->install = install;
|
||||
if (PostMessageW(window, UpdateMessage, 0, reinterpret_cast<LPARAM>(result.get()))) result.release();
|
||||
});
|
||||
}
|
||||
LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
auto *app = current;
|
||||
@@ -1061,6 +1233,50 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
switch (msg)
|
||||
{
|
||||
case UpdateMessage:
|
||||
{
|
||||
std::unique_ptr<UpdateResult> result(reinterpret_cast<UpdateResult *>(lp));
|
||||
if (app->updateWorker.joinable()) app->updateWorker.join();
|
||||
app->updateRunning = false;
|
||||
app->installingUpdate = false;
|
||||
if (result->state == L"prepared")
|
||||
{
|
||||
// The helper has verified and staged the package; it waits for process exit.
|
||||
DestroyWindow(hwnd);
|
||||
}
|
||||
else if (result->state == L"available")
|
||||
{
|
||||
app->availableUpdate = result->version;
|
||||
if (result->interactive)
|
||||
{
|
||||
auto prompt = L"Install KSwitcher " + result->version +
|
||||
L"?\n\nThe program will close and restart. Settings, history and existing dictionaries are preserved.";
|
||||
if (MessageBoxW(hwnd, prompt.c_str(), L"KSwitcher Update", MB_YESNO | MB_ICONQUESTION) == IDYES)
|
||||
app->checkUpdate(true, true, result->version);
|
||||
}
|
||||
else
|
||||
{
|
||||
NOTIFYICONDATAW icon{sizeof(icon)};
|
||||
icon.hWnd = hwnd; icon.uID = 1; icon.uFlags = NIF_INFO; icon.dwInfoFlags = NIIF_INFO;
|
||||
wcscpy_s(icon.szInfoTitle, L"KSwitcher update available");
|
||||
auto text = result->version + L" is available. Select Update in the tray menu to install.";
|
||||
wcsncpy_s(icon.szInfo, text.c_str(), _TRUNCATE);
|
||||
Shell_NotifyIconW(NIM_MODIFY, &icon);
|
||||
}
|
||||
}
|
||||
else if (result->state == L"current")
|
||||
{
|
||||
app->availableUpdate.clear();
|
||||
if (result->interactive)
|
||||
MessageBoxW(hwnd, (std::wstring(L"No newer stable release is available.\nInstalled version: ") + AppVersion).c_str(),
|
||||
L"KSwitcher Update", MB_OK | MB_ICONINFORMATION);
|
||||
}
|
||||
else if (result->interactive)
|
||||
MessageBoxW(hwnd, (L"Update failed.\n\n" + result->error).c_str(), L"KSwitcher Update", MB_OK | MB_ICONERROR);
|
||||
else
|
||||
diagnosticEvent(L"Startup update check failed: " + result->error);
|
||||
return 0;
|
||||
}
|
||||
case WM_HOTKEY:
|
||||
app->dispatch((int)wp);
|
||||
return 0;
|
||||
@@ -1078,10 +1294,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
app->captureHistory();
|
||||
return 0;
|
||||
case AutoCorrectMessage:
|
||||
if (app->installingUpdate) return 0;
|
||||
app->pendingAutoVersion = (uint64_t)wp;
|
||||
SetTimer(hwnd, 3, 250, nullptr);
|
||||
return 0;
|
||||
case SnippetMessage:
|
||||
if (app->installingUpdate) return 0;
|
||||
app->transform(0, (int)wp);
|
||||
return 0;
|
||||
case HistoryMessage:
|
||||
@@ -1104,6 +1322,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
case WM_COPYDATA:
|
||||
{
|
||||
auto *data = (COPYDATASTRUCT *)lp;
|
||||
if (data && data->dwData >= ConfigRead && data->dwData < ConfigReply)
|
||||
{
|
||||
if (app->installingUpdate) return FALSE;
|
||||
return configurationRequest(*app, (HWND)wp, *data);
|
||||
}
|
||||
if (data && data->dwData == 1 && data->cbData == sizeof(int))
|
||||
{
|
||||
int cmd;
|
||||
@@ -1113,6 +1336,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
return TRUE;
|
||||
}
|
||||
case WM_TIMER:
|
||||
if (wp == 5)
|
||||
{
|
||||
KillTimer(hwnd, 5);
|
||||
app->checkUpdate(false);
|
||||
return 0;
|
||||
}
|
||||
if (wp == 4)
|
||||
{
|
||||
if (GetAsyncKeyState(VK_LBUTTON) & 0x8000)
|
||||
@@ -1124,7 +1353,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
if (wp == 3)
|
||||
{
|
||||
KillTimer(hwnd, 3);
|
||||
if (app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
|
||||
if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
|
||||
app->transform(AutoCorrect);
|
||||
return 0;
|
||||
}
|
||||
@@ -1141,6 +1370,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
}
|
||||
return 0;
|
||||
case WM_CLOSE:
|
||||
if (app->updateRunning)
|
||||
{
|
||||
app->report(L"Wait for the update check or preparation to finish before exiting.");
|
||||
return 0;
|
||||
}
|
||||
if (app->busy)
|
||||
{
|
||||
app->report(L"Wait for the current text operation to finish before exiting.");
|
||||
@@ -1149,6 +1383,9 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
||||
DestroyWindow(hwnd);
|
||||
return 0;
|
||||
case WM_DESTROY:
|
||||
if (auto config = FindWindowW(L"KSwitcher.Config", nullptr);
|
||||
config && configurationPeer(config, L"KSwitcher.Config.exe"))
|
||||
PostMessageW(config, WM_CLOSE, 0, 0);
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
default:
|
||||
@@ -1210,8 +1447,13 @@ int App::execute()
|
||||
report(registrationWarning);
|
||||
if (smoke)
|
||||
SetTimer(window, 2, 10000, nullptr);
|
||||
else
|
||||
SetTimer(window, 5, 5000, nullptr);
|
||||
if (keyboardHook && mouseHook)
|
||||
confirmUpdatedStartup(updateStartupEvent);
|
||||
if (startCommand)
|
||||
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
||||
auto screenshotActivation=startScreenshotActivationListener();
|
||||
MSG msg;
|
||||
while (GetMessageW(&msg, nullptr, 0, 0) > 0)
|
||||
{
|
||||
@@ -1225,9 +1467,12 @@ int App::execute()
|
||||
continue;
|
||||
if (snippetsWindow && IsDialogMessageW(snippetsWindow, &msg))
|
||||
continue;
|
||||
if (logWindow && IsDialogMessageW(logWindow, &msg))
|
||||
continue;
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
if(screenshotActivation) CoRevokeClassObject(screenshotActivation);
|
||||
return (int)msg.wParam;
|
||||
}
|
||||
} // namespace ks
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "autocorrect.h"
|
||||
#include "storage.h"
|
||||
#include "update.h"
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
@@ -13,7 +14,7 @@ namespace ks
|
||||
inline constexpr UINT TrayMessage = WM_APP + 1, ErrorMessage = WM_APP + 2, HistoryMessage = WM_APP + 3,
|
||||
SnippetMessage = WM_APP + 4;
|
||||
inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, AutoCorrect = 6,
|
||||
Direct = 100;
|
||||
Screenshot = 7, Direct = 100;
|
||||
inline constexpr UINT AutoCorrectMessage = WM_APP + 6;
|
||||
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
|
||||
struct LastTransform
|
||||
@@ -42,12 +43,15 @@ class App
|
||||
std::atomic<ULONGLONG> pauseUntil{0};
|
||||
HWND pasteTarget{};
|
||||
HWND hotkeyCapture{};
|
||||
DWORD hotkeyCaptureProcess{};
|
||||
HWND logWindow{};
|
||||
std::mutex stateMutex;
|
||||
Run run;
|
||||
std::optional<LastTransform> last;
|
||||
std::wstring registrationWarning;
|
||||
bool smoke{};
|
||||
int startCommand{};
|
||||
std::wstring updateStartupEvent;
|
||||
App(HINSTANCE, bool = false, const std::filesystem::path &dataRoot = {});
|
||||
~App();
|
||||
int execute();
|
||||
@@ -63,29 +67,43 @@ class App
|
||||
bool registerHotkeys(const Settings &, bool rollback = false);
|
||||
void applySettings(const Settings &);
|
||||
void reloadLayouts();
|
||||
void clearHistory();
|
||||
void setHotkeyCapture(HWND);
|
||||
bool isHotkey(DWORD, UINT);
|
||||
void keyboard(const KBDLLHOOKSTRUCT &);
|
||||
void keyboard(const KBDLLHOOKSTRUCT &, UINT modifiers);
|
||||
bool handleKeyboard(const KBDLLHOOKSTRUCT &, bool down, UINT modifiers);
|
||||
void resetInput();
|
||||
void checkUpdate(bool interactive, bool install = false, const std::wstring &version = {});
|
||||
static App *current;
|
||||
|
||||
private:
|
||||
friend struct InputRegressionAccess;
|
||||
HHOOK keyboardHook{}, mouseHook{};
|
||||
std::mutex jobsMutex;
|
||||
std::condition_variable jobsReady;
|
||||
std::deque<std::function<void()>> jobs;
|
||||
std::thread worker;
|
||||
std::thread updateWorker;
|
||||
bool updateRunning{}, installingUpdate{};
|
||||
std::wstring availableUpdate;
|
||||
bool stop{};
|
||||
UINT taskbarCreated{};
|
||||
uint64_t pendingAutoVersion{};
|
||||
uint64_t transformAttempt{}, previousAttemptVersion{};
|
||||
ULONGLONG previousAttemptAt{};
|
||||
HWND previousAttemptWindow{};
|
||||
int previousAttemptCommand{}, repeatAttempts{};
|
||||
std::vector<std::pair<int, Hotkey>> hookBindings;
|
||||
HotkeyHook hotkeyHook;
|
||||
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
|
||||
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
||||
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
|
||||
};
|
||||
void showHistory(App &);
|
||||
void showHistory(App &, bool visible = true);
|
||||
void launchScreenshot(App &);
|
||||
void refreshHistory(App &);
|
||||
void showSettings(App &);
|
||||
void showSettings(App &, bool visible = true);
|
||||
void showLogs(App &);
|
||||
void showSnippets(App &);
|
||||
void showShortcuts(App &);
|
||||
void showEntry(App &, const std::wstring &);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
#include "configuration.h"
|
||||
#include <commctrl.h>
|
||||
#include <cstring>
|
||||
|
||||
namespace ks
|
||||
{
|
||||
App *App::current = nullptr;
|
||||
namespace
|
||||
{
|
||||
HWND service{};
|
||||
Bytes response;
|
||||
bool replied{};
|
||||
Bytes request(App &app, ULONG_PTR command, const Bytes &payload = {})
|
||||
{
|
||||
if (!service || !configurationPeer(service, L"KSwitcher.exe"))
|
||||
throw std::runtime_error("KSwitcher has stopped. Close and reopen the configurator.");
|
||||
if (payload.size() > ConfigLimit) throw std::runtime_error("Settings are too large.");
|
||||
response.clear(); replied = false;
|
||||
COPYDATASTRUCT message{command, (DWORD)payload.size(), const_cast<unsigned char *>(payload.data())};
|
||||
DWORD_PTR result{};
|
||||
SetLastError(ERROR_SUCCESS);
|
||||
if (!SendMessageTimeoutW(service, WM_COPYDATA, (WPARAM)app.window, (LPARAM)&message,
|
||||
SMTO_ABORTIFHUNG | SMTO_ERRORONEXIT, 15000, &result) || !replied)
|
||||
{
|
||||
if (GetLastError() == ERROR_ACCESS_DENIED)
|
||||
throw std::runtime_error("Open Settings from the KSwitcher tray menu so both processes use the same privileges.");
|
||||
throw std::runtime_error("KSwitcher did not respond. The operation may still finish; reopen Settings to check.");
|
||||
}
|
||||
if (result != 1)
|
||||
throw std::runtime_error(response.empty() ? "KSwitcher rejected the request." :
|
||||
std::string(response.begin(), response.end()));
|
||||
return response;
|
||||
}
|
||||
LRESULT CALLBACK controller(HWND window, UINT message, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
auto *app = App::current;
|
||||
if (!app) return DefWindowProcW(window, message, wp, lp);
|
||||
if (message == WM_COPYDATA)
|
||||
{
|
||||
auto *data = reinterpret_cast<COPYDATASTRUCT *>(lp);
|
||||
if ((HWND)wp == service && data && data->dwData == ConfigReply && data->cbData <= ConfigLimit &&
|
||||
(!data->cbData || data->lpData))
|
||||
{
|
||||
response.clear();
|
||||
if (data->cbData)
|
||||
{
|
||||
auto first = static_cast<const unsigned char *>(data->lpData);
|
||||
response.assign(first, first + data->cbData);
|
||||
}
|
||||
replied = true;
|
||||
return TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
if (message == WM_APP + 20)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (wp == 21) showSnippets(*app);
|
||||
else showSettings(*app);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
MessageBoxW(app->settingsWindow, wide(e.what()).c_str(), L"KSwitcher configurator", MB_OK | MB_ICONERROR);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (message == WM_CLOSE)
|
||||
{
|
||||
if (app->settingsWindow) DestroyWindow(app->settingsWindow);
|
||||
if (app->snippetsWindow) DestroyWindow(app->snippetsWindow);
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProcW(window, message, wp, lp);
|
||||
}
|
||||
}
|
||||
// The configurator owns no history store, dictionaries, worker or input hooks.
|
||||
// All mutations are validated and committed by the resident process.
|
||||
App::App(HINSTANCE h, bool, const std::filesystem::path &) : instance(h)
|
||||
{
|
||||
current = this;
|
||||
layouts = readLayouts();
|
||||
}
|
||||
App::~App()
|
||||
{
|
||||
if (settingsWindow) DestroyWindow(settingsWindow);
|
||||
if (snippetsWindow) DestroyWindow(snippetsWindow);
|
||||
if (window) DestroyWindow(window);
|
||||
current = nullptr;
|
||||
}
|
||||
void App::applySettings(const Settings &next)
|
||||
{
|
||||
validateSettings(next);
|
||||
auto before = encodeSettings(settings), after = encodeSettings(next);
|
||||
DWORD length = (DWORD)before.size();
|
||||
Bytes payload(sizeof(length));
|
||||
memcpy(payload.data(), &length, sizeof(length));
|
||||
payload.insert(payload.end(), before.begin(), before.end());
|
||||
payload.insert(payload.end(), after.begin(), after.end());
|
||||
settings = decodeSettings(request(*this, ConfigSave, payload), layouts);
|
||||
}
|
||||
void App::reloadLayouts()
|
||||
{
|
||||
auto bytes = request(*this, ConfigLayouts);
|
||||
layouts = readLayouts();
|
||||
settings = decodeSettings(bytes, layouts);
|
||||
}
|
||||
void App::clearHistory() { request(*this, ConfigClearHistory); }
|
||||
void App::setHotkeyCapture(HWND field)
|
||||
{
|
||||
hotkeyCapture = field;
|
||||
Bytes payload(sizeof(field));
|
||||
memcpy(payload.data(), &field, sizeof(field));
|
||||
// Focus notifications must not let an exception escape a window callback.
|
||||
try { request(*this, ConfigCapture, payload); }
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
hotkeyCapture = nullptr;
|
||||
if (field) MessageBoxW(settingsWindow, wide(e.what()).c_str(), L"Shortcut capture unavailable", MB_OK | MB_ICONWARNING);
|
||||
}
|
||||
}
|
||||
void showLogs(App &app) { request(app, ConfigLogs); }
|
||||
|
||||
int runConfigurator(HINSTANCE instance, bool snippets)
|
||||
{
|
||||
HANDLE singleton = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Config.Singleton");
|
||||
if (!singleton) throw std::runtime_error("Cannot create configurator instance lock.");
|
||||
const bool existing = GetLastError() == ERROR_ALREADY_EXISTS;
|
||||
struct Close { HANDLE handle; ~Close() { CloseHandle(handle); } } close{singleton};
|
||||
if (existing)
|
||||
{
|
||||
HWND window{};
|
||||
for (int i = 0; i < 100 && !window; ++i)
|
||||
{
|
||||
window = FindWindowW(L"KSwitcher.Config", nullptr);
|
||||
if (!window) Sleep(50);
|
||||
}
|
||||
if (!window || !configurationPeer(window, L"KSwitcher.Config.exe"))
|
||||
throw std::runtime_error("The configurator is starting or belongs to another installation. Try again shortly.");
|
||||
DWORD pid{}; GetWindowThreadProcessId(window, &pid);
|
||||
AllowSetForegroundWindow(pid);
|
||||
PostMessageW(window, WM_APP + 20, snippets ? 21 : 20, 0);
|
||||
return 0;
|
||||
}
|
||||
service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
||||
if (!service)
|
||||
{
|
||||
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.exe";
|
||||
auto command = L"\"" + path.wstring() + L"\"";
|
||||
STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{};
|
||||
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
||||
path.parent_path().c_str(), &startup, &process))
|
||||
throw std::runtime_error("Cannot start KSwitcher beside the configurator.");
|
||||
CloseHandle(process.hThread); CloseHandle(process.hProcess);
|
||||
for (int i = 0; i < 100 && !service; ++i)
|
||||
{
|
||||
Sleep(50); service = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
|
||||
}
|
||||
}
|
||||
if (!configurationPeer(service, L"KSwitcher.exe"))
|
||||
throw std::runtime_error("Run the configurator from the active KSwitcher installation.");
|
||||
INITCOMMONCONTROLSEX controls{sizeof(controls), ICC_STANDARD_CLASSES};
|
||||
InitCommonControlsEx(&controls);
|
||||
App app(instance);
|
||||
WNDCLASSW cls{}; cls.hInstance = instance; cls.lpfnWndProc = controller; cls.lpszClassName = L"KSwitcher.Config";
|
||||
RegisterClassW(&cls);
|
||||
app.window = CreateWindowExW(WS_EX_TOOLWINDOW, cls.lpszClassName, L"KSwitcher configurator", WS_POPUP,
|
||||
0, 0, 0, 0, nullptr, nullptr, instance, nullptr);
|
||||
if (!app.window) throw std::runtime_error("Cannot create configurator window.");
|
||||
app.settings = decodeSettings(request(app, ConfigRead), app.layouts);
|
||||
if (snippets) showSnippets(app); else showSettings(app);
|
||||
MSG message;
|
||||
while ((app.settingsWindow || app.snippetsWindow) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
||||
{
|
||||
if (app.settingsWindow && !IsWindowEnabled(app.settingsWindow))
|
||||
{
|
||||
HWND popup = GetLastActivePopup(app.settingsWindow);
|
||||
if (popup != app.settingsWindow && IsDialogMessageW(popup, &message)) continue;
|
||||
}
|
||||
if (app.settingsWindow && IsDialogMessageW(app.settingsWindow, &message)) continue;
|
||||
if (app.snippetsWindow && IsDialogMessageW(app.snippetsWindow, &message)) continue;
|
||||
TranslateMessage(&message); DispatchMessageW(&message);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
||||
{
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
int result = 1;
|
||||
try
|
||||
{
|
||||
int count{}; auto args = CommandLineToArgvW(GetCommandLineW(), &count);
|
||||
bool snippets = count > 1 && std::wstring(args[1]) == L"--snippets";
|
||||
LocalFree(args);
|
||||
result = ks::runConfigurator(instance, snippets);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
MessageBoxW(nullptr, ks::wide(e.what()).c_str(), L"KSwitcher configurator", MB_OK | MB_ICONERROR);
|
||||
}
|
||||
CoUninitialize();
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#include "configuration.h"
|
||||
#include <cstring>
|
||||
namespace ks
|
||||
{
|
||||
bool configurationPeer(HWND window, const wchar_t *filename)
|
||||
{
|
||||
DWORD pid{};
|
||||
GetWindowThreadProcessId(window, &pid);
|
||||
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
|
||||
if (!process) return false;
|
||||
wchar_t path[32768]; DWORD count = std::size(path);
|
||||
bool ok = QueryFullProcessImageNameW(process, 0, path, &count) != 0;
|
||||
CloseHandle(process);
|
||||
auto expected = std::filesystem::path(executable()).parent_path() / filename;
|
||||
return ok && _wcsicmp(path, expected.c_str()) == 0;
|
||||
}
|
||||
void launchConfiguration(App &app, bool snippets)
|
||||
{
|
||||
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Config.exe";
|
||||
if (!std::filesystem::exists(path))
|
||||
throw std::runtime_error("KSwitcher.Config.exe is missing. Extract the complete application package.");
|
||||
auto command = L"\"" + path.wstring() + L"\"" + (snippets ? L" --snippets" : L"");
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
|
||||
path.parent_path().c_str(), &startup, &process))
|
||||
throw std::runtime_error(utf8(lastError(L"Cannot start configurator")));
|
||||
AllowSetForegroundWindow(process.dwProcessId);
|
||||
CloseHandle(process.hThread);
|
||||
CloseHandle(process.hProcess);
|
||||
(void)app;
|
||||
}
|
||||
LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &message)
|
||||
{
|
||||
if (!configurationPeer(sender, L"KSwitcher.Config.exe") || message.cbData > ConfigLimit ||
|
||||
(message.cbData && !message.lpData)) return FALSE;
|
||||
Bytes reply;
|
||||
bool ok = false;
|
||||
try
|
||||
{
|
||||
switch (message.dwData)
|
||||
{
|
||||
case ConfigSave:
|
||||
{
|
||||
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
|
||||
if (message.cbData < sizeof(DWORD)) throw std::runtime_error("Invalid settings request.");
|
||||
DWORD length{};
|
||||
memcpy(&length, message.lpData, sizeof(length));
|
||||
if (length > message.cbData - sizeof(length)) throw std::runtime_error("Invalid settings snapshot.");
|
||||
auto begin = static_cast<const unsigned char *>(message.lpData) + sizeof(length);
|
||||
if (Bytes(begin, begin + length) != encodeSettings(app.settings))
|
||||
throw std::runtime_error("Settings changed in KSwitcher. Close and reopen the configurator before saving.");
|
||||
Bytes next(begin + length, static_cast<const unsigned char *>(message.lpData) + message.cbData);
|
||||
if (next.empty()) throw std::runtime_error("Empty settings request.");
|
||||
app.applySettings(decodeSettings(next, app.layouts));
|
||||
reply = encodeSettings(app.settings);
|
||||
break;
|
||||
}
|
||||
case ConfigLayouts:
|
||||
if (app.busy) throw std::runtime_error("Wait for the current text operation to finish.");
|
||||
app.reloadLayouts();
|
||||
[[fallthrough]];
|
||||
case ConfigRead:
|
||||
reply = encodeSettings(app.settings);
|
||||
break;
|
||||
case ConfigClearHistory:
|
||||
app.clearHistory();
|
||||
break;
|
||||
case ConfigCapture:
|
||||
{
|
||||
if (message.cbData != sizeof(HWND)) throw std::runtime_error("Invalid shortcut field.");
|
||||
HWND field{}; memcpy(&field, message.lpData, sizeof(field));
|
||||
DWORD fieldPid{}, senderPid{};
|
||||
GetWindowThreadProcessId(field, &fieldPid);
|
||||
GetWindowThreadProcessId(sender, &senderPid);
|
||||
if (field && fieldPid != senderPid) throw std::runtime_error("Invalid shortcut field owner.");
|
||||
app.setHotkeyCapture(field);
|
||||
break;
|
||||
}
|
||||
case ConfigLogs:
|
||||
PostMessageW(app.window, WM_HOTKEY, 22, 0);
|
||||
break;
|
||||
default: return FALSE;
|
||||
}
|
||||
ok = true;
|
||||
}
|
||||
catch (const std::exception &error)
|
||||
{
|
||||
std::string text = error.what();
|
||||
reply.assign(text.begin(), text.end());
|
||||
}
|
||||
COPYDATASTRUCT response{ConfigReply, (DWORD)reply.size(), reply.data()};
|
||||
DWORD_PTR received{};
|
||||
if (!SendMessageTimeoutW(sender, WM_COPYDATA, (WPARAM)app.window, (LPARAM)&response,
|
||||
SMTO_ABORTIFHUNG, 2000, &received)) return FALSE;
|
||||
return ok ? 1 : 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
#include "app.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
|
||||
ConfigLayouts = ConfigRead + 2, ConfigClearHistory = ConfigRead + 3,
|
||||
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
|
||||
ConfigReply = ConfigRead + 6;
|
||||
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
|
||||
bool configurationPeer(HWND, const wchar_t *filename);
|
||||
void launchConfiguration(App &, bool snippets = false);
|
||||
LRESULT configurationRequest(App &, HWND sender, const COPYDATASTRUCT &);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ struct Settings
|
||||
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
||||
bool elevatedAutostart = false;
|
||||
bool autoCorrect = false;
|
||||
bool writeLog = true;
|
||||
int autoMaxRank = 0;
|
||||
int autoMinLength = 3;
|
||||
std::wstring autoPrefixChars = L"([{«“‘\"";
|
||||
@@ -58,7 +59,8 @@ struct Settings
|
||||
L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||
int maxItems = 500, maxMiB = 200, maxImageMiB = 20;
|
||||
std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C",
|
||||
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Ctrl+Shift+V";
|
||||
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Win+V",
|
||||
screenshot = L"Win+Shift+S";
|
||||
std::vector<std::wstring> cycle, direct, directIds;
|
||||
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||
std::vector<Snippet> snippets;
|
||||
|
||||
@@ -31,7 +31,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
||||
HANDLE mutex = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Native.Singleton");
|
||||
bool existing = GetLastError() == ERROR_ALREADY_EXISTS;
|
||||
int cmd = ks::ShowHistory;
|
||||
if (arg == L"--convert")
|
||||
if (arg == L"--capture")
|
||||
cmd = ks::Screenshot;
|
||||
else if (arg == L"--convert")
|
||||
cmd = ks::Toggle;
|
||||
else if (arg == L"--case")
|
||||
cmd = ks::Case;
|
||||
@@ -67,7 +69,9 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
|
||||
try
|
||||
{
|
||||
ks::App app(instance, arg == L"--smoke-test");
|
||||
if (!arg.empty() && arg != L"--smoke-test")
|
||||
if (arg.starts_with(L"--update-ready-event="))
|
||||
app.updateStartupEvent = arg.substr(std::wstring(L"--update-ready-event=").size());
|
||||
else if (!arg.empty() && arg != L"--smoke-test")
|
||||
app.startCommand = cmd;
|
||||
result = app.execute();
|
||||
}
|
||||
|
||||
@@ -347,15 +347,30 @@ size_t Clip::size() const
|
||||
n += f.data.size();
|
||||
return n;
|
||||
}
|
||||
ULONG_PTR inputMarker()
|
||||
{
|
||||
static const char marker{};
|
||||
return reinterpret_cast<ULONG_PTR>(&marker);
|
||||
}
|
||||
bool ownKeyboardInput(const KBDLLHOOKSTRUCT &key)
|
||||
{
|
||||
return (key.flags & LLKHF_INJECTED) && key.dwExtraInfo == inputMarker();
|
||||
}
|
||||
INPUT keyboardInput(WORD key, DWORD flags)
|
||||
{
|
||||
INPUT input{};
|
||||
input.type = INPUT_KEYBOARD;
|
||||
input.ki.wVk = key;
|
||||
input.ki.dwFlags = flags;
|
||||
input.ki.dwExtraInfo = inputMarker();
|
||||
return input;
|
||||
}
|
||||
bool chord(WORD key, UINT mods)
|
||||
{
|
||||
std::vector<INPUT> inputs;
|
||||
auto add = [&](WORD k, bool up)
|
||||
{
|
||||
INPUT i{};
|
||||
i.type = INPUT_KEYBOARD;
|
||||
i.ki.wVk = k;
|
||||
i.ki.dwFlags = up ? KEYEVENTF_KEYUP : 0;
|
||||
INPUT i = keyboardInput(k, up ? KEYEVENTF_KEYUP : 0);
|
||||
if (k == VK_LEFT || k == VK_RIGHT || k == VK_INSERT)
|
||||
i.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
|
||||
inputs.push_back(i);
|
||||
@@ -406,10 +421,7 @@ bool selectPrevious(size_t count, const std::function<bool()> &valid)
|
||||
std::vector<INPUT> inputs;
|
||||
auto add = [&](WORD key, DWORD flags)
|
||||
{
|
||||
INPUT i{};
|
||||
i.type = INPUT_KEYBOARD;
|
||||
i.ki.wVk = key;
|
||||
i.ki.dwFlags = flags;
|
||||
INPUT i = keyboardInput(key, flags);
|
||||
inputs.push_back(i);
|
||||
};
|
||||
add(VK_SHIFT, 0);
|
||||
@@ -437,10 +449,7 @@ void releaseModifiers()
|
||||
for (int k : {VK_LCONTROL, VK_RCONTROL, VK_LMENU, VK_RMENU, VK_LSHIFT, VK_RSHIFT, VK_LWIN, VK_RWIN})
|
||||
if (GetAsyncKeyState(k) & 0x8000)
|
||||
{
|
||||
INPUT i{};
|
||||
i.type = INPUT_KEYBOARD;
|
||||
i.ki.wVk = (WORD)k;
|
||||
i.ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
INPUT i = keyboardInput((WORD)k, KEYEVENTF_KEYUP);
|
||||
inputs.push_back(i);
|
||||
}
|
||||
if (!inputs.empty())
|
||||
@@ -527,16 +536,112 @@ std::wstring lastError(const std::wstring &context)
|
||||
{
|
||||
return context + L" (Windows error " + std::to_wstring(GetLastError()) + L")";
|
||||
}
|
||||
std::wstring diagnosticText(const std::wstring &text)
|
||||
{
|
||||
// Escape UTF-16 code units so invisible characters and malformed surrogate pairs
|
||||
// can be reproduced without introducing fake log lines. Bound large selections.
|
||||
std::wstring out = L"\"";
|
||||
constexpr wchar_t hex[] = L"0123456789abcdef";
|
||||
const auto count = std::min<size_t>(text.size(), 512);
|
||||
for (size_t i = 0; i < count; ++i)
|
||||
{
|
||||
auto c = static_cast<unsigned short>(text[i]);
|
||||
if (c == L'"' || c == L'\\')
|
||||
{
|
||||
out += L'\\';
|
||||
out += c;
|
||||
}
|
||||
else if (c < 32 || c >= 127)
|
||||
{
|
||||
out += L"\\u";
|
||||
for (int shift = 12; shift >= 0; shift -= 4)
|
||||
out += hex[(c >> shift) & 15];
|
||||
}
|
||||
else
|
||||
out += c;
|
||||
}
|
||||
return out + L"\" units=" + std::to_wstring(text.size()) +
|
||||
L" truncated=" + std::to_wstring(count != text.size());
|
||||
}
|
||||
namespace
|
||||
{
|
||||
std::recursive_mutex logGate;
|
||||
bool loggingEnabled = false;
|
||||
DiagnosticBuffer diagnosticBuffer;
|
||||
}
|
||||
std::wstring DiagnosticBuffer::record(const std::wstring &message, bool problem, ULONGLONG now)
|
||||
{
|
||||
while (!entries.empty() && (entries.size() >= 40 || now - entries.front().first > 30000))
|
||||
entries.pop_front();
|
||||
entries.emplace_back(now, message);
|
||||
if (!problem)
|
||||
return {};
|
||||
std::wstring out = L"Diagnostic incident (preceding context, up to 30 seconds):";
|
||||
for (const auto &[at, entry] : entries)
|
||||
out += L"\n ageMs=" + std::to_wstring(now - at) + L" " + entry;
|
||||
entries.clear();
|
||||
return out;
|
||||
}
|
||||
void setLoggingEnabled(bool enabled)
|
||||
{
|
||||
std::lock_guard lock(logGate);
|
||||
loggingEnabled = enabled;
|
||||
if (!enabled)
|
||||
diagnosticBuffer.clear();
|
||||
}
|
||||
std::wstring readDiagnosticLog(bool previous)
|
||||
{
|
||||
std::lock_guard lock(logGate);
|
||||
auto path = dataDirectory() / (previous ? L"kswitcher.log.1" : L"kswitcher.log");
|
||||
if (!std::filesystem::exists(path))
|
||||
return previous ? L"No previous log is available yet." : L"No log yet. Entries will appear when a problem is detected.";
|
||||
auto bytes = readFile(path, 2 * 1024 * 1024);
|
||||
auto text = wide(std::string(bytes.begin(), bytes.end()));
|
||||
std::wstring display;
|
||||
for (size_t i = 0; i < text.size(); ++i)
|
||||
{
|
||||
if (text[i] == L'\n' && (i == 0 || text[i - 1] != L'\r'))
|
||||
display += L'\r';
|
||||
display += text[i];
|
||||
}
|
||||
return display.empty() ? L"The log is empty." : display;
|
||||
}
|
||||
void clearDiagnosticLog(bool previous)
|
||||
{
|
||||
// Serialize clearing with writes and rotation. Never delete the other log.
|
||||
std::lock_guard lock(logGate);
|
||||
auto path = dataDirectory() / (previous ? L"kswitcher.log.1" : L"kswitcher.log");
|
||||
std::filesystem::remove(path);
|
||||
if (!previous)
|
||||
diagnosticBuffer.clear();
|
||||
}
|
||||
void diagnosticEvent(const std::wstring &message, bool problem)
|
||||
{
|
||||
std::lock_guard lock(logGate);
|
||||
if (!loggingEnabled)
|
||||
return;
|
||||
auto incident = diagnosticBuffer.record(message, problem, GetTickCount64());
|
||||
if (!incident.empty())
|
||||
log(incident);
|
||||
}
|
||||
void log(const std::wstring &message)
|
||||
{
|
||||
static std::mutex gate;
|
||||
std::lock_guard lock(gate);
|
||||
std::lock_guard lock(logGate);
|
||||
if (!loggingEnabled)
|
||||
return;
|
||||
try
|
||||
{
|
||||
auto path = dataDirectory() / L"kswitcher.log";
|
||||
std::error_code ec;
|
||||
if (std::filesystem::file_size(path, ec) > 256 * 1024)
|
||||
std::filesystem::remove(path, ec);
|
||||
auto size = std::filesystem::file_size(path, ec);
|
||||
if (!ec && size > 256 * 1024)
|
||||
{
|
||||
auto previous = path;
|
||||
previous += L".1";
|
||||
std::filesystem::remove(previous, ec);
|
||||
ec.clear();
|
||||
std::filesystem::rename(path, previous, ec);
|
||||
}
|
||||
std::ofstream f(path, std::ios::app);
|
||||
SYSTEMTIME t;
|
||||
GetLocalTime(&t);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "core.h"
|
||||
#include <filesystem>
|
||||
#include <deque>
|
||||
#include <objbase.h>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
@@ -35,6 +36,10 @@ Clip readClipboard(HWND, size_t limit = 32 * 1024 * 1024);
|
||||
DWORD writeClipboard(HWND, const Clip &);
|
||||
Clip textClip(const std::wstring &);
|
||||
bool chord(WORD key, UINT modifiers = 0);
|
||||
// Process-local marker, shared by every KSwitcher input producer (not an authentication token).
|
||||
ULONG_PTR inputMarker();
|
||||
bool ownKeyboardInput(const KBDLLHOOKSTRUCT &);
|
||||
INPUT keyboardInput(WORD key, DWORD flags = 0);
|
||||
bool needsSelectionDelay(HWND);
|
||||
bool selectPrevious(size_t count, const std::function<bool()> &valid);
|
||||
bool modifiersReleased();
|
||||
@@ -50,4 +55,17 @@ void configureAutostart(int mode);
|
||||
std::wstring executable();
|
||||
std::wstring lastError(const std::wstring &context);
|
||||
void log(const std::wstring &message);
|
||||
std::wstring diagnosticText(const std::wstring &text);
|
||||
// A bounded, short-lived context. A problem drains it, so old incidents are not repeated.
|
||||
class DiagnosticBuffer
|
||||
{
|
||||
std::deque<std::pair<ULONGLONG, std::wstring>> entries;
|
||||
public:
|
||||
std::wstring record(const std::wstring &message, bool problem, ULONGLONG now);
|
||||
void clear() { entries.clear(); }
|
||||
};
|
||||
void setLoggingEnabled(bool enabled);
|
||||
void diagnosticEvent(const std::wstring &message, bool problem = false);
|
||||
std::wstring readDiagnosticLog(bool previous);
|
||||
void clearDiagnosticLog(bool previous);
|
||||
} // namespace ks
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#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 &){};
|
||||
};
|
||||
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 ScreenshotMark {
|
||||
POINT from, to; int number; int radius = 15; int thickness = 3;
|
||||
int fromNumber = 0, toNumber = 0;
|
||||
std::vector<POINT> points;
|
||||
COLORREF color = RGB(225,42,50);
|
||||
bool freehand = false;
|
||||
LineStyle style = LineStyle::Solid;
|
||||
LineEnds ends = LineEnds::End;
|
||||
ScreenshotShape shape = ScreenshotShape::None;
|
||||
std::wstring text, fontName=L"Segoe UI";
|
||||
int fontSize=24, fontWeight=FW_NORMAL;
|
||||
bool fontItalic=false;
|
||||
double rotation=0;
|
||||
std::wstring label;
|
||||
ScreenshotOffset labelOffset{0,-24}; // Along the line and perpendicular to it, in image pixels.
|
||||
bool labelRotationManual=false;
|
||||
bool isText() const { return !text.empty(); }
|
||||
bool isHighlight() const { return !points.empty() && !freehand; }
|
||||
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
|
||||
};
|
||||
class ScreenshotHistory {
|
||||
std::deque<std::vector<ScreenshotMark>> past, future;
|
||||
public:
|
||||
void remember(const std::vector<ScreenshotMark> &);
|
||||
bool undo(std::vector<ScreenshotMark> &);
|
||||
bool redo(std::vector<ScreenshotMark> &);
|
||||
bool canUndo() const { return !past.empty(); }
|
||||
bool canRedo() const { return !future.empty(); }
|
||||
};
|
||||
void resolveScreenshotLinks(std::vector<ScreenshotMark> &);
|
||||
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
|
||||
const std::vector<ScreenshotMark> &, double tolerance);
|
||||
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label };
|
||||
POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16);
|
||||
ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height);
|
||||
int nextScreenshotNumber(const std::vector<ScreenshotMark> &);
|
||||
MarkPart hitScreenshotMark(const ScreenshotMark &, POINT, double tolerance);
|
||||
ScreenshotMark dragScreenshotMark(const ScreenshotMark &, MarkPart, POINT origin, POINT position,
|
||||
int width, int height, bool snapRotation=false);
|
||||
POINT constrainScreenshotShape(ScreenshotShape, POINT anchor, POINT position, int width, int height);
|
||||
RECT screenshotTextBounds(const ScreenshotMark &);
|
||||
POINT screenshotTextRotationHandle(const ScreenshotMark &, double clearance=24);
|
||||
ScreenshotMark screenshotArrowLabel(const ScreenshotMark &);
|
||||
// Render at source resolution; never reads or writes the system clipboard.
|
||||
Clip screenshotClip(HDC source, int width, int height, const std::vector<ScreenshotMark> &marks);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
#include "screenshot_notifications.h"
|
||||
#include <atomic>
|
||||
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}};
|
||||
}
|
||||
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 || !validScreenshotToken(args)) return E_INVALIDARG;
|
||||
auto path=target; auto command=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,21 @@
|
||||
#include "app.h"
|
||||
|
||||
namespace ks {
|
||||
void launchScreenshot(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"\"";
|
||||
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,109 @@
|
||||
#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) {
|
||||
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;
|
||||
int argc{}; auto argv=CommandLineToArgvW(GetCommandLineW(),&argc);
|
||||
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>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,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,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,220 @@
|
||||
#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";
|
||||
constexpr GUID ActivatorId{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}};
|
||||
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 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 registerNotifications()
|
||||
{
|
||||
auto exe=executable();
|
||||
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));
|
||||
// The notification broker caches protocol handlers independently of ShellExecute.
|
||||
SHChangeNotify(SHCNE_ASSOCCHANGED,SHCNF_IDLIST,nullptr,nullptr);
|
||||
check(SetCurrentProcessExplicitAppUserModelID(AppId));
|
||||
}
|
||||
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 {
|
||||
registerNotifications();
|
||||
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(); return false; } // Clipboard is already populated; fall back to the editor.
|
||||
}
|
||||
std::string screenshotNotificationError() { return notificationError; }
|
||||
void registerScreenshotNotifications() { registerNotifications(); }
|
||||
|
||||
}
|
||||
@@ -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 &);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ bool validId(const std::wstring &id)
|
||||
void validateSettings(const Settings &s)
|
||||
{
|
||||
std::vector<Hotkey> used;
|
||||
std::vector<std::wstring> keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard};
|
||||
std::vector<std::wstring> keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard, s.screenshot};
|
||||
keys.insert(keys.end(), s.direct.begin(), s.direct.end());
|
||||
for (auto &text : keys)
|
||||
{
|
||||
@@ -102,6 +102,10 @@ void validateSettings(const Settings &s)
|
||||
}
|
||||
}
|
||||
Settings loadSettings(const std::filesystem::path &path, const std::vector<Layout> &layouts)
|
||||
{
|
||||
return decodeSettings(std::filesystem::exists(path) ? readFile(path, 4 * 1024 * 1024) : Bytes{}, layouts);
|
||||
}
|
||||
Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
||||
{
|
||||
Settings s;
|
||||
for (size_t i = 0; i < layouts.size(); ++i)
|
||||
@@ -111,14 +115,15 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
|
||||
s.direct.push_back(i < 3 ? L"Ctrl+Alt+Shift+" + std::to_wstring(i + 1) : L"");
|
||||
s.directIds.push_back(layouts[i].id);
|
||||
}
|
||||
if (!std::filesystem::exists(path))
|
||||
if (bytes.empty())
|
||||
return s;
|
||||
auto j = parse(readFile(path, 4 * 1024 * 1024));
|
||||
auto j = parse(bytes);
|
||||
auto format = j.value("format", std::string());
|
||||
if (format != "kswitcher-settings" && format != "typlo-settings")
|
||||
throw std::runtime_error("Not a KSwitcher or Typlo settings file");
|
||||
s.recent = j.value("convertLastWord", true);
|
||||
s.switchLayout = j.value("switchSystemLayout", true);
|
||||
s.writeLog = j.value("writeLog", true);
|
||||
s.history = j.value("historyEnabled", true);
|
||||
s.autostart = j.value("startWithSystem", false);
|
||||
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
|
||||
@@ -145,6 +150,7 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
|
||||
s.translit = str(j, "translitHotkey", s.translit);
|
||||
s.undo = str(j, "undoHotkey", s.undo);
|
||||
s.clipboard = str(j, "clipboardHotkey", s.clipboard);
|
||||
s.screenshot = str(j, "screenshotHotkey", s.screenshot);
|
||||
if (j.contains("layouts"))
|
||||
{
|
||||
s.cycle.clear();
|
||||
@@ -176,12 +182,17 @@ Settings loadSettings(const std::filesystem::path &path, const std::vector<Layou
|
||||
return s;
|
||||
}
|
||||
void saveSettings(const std::filesystem::path &path, const Settings &s)
|
||||
{
|
||||
atomicWrite(path, encodeSettings(s));
|
||||
}
|
||||
Bytes encodeSettings(const Settings &s)
|
||||
{
|
||||
validateSettings(s);
|
||||
Json j = {{"format", "kswitcher-settings"},
|
||||
{"schemaVersion", 1},
|
||||
{"convertLastWord", s.recent},
|
||||
{"switchSystemLayout", s.switchLayout},
|
||||
{"writeLog", s.writeLog},
|
||||
{"autostartAsAdministrator", s.elevatedAutostart},
|
||||
{"historyEnabled", s.history},
|
||||
{"startWithSystem", s.autostart},
|
||||
@@ -193,7 +204,8 @@ void saveSettings(const std::filesystem::path &path, const Settings &s)
|
||||
{"caseHotkey", utf8(s.casing)},
|
||||
{"translitHotkey", utf8(s.translit)},
|
||||
{"undoHotkey", utf8(s.undo)},
|
||||
{"clipboardHotkey", utf8(s.clipboard)}};
|
||||
{"clipboardHotkey", utf8(s.clipboard)},
|
||||
{"screenshotHotkey", utf8(s.screenshot)}};
|
||||
j["layouts"] = Json::array();
|
||||
for (auto &id : s.cycle)
|
||||
j["layouts"].push_back(utf8(id));
|
||||
@@ -219,7 +231,7 @@ void saveSettings(const std::filesystem::path &path, const Settings &s)
|
||||
j["excludedApps"] = Json::array();
|
||||
for (auto &name : s.excluded)
|
||||
j["excludedApps"].push_back(utf8(name));
|
||||
atomicWrite(path, jsonBytes(j));
|
||||
return jsonBytes(j);
|
||||
}
|
||||
Bytes encodeClip(const Clip &clip)
|
||||
{
|
||||
|
||||