Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b180798f5 | |||
| 21a09e33be | |||
| 3dbd7317f1 | |||
| 510d0ed211 | |||
| dd9629f8fd | |||
| ccaa8dad3f | |||
| d1eabfca88 | |||
| dc615d0223 |
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.3.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()
|
||||
@@ -8,8 +8,8 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp)
|
||||
target_include_directories(kscore PUBLIC src PRIVATE vendor)
|
||||
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
|
||||
target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
|
||||
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(kscore PUBLIC 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)
|
||||
@@ -18,20 +18,36 @@ 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/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
target_link_libraries(KSwitcherIntegration PRIVATE kscore)
|
||||
add_executable(KSwitcherInputTests tests/input.cpp src/app.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
|
||||
add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.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 KSwitcherConfig KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
target_compile_options(kscore PRIVATE /W4 /utf-8)
|
||||
target_compile_options(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++)
|
||||
@@ -40,9 +56,10 @@ endif()
|
||||
enable_testing()
|
||||
add_test(NAME native-regressions COMMAND KSwitcherTests)
|
||||
add_test(NAME input-regressions COMMAND KSwitcherInputTests)
|
||||
add_test(NAME screenshot-ui-regressions COMMAND KSwitcherScreenshotUITests)
|
||||
add_test(NAME updater-regressions COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/update.ps1" -Executable "$<TARGET_FILE:KSwitcher>")
|
||||
set_tests_properties(native-regressions PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
install(TARGETS KSwitcher KSwitcherConfig RUNTIME DESTINATION .)
|
||||
install(TARGETS KSwitcher KSwitcherConfig KSwitcherScreenshot RUNTIME DESTINATION .)
|
||||
install(FILES LICENSE vendor/json-LICENSE.txt DESTINATION .)
|
||||
|
||||
add_custom_command(TARGET KSwitcher POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/dictionaries" "$<TARGET_FILE_DIR:KSwitcher>/dictionaries")
|
||||
|
||||
@@ -19,3 +19,50 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
Third-party toolbar icons: Lucide (https://lucide.dev)
|
||||
|
||||
ISC License
|
||||
|
||||
Copyright (c) 2026 Lucide Icons and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
The following Lucide icons are derived from the Feather project:
|
||||
|
||||
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
|
||||
|
||||
The MIT License (MIT) (for the icons listed above)
|
||||
|
||||
Copyright (c) 2013-present Cole Bemis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -4,7 +4,11 @@ Native C++20 / Win32 rewrite of the Windows functionality of [Typlo](https://git
|
||||
No .NET, Avalonia, WebView, Qt, external graphics engine, or separately installed C++ runtime.
|
||||
The original [Typlo](https://github.com/bitcodepro/Typlo) project and its data remain separate.
|
||||
|
||||
**[User guide / Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki)** — setup, features, shortcuts, dictionaries, screenshots and troubleshooting (Ukrainian).
|
||||
**[User guide / Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki)** — setup, features, shortcuts, dictionaries, screenshots and troubleshooting in **English** and **[Українська](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Home-uk)**. Each wiki page links to its translation.
|
||||
|
||||
[](https://send.monobank.ua/jar/88h2TddKfS)
|
||||
|
||||
[Donate via Monobank](https://send.monobank.ua/jar/88h2TddKfS) · Thank you for supporting KSwitcher.
|
||||
|
||||
## Run
|
||||
|
||||
@@ -16,12 +20,25 @@ Settings and the snippet editor run on demand in **KSwitcher.Config.exe**, besid
|
||||
Repeated launches activate the existing configurator; closing its last window exits that process.
|
||||
Saving applies changes immediately through the resident KSwitcher process. The configurator does not
|
||||
load clipboard history, dictionaries or global input hooks. Clipboard history and the log viewer remain
|
||||
in the resident app. Keep both executables together when copying or updating the application.
|
||||
in the resident app. Screen capture and all screenshot editors run on demand in
|
||||
**KSwitcher.Screenshot.exe**. The capture hotkey starts it; subsequent captures reuse the same
|
||||
process. Closing the last editor (or cancelling the only selection) exits that process. It does not
|
||||
load resident settings, dictionaries, clipboard history or keyboard hooks. Editor windows have
|
||||
stable numbered titles within that process. Keep all three executables together when copying or
|
||||
updating the application. Close screenshot editors before installing an update.
|
||||
If settings change in the tray while the configurator is open, a conflicting Save is rejected; reopen
|
||||
the configurator to load the latest settings.
|
||||
The first upgrade from v0.2.3 or earlier to this two-executable build requires manual extraction:
|
||||
the older updater rejects additional executable files. Exit KSwitcher, replace both executables,
|
||||
and retain `data/` and customized dictionaries. Subsequent updates support both executables.
|
||||
Updates from v0.3.x–v0.4.1 first install the compatible **v0.4.2 bridge** through
|
||||
Update in the tray menu. Select Update again to install the full current release;
|
||||
manual extraction is not required. Future full releases use stable `release-vX.Y.Z`
|
||||
tags, which the bridge and newer clients recognize. Older clients ignore those tags
|
||||
and continue to find the bridge instead of skipping it. Versions v0.2.3 and earlier
|
||||
still require the previously documented initial configurator migration.
|
||||
|
||||
Modern ZIPs contain `release-files.json` (schema, version, file paths, sizes and SHA-256
|
||||
hashes). The updater validates that every packaged file is declared, all hashes match,
|
||||
and paths stay within the installation directory. `data/` is protected; existing
|
||||
dictionaries are preserved. Adding a new program file no longer needs a new updater.
|
||||
In Settings, enable **Start with Windows** and **Start with Windows as administrator**
|
||||
to use a per-user Task Scheduler logon task with highest privileges. Enabling or changing
|
||||
the elevated task requests UAC approval if the app is not already elevated. No password is stored;
|
||||
@@ -53,6 +70,90 @@ keys cannot trigger recursive commands or overwrite the recorded typing run. Oth
|
||||
is accepted, including key-up events and VK-only keyboard events. Unicode packets do not provide
|
||||
physical key positions and therefore reset recent-word tracking; selection conversion still works.
|
||||
|
||||
### Screen capture and annotations
|
||||
|
||||
Press **Win+Shift+S**, choose **Capture screen region…** in the tray menu, or run
|
||||
`KSwitcher.exe --capture`. Drag a rectangle on the dimmed, frozen desktop; the selected area stays
|
||||
at normal brightness. Escape cancels. Selection and annotation frames are buffered to avoid flicker.
|
||||
The selected area is copied immediately and opens in the editor, even when Windows notifications
|
||||
are disabled, suppressed or cannot be sent. Disable **Open editor after screen capture** in settings
|
||||
to open it only by clicking the notification; an immediate notification error still opens the editor.
|
||||
The preference applies to each new capture, including while other editors are open.
|
||||
KSwitcher also sends a notification with a thumbnail. Notification identity and activation are registered
|
||||
at resident startup and refreshed before sending; registration/submission errors are written to the log.
|
||||
Click the notification to reopen the original screenshot, including after the screenshot process has exited.
|
||||
Originals in the local notification cache are DPAPI-encrypted. Thumbnail PNGs live in a separate
|
||||
per-user cache under ProgramData, readable only by that user and SYSTEM, so the Windows notification
|
||||
broker can load them even when the profile directory is inaccessible to it.
|
||||
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
|
||||
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
|
||||
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
|
||||
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
|
||||
@@ -72,9 +173,12 @@ intact and is reported when Update is selected. Startup network failures remain
|
||||
The helper uses Windows' built-in Windows PowerShell 5.1; no additional runtime is installed.
|
||||
|
||||
Settings and the other utility windows use the destination monitor's DPI for their initial frame,
|
||||
controls and fonts. They can be resized, stay within a remaining monitor's work area after display
|
||||
changes, and provide scrollbars when their content does not fit. Tab navigation scrolls the focused
|
||||
controls and fonts. Settings has a fixed form size, without manual resizing or maximization; other
|
||||
utility windows remain resizable. Windows stay within a remaining monitor's work area after display
|
||||
changes and provide scrollbars when their content does not fit. Tab navigation scrolls the focused
|
||||
field into view; Shift+wheel scrolls horizontally.
|
||||
Clipboard history also updates its preview row heights when DPI changes, keeping text,
|
||||
thumbnails and source labels aligned with the scaled fonts and spacing.
|
||||
|
||||
## Implemented
|
||||
|
||||
@@ -107,8 +211,9 @@ field into view; Shift+wheel scrolls horizontally.
|
||||
- Single instance and `--history`, `--convert`, `--case`, `--transliterate`, `--settings`,
|
||||
`--snippets`, `--exit` commands. `--convert` cycles layouts.
|
||||
|
||||
Default shortcuts match [Typlo](https://github.com/bitcodepro/Typlo): Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
|
||||
Z (undo), 1/2/3 (first layouts), and Ctrl+Shift+V (history).
|
||||
Default text shortcuts match [Typlo](https://github.com/bitcodepro/Typlo): Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
|
||||
Z (undo), and 1/2/3 (first layouts). Clipboard history defaults to **Win+V** for new settings;
|
||||
existing saved shortcuts are preserved. While KSwitcher runs, this binding opens its history instead of Windows clipboard history.
|
||||
|
||||
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
|
||||
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
|
||||
@@ -273,3 +378,15 @@ It still uses the normal instance lock; close a running KSwitcher first if measu
|
||||
|
||||
Application code is MIT licensed; preserves the original [Typlo](https://github.com/bitcodepro/Typlo) attribution in `LICENSE`.
|
||||
Dictionary data has its own CC BY-SA 4.0 license as described above.
|
||||
|
||||
The screenshot process lifecycle can be checked interactively with `tests/screenshot_process.ps1`
|
||||
against a staged build. It briefly opens real selection/editor windows without changing the clipboard.
|
||||
|
||||
### Release channel rule
|
||||
|
||||
Keep `v0.4.2` as the last legacy `vX.Y.Z` release. Publish full packages as
|
||||
`release-vX.Y.Z` (stable, not prerelease), including `release-files.json`,
|
||||
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt`. Never publish a manifest package under a
|
||||
legacy version tag: clients that have not installed the bridge would select it.
|
||||
Keep the compatibility bridge in the first 100 releases returned by Gitea while
|
||||
supporting old clients; their immutable update code only reads that page.
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.3.0 — 2026-10-28
|
||||
## v0.6.2 — 2026-10-29
|
||||
|
||||
- Move Settings and the snippet editor into the on-demand `KSwitcher.Config.exe` process.
|
||||
- Repeated launches activate the existing configurator. Closing its last window releases the process and its memory.
|
||||
- Apply settings immediately through validated IPC with the resident process; reject conflicting saves instead of overwriting newer settings.
|
||||
- Keep shortcut capture in the resident hook, with no extra global hooks in the configurator.
|
||||
- Support packaging, validation and rollback of both executables. About now displays the actual application version.
|
||||
- Add **Open editor after screen capture**, enabled by default. Disable it to use notification-first capture; an immediate notification error still opens the editor.
|
||||
- Save the preference and apply it to each capture, including requests sent to an already running screenshot process.
|
||||
- Register screenshot notification identity, shortcut and COM activation during resident startup, as well as before sending a notification.
|
||||
- Log notification registration and submission errors when logging is enabled.
|
||||
- Refresh clipboard history preview row heights when display DPI changes.
|
||||
|
||||
Verified: 170 native checks, 51 input/layout checks, 28 updater/metadata checks and 21 configurator process checks.
|
||||
A local before/after measurement after opening and closing Settings reduced resident working set from about 21.0 MiB to 14.7 MiB. Results vary; total memory is higher while the separate configurator is open.
|
||||
Verified: 175 native checks, 397 input/layout checks at 240 DPI, screenshot editor UI integration,
|
||||
isolated notification registration checks, and 45 updater checks. A physical transition between
|
||||
monitors with different DPI has not been tested. Successful notification submission does not
|
||||
guarantee a visible Windows banner; asynchronous toast failures are not yet monitored.
|
||||
|
||||
**Upgrade from v0.2.3 or earlier: install this release manually once.** The old updater rejects the additional EXE.
|
||||
Exit KSwitcher, extract the entire ZIP into the installation folder, and retain `data/` and any customized dictionaries.
|
||||
Keep `KSwitcher.exe`, `KSwitcher.Config.exe` and `dictionaries/` together. Launch `KSwitcher.exe`.
|
||||
Subsequent updates support both executables through the tray Update menu.
|
||||
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the
|
||||
v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||
For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder.
|
||||
Keep personal `data/` and customized dictionaries.
|
||||
|
||||
Download **KSwitcher-win-x64.zip**. The archive contains both applications, offline dictionaries,
|
||||
documentation and license notices. It does not contain personal settings or clipboard history.
|
||||
`SHA256SUMS.txt` contains the archive checksum.
|
||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains the resident,
|
||||
configurator and screenshot executables, a release manifest, offline dictionaries, documentation
|
||||
and license notices; no personal settings or clipboard history.
|
||||
|
||||
This folder holds the latest compiled version; older versions remain in Git history and Releases.
|
||||
To refresh the tracked package from source:
|
||||
Build and package:
|
||||
|
||||
```powershell
|
||||
.\build.ps1 -Stage -Test
|
||||
.\package.ps1 -PublishRelease
|
||||
```
|
||||
|
||||
Commit the ZIP and checksum together with their source changes. Publish both files as assets of
|
||||
one stable release tagged with the executable version, so the updater can verify it.
|
||||
Commit the archive, checksum and sources together. Publish both assets on a stable
|
||||
`release-vX.Y.Z` tag matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
||||
|
||||
@@ -1 +1 @@
|
||||
104c79b64bb30a2161e3ec2f5108a731da0d274e015d53d8835ea7e64f431ee1 KSwitcher-win-x64.zip
|
||||
e0c5a7b4f225ee32790f01ec916ae4706783b7e50b0bf6816c38ae2a6685d43d KSwitcher-win-x64.zip
|
||||
|
||||
@@ -13,8 +13,8 @@ New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null
|
||||
Push-Location $root
|
||||
try {
|
||||
$common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections')
|
||||
$libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore')
|
||||
foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','app','ui','main','configuration','config_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" }
|
||||
}
|
||||
@@ -24,28 +24,46 @@ try {
|
||||
if ($LASTEXITCODE) { throw 'Configurator UI compilation failed' }
|
||||
& windres --codepage=65001 resources/app.rc -O coff -o build/app-res.o
|
||||
if ($LASTEXITCODE) { throw 'Resource compilation failed' }
|
||||
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
|
||||
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
|
||||
New-Item -ItemType Directory -Force build/release | Out-Null
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
|
||||
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/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 build/update.o @libs -static -s -o build/KSwitcherTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Test build failed' }
|
||||
& .\build\KSwitcherTests.exe
|
||||
if ($LASTEXITCODE) { throw 'Tests failed' }
|
||||
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherInputTests.exe
|
||||
& $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/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' }
|
||||
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
|
||||
try {
|
||||
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
|
||||
if ($LASTEXITCODE) { throw 'Screenshot registration test build failed' }
|
||||
& $registrationTest
|
||||
if ($LASTEXITCODE) { throw 'Screenshot registration tests failed' }
|
||||
} finally { Remove-Item -LiteralPath $registrationTest -ErrorAction SilentlyContinue }
|
||||
|
||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
|
||||
if ($LASTEXITCODE) { throw 'Updater tests failed' }
|
||||
}
|
||||
if ($Integration) {
|
||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/app.o build/configuration.o build/ui.o build/app-res.o @libs -static -s -o build/KSwitcherIntegration.exe
|
||||
& $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/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' }
|
||||
|
||||
@@ -5,12 +5,22 @@ $destination = Join-Path $root 'dist\KSwitcher'
|
||||
New-Item -ItemType Directory -Force $destination | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Screenshot.exe') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
|
||||
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
|
||||
# Enumerate the distributable files explicitly; do not include a user's data directory.
|
||||
$files = @('KSwitcher.exe','KSwitcher.Config.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
|
||||
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','LICENSE','json-LICENSE.txt','README.md','dictionaries') | ForEach-Object { Join-Path $destination $_ }
|
||||
$entries = foreach ($path in $files) {
|
||||
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
|
||||
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
|
||||
}
|
||||
}
|
||||
$version=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $destination 'KSwitcher.exe')).FileVersion
|
||||
$manifest=Join-Path $destination 'release-files.json'
|
||||
@{schema=1; version=$version; files=@($entries)} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifest -Encoding utf8
|
||||
$files += $manifest
|
||||
$archive = Join-Path $root 'dist\KSwitcher-win-x64.zip'
|
||||
Compress-Archive -LiteralPath $files -DestinationPath $archive -Force
|
||||
Get-FileHash -LiteralPath $archive -Algorithm SHA256
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
#include <windows.h>
|
||||
#include "resource.h"
|
||||
IDI_KSWITCHER ICON "kswitcher.ico"
|
||||
#ifdef KS_SCREENSHOT_RESOURCES
|
||||
IDI_SCREENSHOT_QUESTION ICON "icons/question.ico"
|
||||
IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
||||
#endif
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
201 RCDATA "update.ps1"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,3,0,0
|
||||
PRODUCTVERSION 0,3,0,0
|
||||
FILEVERSION 0,6,2,0
|
||||
PRODUCTVERSION 0,6,2,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
@@ -16,7 +20,7 @@ BEGIN
|
||||
BEGIN
|
||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||
VALUE "ProductName", "KSwitcher"
|
||||
VALUE "FileVersion", "0.3.0"
|
||||
VALUE "FileVersion", "0.6.2"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
@@ -24,3 +28,54 @@ BEGIN
|
||||
VALUE "Translation", 0x409, 1200
|
||||
END
|
||||
END
|
||||
#ifdef KS_SCREENSHOT_RESOURCES
|
||||
300 ICON "icons/lucide/circle-dot-normal.ico"
|
||||
310 ICON "icons/lucide/circle-dot-active.ico"
|
||||
320 ICON "icons/lucide/circle-dot-disabled.ico"
|
||||
301 ICON "icons/lucide/arrow-up-right-normal.ico"
|
||||
311 ICON "icons/lucide/arrow-up-right-active.ico"
|
||||
321 ICON "icons/lucide/arrow-up-right-disabled.ico"
|
||||
302 ICON "icons/lucide/undo-2-normal.ico"
|
||||
312 ICON "icons/lucide/undo-2-active.ico"
|
||||
322 ICON "icons/lucide/undo-2-disabled.ico"
|
||||
303 ICON "icons/lucide/copy-normal.ico"
|
||||
313 ICON "icons/lucide/copy-active.ico"
|
||||
323 ICON "icons/lucide/copy-disabled.ico"
|
||||
304 ICON "icons/lucide/x-normal.ico"
|
||||
314 ICON "icons/lucide/x-active.ico"
|
||||
324 ICON "icons/lucide/x-disabled.ico"
|
||||
305 ICON "icons/lucide/redo-2-normal.ico"
|
||||
315 ICON "icons/lucide/redo-2-active.ico"
|
||||
325 ICON "icons/lucide/redo-2-disabled.ico"
|
||||
306 ICON "icons/lucide/highlighter-normal.ico"
|
||||
316 ICON "icons/lucide/highlighter-active.ico"
|
||||
326 ICON "icons/lucide/highlighter-disabled.ico"
|
||||
307 ICON "icons/lucide/palette-normal.ico"
|
||||
317 ICON "icons/lucide/palette-active.ico"
|
||||
327 ICON "icons/lucide/palette-disabled.ico"
|
||||
308 ICON "icons/lucide/pencil-normal.ico"
|
||||
318 ICON "icons/lucide/pencil-active.ico"
|
||||
328 ICON "icons/lucide/pencil-disabled.ico"
|
||||
|
||||
400 ICON "icons/lucide/slash-normal.ico"
|
||||
410 ICON "icons/lucide/slash-active.ico"
|
||||
420 ICON "icons/lucide/slash-disabled.ico"
|
||||
401 ICON "icons/lucide/square-normal.ico"
|
||||
411 ICON "icons/lucide/square-active.ico"
|
||||
421 ICON "icons/lucide/square-disabled.ico"
|
||||
402 ICON "icons/lucide/circle-normal.ico"
|
||||
412 ICON "icons/lucide/circle-active.ico"
|
||||
422 ICON "icons/lucide/circle-disabled.ico"
|
||||
403 ICON "icons/lucide/ellipse-normal.ico"
|
||||
413 ICON "icons/lucide/ellipse-active.ico"
|
||||
423 ICON "icons/lucide/ellipse-disabled.ico"
|
||||
|
||||
404 ICON "icons/lucide/type-normal.ico"
|
||||
414 ICON "icons/lucide/type-active.ico"
|
||||
424 ICON "icons/lucide/type-disabled.ico"
|
||||
|
||||
405 ICON "icons/lucide/a-large-small-normal.ico"
|
||||
415 ICON "icons/lucide/a-large-small-active.ico"
|
||||
425 ICON "icons/lucide/a-large-small-disabled.ico"
|
||||
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
ISC License
|
||||
|
||||
Copyright (c) 2026 Lucide Icons and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
The following Lucide icons are derived from the Feather project:
|
||||
|
||||
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
|
||||
|
||||
The MIT License (MIT) (for the icons listed above)
|
||||
|
||||
Copyright (c) 2013-present Cole Bemis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,5 @@
|
||||
Lucide https://github.com/lucide-icons/lucide
|
||||
Commit: be09619c1c1124ad09c19fc3e87ee3b939409bbd
|
||||
SVG originals retained. ICO variants generated with tools/generate-toolbar-icons.py.
|
||||
|
||||
Ellipse is adapted from the pinned Lucide circle SVG (r replaced with rx/ry).
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 16 2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16" />
|
||||
<path d="M15.697 14h5.606" />
|
||||
<path d="m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16" />
|
||||
<path d="M3.304 13h6.392" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 391 B |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M7 7h10v10" />
|
||||
<path d="M7 17 17 7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 260 B |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 279 B |
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 244 B |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 339 B |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<ellipse cx="12" cy="12" rx="10" ry="7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m9 11-6 6v3h9l3-3" />
|
||||
<path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 317 B |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 232 B |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 53 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z" />
|
||||
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
|
||||
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
|
||||
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
|
||||
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 571 B |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 256 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 14 5-5-5-5" />
|
||||
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 308 B |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M22 2 2 22" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 234 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 4v16" />
|
||||
<path d="M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2" />
|
||||
<path d="M9 20h6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 310 B |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 14 4 9l5-5" />
|
||||
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 260 B |
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<defs>
|
||||
<linearGradient id="blue" x1="8" y1="4" x2="36" y2="44" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#38bdf8"/>
|
||||
<stop offset=".55" stop-color="#1687e8"/>
|
||||
<stop offset="1" stop-color="#2563eb"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="24" cy="25" r="21" fill="#1e40af" opacity=".12"/>
|
||||
<circle cx="24" cy="23" r="21" fill="url(#blue)"/>
|
||||
<circle cx="24" cy="23" r="20.5" fill="none" stroke="white" stroke-opacity=".25"/>
|
||||
<path d="M18 17.5c0-3.1 2.5-5 6-5s6 2 6 5c0 4.8-6 4.8-6 9" fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<circle cx="24" cy="33" r="2" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 711 B |
@@ -1,2 +1,4 @@
|
||||
#pragma once
|
||||
#define IDI_KSWITCHER 101
|
||||
#define IDI_SCREENSHOT_QUESTION 102
|
||||
#define IDC_SCREENSHOT_PENCIL 500
|
||||
|
||||
|
After Width: | Height: | Size: 71 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="330" viewBox="0 0 960 330">
|
||||
<rect x="1" y="1" width="958" height="328" rx="24" fill="#F5F3FF" stroke="#DDD6FE" stroke-width="2"/>
|
||||
<g font-family="Segoe UI" fill="#241B46">
|
||||
<text x="40" y="58" font-size="20" font-weight="600" fill="#4F46E5">KSwitcher / Support development</text>
|
||||
<text x="40" y="115" font-size="35" font-weight="700">KSwitcher part of your day?</text>
|
||||
<text x="40" y="159" font-size="35" font-weight="700">Buy the developer a coffee.</text>
|
||||
<text x="40" y="202" font-size="20" fill="#655B7C">Your donation helps the tool keep growing.</text>
|
||||
<rect x="40" y="238" width="350" height="54" rx="12" fill="#4F46E5"/>
|
||||
<text x="215" y="272" text-anchor="middle" font-size="20" font-weight="600" fill="white">Support via monobank</text>
|
||||
</g>
|
||||
<g transform="translate(759 48) rotate(10 66 66)">
|
||||
<rect y="8" width="132" height="132" rx="26" fill="#C4B5FD"/>
|
||||
<rect width="132" height="132" rx="26" fill="#4F46E5"/>
|
||||
<g transform="translate(14 14) scale(3.25)"><path d="M8 7H12V25H8ZM11 15L22 6L25 9L14 18Z" fill="white"/><path d="M11 14L25 24L22 27L10 18Z" fill="#5EEAD4"/></g>
|
||||
</g>
|
||||
<g transform="translate(699 172) rotate(-8 67 60)">
|
||||
<rect y="7" width="142" height="115" rx="22" fill="#C4B5FD"/>
|
||||
<rect width="142" height="115" rx="22" fill="white" stroke="#DDD6FE" stroke-width="2"/>
|
||||
<g stroke="#4F46E5" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M43 41H92V66Q92 83 75 83H60Q43 83 43 66Z"/>
|
||||
<path d="M92 46H99Q115 46 111 58Q108 67 92 66M37 92H100M56 28V18M69 28V18M82 28V18"/>
|
||||
</g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 70 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="330" viewBox="0 0 960 330">
|
||||
<rect x="1" y="1" width="958" height="328" rx="24" fill="#F5F3FF" stroke="#DDD6FE" stroke-width="2"/>
|
||||
<g font-family="Segoe UI" fill="#241B46">
|
||||
<text x="40" y="58" font-size="20" font-weight="600" fill="#4F46E5">KSwitcher / Підтримати розробку</text>
|
||||
<text x="40" y="115" font-size="35" font-weight="700">KSwitcher щодня під рукою?</text>
|
||||
<text x="40" y="159" font-size="35" font-weight="700">Пригостіть розробника кавою.</text>
|
||||
<text x="40" y="202" font-size="20" fill="#655B7C">Ваш донат допомагає розвивати інструмент.</text>
|
||||
<rect x="40" y="238" width="350" height="54" rx="12" fill="#4F46E5"/>
|
||||
<text x="215" y="272" text-anchor="middle" font-size="20" font-weight="600" fill="white">Підтримати через monobank</text>
|
||||
</g>
|
||||
<g transform="translate(759 48) rotate(10 66 66)">
|
||||
<rect y="8" width="132" height="132" rx="26" fill="#C4B5FD"/>
|
||||
<rect width="132" height="132" rx="26" fill="#4F46E5"/>
|
||||
<g transform="translate(14 14) scale(3.25)"><path d="M8 7H12V25H8ZM11 15L22 6L25 9L14 18Z" fill="white"/><path d="M11 14L25 24L22 27L10 18Z" fill="#5EEAD4"/></g>
|
||||
</g>
|
||||
<g transform="translate(699 172) rotate(-8 67 60)">
|
||||
<rect y="7" width="142" height="115" rx="22" fill="#C4B5FD"/>
|
||||
<rect width="142" height="115" rx="22" fill="white" stroke="#DDD6FE" stroke-width="2"/>
|
||||
<g stroke="#4F46E5" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M43 41H92V66Q92 83 75 83H60Q43 83 43 66Z"/>
|
||||
<path d="M92 46H99Q115 46 111 58Q108 67 92 66M37 92H100M56 28V18M69 28V18M82 28V18"/>
|
||||
</g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -9,8 +9,8 @@ $ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Get-StableVersion([string]$Tag) {
|
||||
if ($Tag -notmatch '^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
||||
try { return [version]($Tag -replace '^v','') } catch { return $null }
|
||||
if ($Tag -notmatch '^(?:v|release-v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { return $null }
|
||||
try { return [version]($Tag -replace '^(?:release-)?v','') } catch { return $null }
|
||||
}
|
||||
function Assert-ReleaseUrl([string]$Url) {
|
||||
$uri = [uri]$Url
|
||||
@@ -63,9 +63,15 @@ function Assert-Checksum([string]$Archive,[string]$Checksum) {
|
||||
$matchesFound = @([IO.File]::ReadAllLines($Checksum) | Where-Object { $_ -cmatch '^([a-fA-F0-9]{64})\s+\*?KSwitcher-win-x64\.zip$' })
|
||||
if ($matchesFound.Count -ne 1) { throw 'Missing or ambiguous archive checksum.' }
|
||||
$expected = ($matchesFound[0] -split '\s+')[0]
|
||||
if ((Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash -ne $expected) { throw 'Release checksum verification failed.' }
|
||||
# Do not depend on PowerShell module autoloading: hosts can inherit a
|
||||
# PowerShell 7 PSModulePath while this helper runs in Windows PowerShell 5.1.
|
||||
$stream = [IO.File]::OpenRead($Archive)
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try { $actual = [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','') }
|
||||
finally { $sha.Dispose(); $stream.Dispose() }
|
||||
if ($actual -ne $expected) { throw 'Release checksum verification failed.' }
|
||||
}
|
||||
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
|
||||
function Expand-LegacyRelease([string]$Archive,[string]$Destination,[version]$Expected) {
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
|
||||
try {
|
||||
@@ -74,7 +80,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
|
||||
foreach ($entry in $zip.Entries) {
|
||||
$name = $entry.FullName.Replace('\','/')
|
||||
if ($name.EndsWith('/') -and $name -eq 'dictionaries/') { continue }
|
||||
if ($name -cnotmatch '^(KSwitcher(?:\.Config)?\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or
|
||||
if ($name -cnotmatch '^(KSwitcher(?:\.(?:Config|Screenshot))?\.exe|LICENSE|json-LICENSE\.txt|README\.md|dictionaries/[a-zA-Z0-9_.-]+)$' -or
|
||||
$name.EndsWith('/.') -or $name.EndsWith('/..') -or $name.EndsWith('.') -or
|
||||
(($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or !$seen.Add($name)) {
|
||||
throw "Unexpected or unsafe archive entry: $name"
|
||||
@@ -92,7 +98,7 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
|
||||
throw 'Incomplete release: KSwitcher.Config.exe'
|
||||
}
|
||||
} finally { $zip.Dispose() }
|
||||
foreach ($binary in @('KSwitcher.exe','KSwitcher.Config.exe')) {
|
||||
foreach ($binary in @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe')) {
|
||||
$exe = Join-Path $Destination $binary
|
||||
if (!(Test-Path -LiteralPath $exe)) { continue }
|
||||
$version = [Diagnostics.FileVersionInfo]::GetVersionInfo($exe)
|
||||
@@ -106,6 +112,75 @@ function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected
|
||||
[BitConverter]::ToUInt16($bytes,$pe+4) -ne 0x8664) { throw 'Release is not a Windows x64 executable.' }
|
||||
}
|
||||
}
|
||||
function Get-Sha256([IO.Stream]$Stream) {
|
||||
$sha=[Security.Cryptography.SHA256]::Create()
|
||||
try { return [BitConverter]::ToString($sha.ComputeHash($Stream)).Replace('-','').ToLowerInvariant() }
|
||||
finally { $sha.Dispose() }
|
||||
}
|
||||
function Assert-PackagePath([string]$Name) {
|
||||
if (!$Name -or $Name.Length -gt 220 -or $Name -match '[\\:<>"|?*]' -or $Name.StartsWith('/')) { throw "Unsafe package path: $Name" }
|
||||
$parts=$Name.Split('/')
|
||||
foreach ($part in $parts) {
|
||||
if (!$part -or $part -in @('.','..') -or $part -match '[. ]$|[\x00-\x1f]' -or
|
||||
$part -match '^(?i:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\.|$)') { throw "Unsafe package path: $Name" }
|
||||
}
|
||||
if ($parts[0] -ieq 'data' -or $parts[0] -like '.kswitcher-*') { throw "Protected user path: $Name" }
|
||||
}
|
||||
function Expand-Release([string]$Archive,[string]$Destination,[version]$Expected) {
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$zip=[IO.Compression.ZipFile]::OpenRead($Archive)
|
||||
try {
|
||||
$manifests=@($zip.Entries | Where-Object FullName -CEQ 'release-files.json')
|
||||
if (!$manifests.Count) {
|
||||
if ($Expected -gt [version]'0.4.2') { throw 'Release manifest is missing.' }
|
||||
$zip.Dispose(); $zip=$null
|
||||
Expand-LegacyRelease $Archive $Destination $Expected
|
||||
return
|
||||
}
|
||||
if ($manifests.Count -ne 1 -or $manifests[0].Length -gt 256KB) { throw 'Invalid release manifest.' }
|
||||
$reader=[IO.StreamReader]::new($manifests[0].Open())
|
||||
try { $manifest=$reader.ReadToEnd() | ConvertFrom-Json } finally { $reader.Dispose() }
|
||||
if ($manifest.schema -ne 1 -or (Get-StableVersion $manifest.version) -ne $Expected -or
|
||||
@($manifest.files).Count -lt 1 -or @($manifest.files).Count -gt 1000) { throw 'Unsupported or mismatched release manifest.' }
|
||||
$declared=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($file in $manifest.files) {
|
||||
Assert-PackagePath $file.path
|
||||
if ($file.path -ieq 'release-files.json' -or $declared.ContainsKey($file.path) -or
|
||||
$file.sha256 -notmatch '^[a-fA-F0-9]{64}$' -or $file.size -lt 0 -or $file.size -gt 100MB) { throw 'Invalid manifest file entry.' }
|
||||
$declared.Add($file.path,$file)
|
||||
}
|
||||
if (!$declared.ContainsKey('KSwitcher.exe')) { throw 'Main executable is missing from the manifest.' }
|
||||
$seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
$total=0L
|
||||
foreach ($entry in $zip.Entries) {
|
||||
$name=$entry.FullName.Replace('\','/')
|
||||
if ($name.EndsWith('/')) { Assert-PackagePath $name.TrimEnd('/'); continue }
|
||||
Assert-PackagePath $name
|
||||
if (!$seen.Add($name) -or (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000) { throw 'Duplicate or linked archive entry.' }
|
||||
if ($name -ceq 'release-files.json') { continue }
|
||||
if (!$declared.ContainsKey($name)) { throw "Undeclared package file: $name" }
|
||||
$file=$declared[$name]
|
||||
if ($entry.Length -ne $file.size) { throw "Package file size mismatch: $name" }
|
||||
$total+=$entry.Length
|
||||
if ($total -gt 100MB -or $zip.Entries.Count -gt 1100) { throw 'Release expands beyond allowed limits.' }
|
||||
$stream=$entry.Open()
|
||||
try { $hash=Get-Sha256 $stream } finally { $stream.Dispose() }
|
||||
if ($hash -ne $file.sha256) { throw "Package file checksum mismatch: $name" }
|
||||
}
|
||||
if ($seen.Count -ne $declared.Count+1) { throw 'Archive does not contain every manifest file.' }
|
||||
[IO.Directory]::CreateDirectory($Destination) | Out-Null
|
||||
Assert-PlainDirectory $Destination
|
||||
foreach ($entry in $zip.Entries) {
|
||||
if ($entry.FullName.EndsWith('/')) { continue }
|
||||
$target=Join-Path $Destination $entry.FullName
|
||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($target)) | Out-Null
|
||||
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($target))
|
||||
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry,$target,$false)
|
||||
}
|
||||
$info=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $Destination 'KSwitcher.exe'))
|
||||
if ($info.ProductName -ne 'KSwitcher' -or (Get-StableVersion $info.FileVersion) -ne $Expected) { throw 'Main executable identity/version mismatch.' }
|
||||
} finally { if ($zip) { $zip.Dispose() } }
|
||||
}
|
||||
function Assert-PlainDirectory([string]$Directory) {
|
||||
$item = Get-Item -LiteralPath $Directory -Force
|
||||
while ($item) {
|
||||
@@ -118,7 +193,7 @@ function Install-Files([string]$Stage,[string]$Target,[string]$Backup,[Collectio
|
||||
foreach ($file in @(Get-ChildItem -LiteralPath $Stage -File -Recurse)) {
|
||||
$relative = $file.FullName.Substring($Stage.Length+1)
|
||||
$destination = Join-Path $Target $relative
|
||||
if ($relative.StartsWith('dictionaries\') -and (Test-Path -LiteralPath $destination)) { continue }
|
||||
if ($relative.StartsWith('dictionaries\',[StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $destination)) { continue }
|
||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
|
||||
Assert-PlainDirectory ([IO.Path]::GetDirectoryName($destination))
|
||||
if (Test-Path -LiteralPath $destination) {
|
||||
@@ -171,6 +246,8 @@ try {
|
||||
Receive-File $release.checksum $checksum 64KB
|
||||
Assert-Checksum $archive $checksum
|
||||
Expand-Release $archive $stage (Get-StableVersion $release.version)
|
||||
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
|
||||
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
|
||||
Write-Result @{state='prepared';version=$release.version}
|
||||
$ready.Set() | Out-Null
|
||||
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "../resources/resource.h"
|
||||
#include "ocr.h"
|
||||
#include "configuration.h"
|
||||
#include "screenshot_notifications.h"
|
||||
#include <algorithm>
|
||||
#include <commctrl.h>
|
||||
#include <set>
|
||||
@@ -17,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;
|
||||
@@ -586,6 +588,11 @@ void App::dispatch(int command)
|
||||
return;
|
||||
try
|
||||
{
|
||||
if (command == Screenshot)
|
||||
{
|
||||
if (!busy) { resetInput(); launchScreenshot(*this); }
|
||||
return;
|
||||
}
|
||||
if (command == ShowHistory)
|
||||
{
|
||||
showHistory(*this);
|
||||
@@ -1096,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…");
|
||||
@@ -1127,6 +1135,9 @@ void App::trayMenu()
|
||||
{
|
||||
switch (cmd)
|
||||
{
|
||||
case 24:
|
||||
dispatch(Screenshot);
|
||||
break;
|
||||
case 10:
|
||||
showHistory(*this);
|
||||
break;
|
||||
@@ -1442,6 +1453,9 @@ int App::execute()
|
||||
confirmUpdatedStartup(updateStartupEvent);
|
||||
if (startCommand)
|
||||
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
||||
try { registerScreenshotNotifications(); }
|
||||
catch(const std::exception &e) { log(L"Screenshot notification registration: "+wide(e.what())); }
|
||||
auto screenshotActivation=startScreenshotActivationListener();
|
||||
MSG msg;
|
||||
while (GetMessageW(&msg, nullptr, 0, 0) > 0)
|
||||
{
|
||||
@@ -1460,6 +1474,7 @@ int App::execute()
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
if(screenshotActivation) CoRevokeClassObject(screenshotActivation);
|
||||
return (int)msg.wParam;
|
||||
}
|
||||
} // namespace ks
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace ks
|
||||
inline constexpr UINT TrayMessage = WM_APP + 1, ErrorMessage = WM_APP + 2, HistoryMessage = WM_APP + 3,
|
||||
SnippetMessage = WM_APP + 4;
|
||||
inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, AutoCorrect = 6,
|
||||
Direct = 100;
|
||||
Screenshot = 7, Direct = 100;
|
||||
inline constexpr UINT AutoCorrectMessage = WM_APP + 6;
|
||||
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
|
||||
struct LastTransform
|
||||
@@ -99,7 +99,8 @@ class App
|
||||
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
|
||||
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
|
||||
};
|
||||
void showHistory(App &);
|
||||
void showHistory(App &, bool visible = true);
|
||||
void launchScreenshot(App &);
|
||||
void refreshHistory(App &);
|
||||
void showSettings(App &, bool visible = true);
|
||||
void showLogs(App &);
|
||||
|
||||
@@ -48,6 +48,7 @@ struct Settings
|
||||
bool elevatedAutostart = false;
|
||||
bool autoCorrect = false;
|
||||
bool writeLog = true;
|
||||
bool screenshotAutoOpenEditor = true;
|
||||
int autoMaxRank = 0;
|
||||
int autoMinLength = 3;
|
||||
std::wstring autoPrefixChars = L"([{«“‘\"";
|
||||
@@ -59,7 +60,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;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
// Only the on-demand screenshot process owns this state; no resident App dependency.
|
||||
struct ScreenshotHost {
|
||||
HINSTANCE instance{};
|
||||
HWND window{};
|
||||
unsigned windows{};
|
||||
bool (*notify)(const Clip &){};
|
||||
bool autoOpenEditor = true;
|
||||
};
|
||||
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,145 @@
|
||||
#include "screenshot_notifications.h"
|
||||
#include <atomic>
|
||||
#include <shlobj.h>
|
||||
#include <propkey.h>
|
||||
#include <propvarutil.h>
|
||||
#include <wrl/client.h>
|
||||
namespace ks {
|
||||
namespace {
|
||||
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
|
||||
constexpr GUID ActivatorId{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}};
|
||||
}
|
||||
namespace {
|
||||
using Microsoft::WRL::ComPtr;
|
||||
void check(HRESULT result) {
|
||||
if(FAILED(result)) throw std::runtime_error("Screenshot registration failed: " + std::to_string((unsigned long)result));
|
||||
}
|
||||
void registryString(const std::wstring &path,const wchar_t *name,const std::wstring &value)
|
||||
{
|
||||
HKEY key{};
|
||||
if(RegCreateKeyExW(HKEY_CURRENT_USER,path.c_str(),0,nullptr,0,KEY_SET_VALUE,nullptr,&key,nullptr)!=ERROR_SUCCESS)
|
||||
throw std::runtime_error("Cannot register screenshot notifications");
|
||||
auto result=RegSetValueExW(key,name,0,REG_SZ,(const BYTE *)value.c_str(),DWORD((value.size()+1)*sizeof(wchar_t)));
|
||||
RegCloseKey(key);
|
||||
if(result!=ERROR_SUCCESS) throw std::runtime_error("Cannot register screenshot notification activation");
|
||||
}
|
||||
}
|
||||
void registerScreenshotNotifications()
|
||||
{
|
||||
auto exe=(std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe").wstring();
|
||||
if(!std::filesystem::exists(exe)) throw std::runtime_error("Screenshot helper is missing");
|
||||
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",nullptr,L"\""+exe+L"\" --toast-activated");
|
||||
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",L"ServerExecutable",exe);
|
||||
auto identity=std::wstring(L"Software\\Classes\\AppUserModelId\\")+AppId;
|
||||
registryString(identity,L"DisplayName",L"KSwitcher Screenshot");
|
||||
registryString(identity,L"IconUri",exe);
|
||||
registryString(identity,L"CustomActivator",L"{e3f78047-f0ad-49b6-9074-411931706167}");
|
||||
// A shortcut supplies desktop notification identity and its COM activation callback.
|
||||
ComPtr<IShellLinkW> link; check(CoCreateInstance(CLSID_ShellLink,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&link)));
|
||||
check(link->SetPath(exe.c_str())); check(link->SetIconLocation(exe.c_str(),0));
|
||||
ComPtr<IPropertyStore> properties; check(link.As(&properties));
|
||||
PROPVARIANT id{}; check(InitPropVariantFromString(AppId,&id));
|
||||
auto result=properties->SetValue(PKEY_AppUserModel_ID,id); PropVariantClear(&id); check(result);
|
||||
GUID activator=ActivatorId;
|
||||
PROPVARIANT clsid{}; clsid.vt=VT_CLSID; clsid.puuid=&activator;
|
||||
check(properties->SetValue(PKEY_AppUserModel_ToastActivatorCLSID,clsid));
|
||||
check(properties->Commit());
|
||||
PWSTR programs{}; check(SHGetKnownFolderPath(FOLDERID_Programs,0,nullptr,&programs));
|
||||
auto shortcut=std::filesystem::path(programs)/L"KSwitcher Screenshot.lnk"; CoTaskMemFree(programs);
|
||||
ComPtr<IPersistFile> file; check(link.As(&file)); check(file->Save(shortcut.c_str(),TRUE));
|
||||
// Notify the shell that notification identity/activation registration changed.
|
||||
SHChangeNotify(SHCNE_ASSOCCHANGED,SHCNF_IDLIST,nullptr,nullptr);
|
||||
}
|
||||
bool validScreenshotToken(const std::wstring &id)
|
||||
{
|
||||
if(id.size()!=36) return false;
|
||||
for(size_t i=0;i<id.size();++i) {
|
||||
if(i==8 || i==13 || i==18 || i==23) { if(id[i]!=L'-') return false; }
|
||||
else if(!((id[i]>=L'0' && id[i]<=L'9') || (id[i]>=L'a' && id[i]<=L'f') || (id[i]>=L'A' && id[i]<=L'F'))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Windows SDK INotificationActivationCallback ABI (notificationactivationcallback.h).
|
||||
constexpr GUID CallbackId{0x53e31837,0x6600,0x4a81,{0x93,0x95,0x75,0xcf,0xfe,0x74,0x6f,0x94}};
|
||||
struct NotificationInput { LPCWSTR key,value; };
|
||||
struct NotificationCallback : IUnknown {
|
||||
virtual HRESULT STDMETHODCALLTYPE Activate(LPCWSTR,LPCWSTR,const NotificationInput *,ULONG)=0;
|
||||
};
|
||||
class Activator final : public NotificationCallback {
|
||||
std::atomic<ULONG> references{1};
|
||||
DWORD thread=GetCurrentThreadId();
|
||||
std::wstring target;
|
||||
bool quit;
|
||||
public:
|
||||
Activator(std::wstring path,bool shouldQuit):target(std::move(path)),quit(shouldQuit) {}
|
||||
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID id,void **out) override {
|
||||
if(!out) return E_POINTER;
|
||||
*out=nullptr;
|
||||
if(id!=IID_IUnknown && id!=CallbackId) return E_NOINTERFACE;
|
||||
*out=static_cast<NotificationCallback *>(this); AddRef(); return S_OK;
|
||||
}
|
||||
ULONG STDMETHODCALLTYPE AddRef() override { return ++references; }
|
||||
ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; }
|
||||
HRESULT STDMETHODCALLTYPE Activate(LPCWSTR app,LPCWSTR args,const NotificationInput *,ULONG) override {
|
||||
if(!app || std::wstring(app)!=AppId || !args || !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,23 @@
|
||||
#include "app.h"
|
||||
|
||||
namespace ks {
|
||||
void launchScreenshot(App &app)
|
||||
{
|
||||
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
||||
if (!std::filesystem::exists(path))
|
||||
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
|
||||
auto command=L"\""+path.wstring()+L"\" --auto-open-editor="+
|
||||
(app.settings.screenshotAutoOpenEditor?L"1":L"0");
|
||||
if(app.settings.writeLog) command+=L" --write-log";
|
||||
STARTUPINFOW startup{sizeof(startup)};
|
||||
PROCESS_INFORMATION process{};
|
||||
// Grant foreground rights before the child's first selection window opens.
|
||||
if (!CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,
|
||||
nullptr,path.parent_path().c_str(),&startup,&process))
|
||||
throw std::runtime_error(utf8(lastError(L"Cannot start screenshot editor")));
|
||||
AllowSetForegroundWindow(process.dwProcessId);
|
||||
ResumeThread(process.hThread);
|
||||
CloseHandle(process.hThread);
|
||||
CloseHandle(process.hProcess);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#include "screenshot.h"
|
||||
#include "screenshot_notifications.h"
|
||||
#include <commctrl.h>
|
||||
#include <shellapi.h>
|
||||
#include <roapi.h>
|
||||
|
||||
namespace {
|
||||
constexpr wchar_t ControllerClass[]=L"KSwitcher.Screenshot.Controller";
|
||||
LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM lp)
|
||||
{
|
||||
auto host=(ks::ScreenshotHost *)GetWindowLongPtrW(window,GWLP_USERDATA);
|
||||
if(message==WM_NCCREATE) {
|
||||
host=(ks::ScreenshotHost *)((CREATESTRUCTW *)lp)->lpCreateParams;
|
||||
SetWindowLongPtrW(window,GWLP_USERDATA,(LONG_PTR)host);
|
||||
}
|
||||
if(host && message==ks::ScreenshotRequest) {
|
||||
host->autoOpenEditor=(wp&1)!=0;
|
||||
ks::setLoggingEnabled((wp&2)!=0);
|
||||
try { ks::showScreenshot(*host); }
|
||||
catch(const std::exception &e) {
|
||||
MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screen capture",MB_OK|MB_ICONERROR);
|
||||
PostMessageW(window,ks::ScreenshotIdle,0,0);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
if(host && message==WM_COPYDATA) {
|
||||
auto data=(COPYDATASTRUCT *)lp;
|
||||
if(!data || data->dwData!=73 || data->cbData!=37*sizeof(wchar_t) || !data->lpData) return FALSE;
|
||||
std::wstring token((const wchar_t *)data->lpData,36);
|
||||
if(!ks::validScreenshotToken(token)) return FALSE;
|
||||
try { ks::showScreenshotEditor(*host,ks::loadNotifiedScreenshot(token)); }
|
||||
catch(const std::exception &e) {
|
||||
MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screenshot",MB_OK|MB_ICONINFORMATION);
|
||||
PostMessageW(window,ks::ScreenshotIdle,0,0);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
if(host && message==ks::ScreenshotIdle && !host->windows) {
|
||||
DestroyWindow(window);
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProcW(window,message,wp,lp);
|
||||
}
|
||||
}
|
||||
|
||||
int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
|
||||
{
|
||||
std::wstring token;
|
||||
bool autoOpenEditor=true, writeLog=false;
|
||||
int argc{}; auto argv=CommandLineToArgvW(GetCommandLineW(),&argc);
|
||||
if(argc>1 && std::wstring(argv[argc-1])==L"--write-log") {
|
||||
writeLog=true; --argc;
|
||||
ks::setLoggingEnabled(true);
|
||||
}
|
||||
if(argc>=2 && std::wstring(argv[1])==L"--toast-activated") {
|
||||
LocalFree(argv); return ks::runScreenshotToastActivator();
|
||||
}
|
||||
if(argc==2 && std::wstring(argv[1])==L"--register-notifications") {
|
||||
LocalFree(argv); CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||
int result=0; try { ks::registerScreenshotNotifications(); } catch(...) { result=1; }
|
||||
CoUninitialize(); return result;
|
||||
}
|
||||
if(argc==2 && (std::wstring(argv[1])==L"--auto-open-editor=0" ||
|
||||
std::wstring(argv[1])==L"--auto-open-editor=1")) {
|
||||
autoOpenEditor=std::wstring(argv[1])==L"--auto-open-editor=1";
|
||||
} else if(argc>1) {
|
||||
if(argc==3 && std::wstring(argv[1])==L"--open") {
|
||||
std::wstring uri=argv[2]; const std::wstring prefix=L"kswitcher-screenshot:";
|
||||
if(uri.starts_with(prefix)) token=uri.substr(prefix.size());
|
||||
}
|
||||
if(!ks::validScreenshotToken(token)) { LocalFree(argv); return 1; }
|
||||
}
|
||||
LocalFree(argv);
|
||||
COPYDATASTRUCT open{73,DWORD((token.size()+1)*sizeof(wchar_t)),token.data()};
|
||||
HANDLE mutex=CreateMutexW(nullptr,FALSE,L"Local\\KSwitcher.Screenshot.Singleton");
|
||||
if(!mutex) return 1;
|
||||
// Wait through startup/shutdown races; the controller accepts requests only while alive.
|
||||
bool acquired=false;
|
||||
for(int attempt=0;attempt<100;++attempt) {
|
||||
auto wait=WaitForSingleObject(mutex,0);
|
||||
if(wait==WAIT_OBJECT_0 || wait==WAIT_ABANDONED) { acquired=true; break; }
|
||||
if(auto existing=FindWindowW(ControllerClass,nullptr)) {
|
||||
DWORD pid{}; GetWindowThreadProcessId(existing,&pid);
|
||||
AllowSetForegroundWindow(pid);
|
||||
DWORD_PTR accepted{};
|
||||
if(SendMessageTimeoutW(existing,token.empty()?ks::ScreenshotRequest:WM_COPYDATA,token.empty()?(autoOpenEditor?1:0)|(writeLog?2:0):0,
|
||||
token.empty()?0:(LPARAM)&open,SMTO_ABORTIFHUNG,2000,&accepted) && accepted) {
|
||||
CloseHandle(mutex); return 0;
|
||||
}
|
||||
}
|
||||
Sleep(50);
|
||||
}
|
||||
if(!acquired) { CloseHandle(mutex); return 1; }
|
||||
RoInitialize(RO_INIT_SINGLETHREADED);
|
||||
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_BAR_CLASSES};
|
||||
InitCommonControlsEx(&controls);
|
||||
ks::ScreenshotHost host{instance};
|
||||
host.notify=ks::notifyScreenshot;
|
||||
WNDCLASSW cls{}; cls.hInstance=instance; cls.lpfnWndProc=controller; cls.lpszClassName=ControllerClass;
|
||||
RegisterClassW(&cls);
|
||||
host.window=CreateWindowW(ControllerClass,L"KSwitcher Screenshot",0,0,0,0,0,nullptr,nullptr,instance,&host);
|
||||
int result=1;
|
||||
if(host.window) {
|
||||
// Toast protocol activation may run unelevated while the editor was launched by an elevated resident.
|
||||
// WM_COPYDATA accepts only a validated cache GUID, never a path or executable command.
|
||||
ChangeWindowMessageFilterEx(host.window,WM_COPYDATA,MSGFLT_ALLOW,nullptr);
|
||||
if(token.empty()) PostMessageW(host.window,ks::ScreenshotRequest,(autoOpenEditor?1:0)|(writeLog?2:0),0);
|
||||
else SendMessageW(host.window,WM_COPYDATA,0,(LPARAM)&open);
|
||||
MSG message{};
|
||||
while((result=GetMessageW(&message,nullptr,0,0))>0) {
|
||||
TranslateMessage(&message); DispatchMessageW(&message);
|
||||
}
|
||||
result=result<0?1:0;
|
||||
}
|
||||
RoUninitialize();
|
||||
ReleaseMutex(mutex); CloseHandle(mutex);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
#include "screenshot_notifications.h"
|
||||
#include "ocr.h"
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <aclapi.h>
|
||||
#include <sddl.h>
|
||||
#include <shlobj.h>
|
||||
#include <propkey.h>
|
||||
#include <propvarutil.h>
|
||||
#include <roapi.h>
|
||||
#include <winstring.h>
|
||||
#include <wincodec.h>
|
||||
#ifdef __MINGW32__
|
||||
// This SDK aliases BYTE and boolean, producing duplicate template specializations.
|
||||
// Notifications use the boolean specialization; IReference<BYTE> is unused here.
|
||||
#define ____FIReference_1_BYTE_INTERFACE_DEFINED__
|
||||
#endif
|
||||
#include <windows.ui.notifications.h>
|
||||
#include <windows.data.xml.dom.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
namespace ks {
|
||||
namespace {
|
||||
using Microsoft::WRL::ComPtr;
|
||||
namespace Notifications=ABI::Windows::UI::Notifications;
|
||||
namespace Xml=ABI::Windows::Data::Xml::Dom;
|
||||
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
|
||||
std::string notificationError;
|
||||
void check(HRESULT result) { if(FAILED(result)) { char message[100]; snprintf(message,sizeof(message),"Windows screenshot notification failed (0x%08lx)",(unsigned long)result); throw std::runtime_error(message); } }
|
||||
struct String {
|
||||
HSTRING value{};
|
||||
explicit String(const std::wstring &s) { check(WindowsCreateString(s.c_str(),(UINT32)s.size(),&value)); }
|
||||
~String() { WindowsDeleteString(value); }
|
||||
};
|
||||
std::filesystem::path cacheDirectory()
|
||||
{
|
||||
PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_LocalAppData,0,nullptr,&folder));
|
||||
auto path=std::filesystem::path(folder)/L"KSwitcher"/L"ScreenshotNotifications";
|
||||
CoTaskMemFree(folder); std::filesystem::create_directories(path); return path;
|
||||
}
|
||||
std::filesystem::path previewDirectory()
|
||||
{
|
||||
HANDLE token{};
|
||||
if(!OpenProcessToken(GetCurrentProcess(),TOKEN_QUERY,&token)) check(HRESULT_FROM_WIN32(GetLastError()));
|
||||
DWORD size{}; GetTokenInformation(token,TokenUser,nullptr,0,&size);
|
||||
std::vector<BYTE> data(size);
|
||||
BOOL read=GetTokenInformation(token,TokenUser,data.data(),size,&size);
|
||||
DWORD error=read?ERROR_SUCCESS:GetLastError(); CloseHandle(token); check(HRESULT_FROM_WIN32(error));
|
||||
auto user=reinterpret_cast<TOKEN_USER *>(data.data())->User.Sid;
|
||||
LPWSTR sidText{}; if(!ConvertSidToStringSidW(user,&sidText)) check(HRESULT_FROM_WIN32(GetLastError()));
|
||||
std::wstring sid(sidText); LocalFree(sidText);
|
||||
PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_ProgramData,0,nullptr,&folder));
|
||||
auto path=std::filesystem::path(folder)/(L"KSwitcher.ScreenshotPreviews-"+sid); CoTaskMemFree(folder);
|
||||
// The notification broker cannot load previews from some profile directories.
|
||||
// Keep this cache outside the profile, private to the current user and SYSTEM.
|
||||
auto sddl=L"O:"+sid+L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;"+sid+L")";
|
||||
PSECURITY_DESCRIPTOR security{};
|
||||
if(!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(),SDDL_REVISION_1,&security,nullptr))
|
||||
check(HRESULT_FROM_WIN32(GetLastError()));
|
||||
SECURITY_ATTRIBUTES attributes{sizeof(attributes),security,FALSE};
|
||||
BOOL created=CreateDirectoryW(path.c_str(),&attributes);
|
||||
error=created?ERROR_SUCCESS:GetLastError();
|
||||
LocalFree(security);
|
||||
if(error!=ERROR_ALREADY_EXISTS) check(HRESULT_FROM_WIN32(error));
|
||||
// Do not use a pre-created directory or junction controlled by another user.
|
||||
DWORD flags=GetFileAttributesW(path.c_str());
|
||||
if(flags==INVALID_FILE_ATTRIBUTES || !(flags&FILE_ATTRIBUTE_DIRECTORY) || (flags&FILE_ATTRIBUTE_REPARSE_POINT))
|
||||
throw std::runtime_error("Invalid screenshot preview directory");
|
||||
PSID owner{}; PSECURITY_DESCRIPTOR existing{};
|
||||
error=GetNamedSecurityInfoW(const_cast<wchar_t *>(path.c_str()),SE_FILE_OBJECT,OWNER_SECURITY_INFORMATION,
|
||||
&owner,nullptr,nullptr,nullptr,&existing);
|
||||
check(HRESULT_FROM_WIN32(error));
|
||||
bool owned=EqualSid(owner,user); LocalFree(existing);
|
||||
if(!owned) throw std::runtime_error("Screenshot preview directory belongs to another user");
|
||||
return path;
|
||||
}
|
||||
void cleanCache(const std::filesystem::path &path)
|
||||
{
|
||||
std::vector<std::filesystem::directory_entry> files;
|
||||
for(const auto &file:std::filesystem::directory_iterator(path))
|
||||
if(file.is_regular_file() && validScreenshotToken(file.path().stem().wstring()) &&
|
||||
(file.path().extension()==L".capture" || file.path().extension()==L".png")) files.push_back(file);
|
||||
std::sort(files.begin(),files.end(),[](const auto &a,const auto &b){return a.last_write_time()>b.last_write_time();});
|
||||
uintmax_t size=0;
|
||||
for(size_t i=0;i<files.size();++i) {
|
||||
size+=files[i].file_size();
|
||||
if(i>=40 || size>256*1024*1024 || std::filesystem::file_time_type::clock::now()-files[i].last_write_time()>std::chrono::hours(24)) {
|
||||
std::error_code ignored; std::filesystem::remove(files[i].path(),ignored);
|
||||
}
|
||||
}
|
||||
}
|
||||
void savePreview(const Clip &clip,const std::filesystem::path &path)
|
||||
{
|
||||
auto pixels=decodeImage(clip,360,220);
|
||||
for(size_t i=3;i<pixels.bgra.size();i+=4) pixels.bgra[i]=255;
|
||||
ComPtr<IWICImagingFactory> factory; check(CoCreateInstance(CLSID_WICImagingFactory,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&factory)));
|
||||
ComPtr<IWICStream> stream; check(factory->CreateStream(&stream)); check(stream->InitializeFromFilename(path.c_str(),GENERIC_WRITE));
|
||||
ComPtr<IWICBitmapEncoder> encoder; check(factory->CreateEncoder(GUID_ContainerFormatPng,nullptr,&encoder));
|
||||
check(encoder->Initialize(stream.Get(),WICBitmapEncoderNoCache));
|
||||
ComPtr<IWICBitmapFrameEncode> frame; check(encoder->CreateNewFrame(&frame,nullptr)); check(frame->Initialize(nullptr));
|
||||
check(frame->SetSize(pixels.width,pixels.height));
|
||||
auto format=GUID_WICPixelFormat32bppBGRA; check(frame->SetPixelFormat(&format));
|
||||
if(format!=GUID_WICPixelFormat32bppBGRA) throw std::runtime_error("Unsupported preview pixel format");
|
||||
check(frame->WritePixels(pixels.height,pixels.width*4,(UINT)pixels.bgra.size(),pixels.bgra.data()));
|
||||
check(frame->Commit()); check(encoder->Commit());
|
||||
}
|
||||
std::wstring escapeXml(const std::wstring &s)
|
||||
{
|
||||
std::wstring result;
|
||||
for(auto ch:s) { if(ch==L'&') result+=L"&"; else if(ch==L'\"') result+=L"""; else if(ch==L'<') result+=L"<"; else result+=ch; }
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Clip loadNotifiedScreenshot(const std::wstring &id)
|
||||
{
|
||||
if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification");
|
||||
auto directory=cacheDirectory(); cleanCache(directory);
|
||||
auto path=directory/(id+L".capture");
|
||||
if(!std::filesystem::exists(path)) throw std::runtime_error("This screenshot notification has expired. Take a new screenshot.");
|
||||
return Clip{{{CF_DIB,L"",unprotect(readFile(path,160*1024*1024))}}};
|
||||
}
|
||||
bool notifyScreenshot(const Clip &clip)
|
||||
{
|
||||
notificationError.clear();
|
||||
const char *stage="registration";
|
||||
try {
|
||||
registerScreenshotNotifications();
|
||||
check(SetCurrentProcessExplicitAppUserModelID(AppId));
|
||||
stage="notification manager";
|
||||
ComPtr<Notifications::IToastNotificationManagerStatics> manager;
|
||||
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
|
||||
check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager)));
|
||||
ComPtr<Notifications::IToastNotifier> notifier; String appId(AppId);
|
||||
stage="notifier";
|
||||
check(manager->CreateToastNotifierWithId(appId.value,¬ifier));
|
||||
stage="settings";
|
||||
Notifications::NotificationSetting setting{};
|
||||
auto settingResult=notifier->get_Setting(&setting);
|
||||
// A first-time sender has no settings record until its first notification.
|
||||
if(settingResult!=HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) check(settingResult);
|
||||
if(setting!=Notifications::NotificationSetting_Enabled) throw std::runtime_error("Screenshot notifications are disabled in Windows settings");
|
||||
stage="cache";
|
||||
auto directory=cacheDirectory(); cleanCache(directory);
|
||||
GUID guid{}; check(CoCreateGuid(&guid)); wchar_t value[40]{}; StringFromGUID2(guid,value,40);
|
||||
std::wstring id(value+1,36);
|
||||
auto dib=std::find_if(clip.formats.begin(),clip.formats.end(),[](const auto &format){return format.id==CF_DIB;});
|
||||
if(dib==clip.formats.end() || dib->data.size()>128*1024*1024) return false;
|
||||
atomicWrite(directory/(id+L".capture"),protect(dib->data));
|
||||
stage="preview";
|
||||
auto previews=previewDirectory(); cleanCache(previews);
|
||||
auto preview=previews/(id+L".png"); savePreview(clip,preview);
|
||||
cleanCache(previews);
|
||||
cleanCache(directory);
|
||||
stage="XML";
|
||||
String documentClass(L"Windows.Data.Xml.Dom.XmlDocument"); ComPtr<IInspectable> documentInstance;
|
||||
check(RoActivateInstance(documentClass.value,&documentInstance));
|
||||
ComPtr<Xml::IXmlDocument> document; check(documentInstance.As(&document));
|
||||
ComPtr<Xml::IXmlDocumentIO> documentIO; check(document.As(&documentIO));
|
||||
auto uri=L"file:///"+preview.generic_wstring();
|
||||
String content(L"<toast launch=\""+id+L"\"><visual><binding template=\"ToastGeneric\">"
|
||||
L"<text>Screenshot copied</text><text>Click to edit and annotate</text><image id=\"1\" placement=\"appLogoOverride\" alt=\"Screenshot preview\" src=\""+escapeXml(uri)+L"\"/></binding></visual><audio silent=\"true\"/></toast>");
|
||||
check(documentIO->LoadXml(content.value));
|
||||
stage="toast factory";
|
||||
ComPtr<Notifications::IToastNotificationFactory> toastFactory; String toastClass(L"Windows.UI.Notifications.ToastNotification");
|
||||
check(RoGetActivationFactory(toastClass.value,IID_PPV_ARGS(&toastFactory)));
|
||||
ComPtr<Notifications::IToastNotification> toast; check(toastFactory->CreateToastNotification(document.Get(),&toast));
|
||||
ComPtr<ABI::Windows::Foundation::IPropertyValueStatics> values;
|
||||
String propertyClass(L"Windows.Foundation.PropertyValue");
|
||||
check(RoGetActivationFactory(propertyClass.value,IID_PPV_ARGS(&values)));
|
||||
FILETIME now{}; GetSystemTimeAsFileTime(&now);
|
||||
ULARGE_INTEGER timestamp{}; timestamp.LowPart=now.dwLowDateTime; timestamp.HighPart=now.dwHighDateTime;
|
||||
ABI::Windows::Foundation::DateTime expires{(INT64)(timestamp.QuadPart+24ull*60*60*10000000)};
|
||||
ComPtr<IInspectable> boxed; check(values->CreateDateTime(expires,&boxed));
|
||||
ComPtr<ABI::Windows::Foundation::IReference<ABI::Windows::Foundation::DateTime>> expiration;
|
||||
check(boxed.As(&expiration)); check(toast->put_ExpirationTime(expiration.Get()));
|
||||
stage="show";
|
||||
check(notifier->Show(toast.Get()));
|
||||
return true;
|
||||
} catch(const std::exception &e) { notificationError=std::string(stage)+": "+e.what(); log(wide(notificationError)); return false; } // Clipboard is already populated; fall back to the editor.
|
||||
}
|
||||
std::string screenshotNotificationError() { return notificationError; }
|
||||
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
@@ -150,6 +150,8 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
||||
s.translit = str(j, "translitHotkey", s.translit);
|
||||
s.undo = str(j, "undoHotkey", s.undo);
|
||||
s.clipboard = str(j, "clipboardHotkey", s.clipboard);
|
||||
s.screenshot = str(j, "screenshotHotkey", s.screenshot);
|
||||
s.screenshotAutoOpenEditor = j.value("screenshotAutoOpenEditor", true);
|
||||
if (j.contains("layouts"))
|
||||
{
|
||||
s.cycle.clear();
|
||||
@@ -203,7 +205,9 @@ Bytes encodeSettings(const Settings &s)
|
||||
{"caseHotkey", utf8(s.casing)},
|
||||
{"translitHotkey", utf8(s.translit)},
|
||||
{"undoHotkey", utf8(s.undo)},
|
||||
{"clipboardHotkey", utf8(s.clipboard)}};
|
||||
{"clipboardHotkey", utf8(s.clipboard)},
|
||||
{"screenshotHotkey", utf8(s.screenshot)},
|
||||
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
|
||||
j["layouts"] = Json::array();
|
||||
for (auto &id : s.cycle)
|
||||
j["layouts"].push_back(utf8(id));
|
||||
|
||||
@@ -31,7 +31,7 @@ HFONT font(HWND w)
|
||||
struct WindowLayout
|
||||
{
|
||||
int width{}, height{}, x{}, y{};
|
||||
bool scrolling{}, arranging{};
|
||||
bool scrolling{}, arranging{}, history{};
|
||||
int wheelRemainder{}, horizontalWheelRemainder{};
|
||||
std::vector<std::pair<HWND, RECT>> controls; // Original 96-DPI geometry; never rescale rounded pixels.
|
||||
};
|
||||
@@ -68,9 +68,18 @@ void arrangeWindow(HWND w)
|
||||
layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE);
|
||||
layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE);
|
||||
}
|
||||
RECT client{}; GetClientRect(w, &client);
|
||||
int extraWidth = layout->history ? std::max(0, int(client.right) - scale(w, layout->width)) : 0;
|
||||
int extraHeight = layout->history ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0;
|
||||
for (const auto &[child, r] : layout->controls)
|
||||
SetWindowPos(child, nullptr, scale(w, r.left) - layout->x, scale(w, r.top) - layout->y,
|
||||
scale(w, r.right - r.left), scale(w, r.bottom - r.top), SWP_NOACTIVATE | SWP_NOZORDER);
|
||||
{
|
||||
int id = GetDlgCtrlID(child);
|
||||
SetWindowPos(child, nullptr, scale(w, r.left) - layout->x,
|
||||
scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0),
|
||||
scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0),
|
||||
scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0),
|
||||
SWP_NOACTIVATE | SWP_NOZORDER);
|
||||
}
|
||||
layout->arranging = false;
|
||||
InvalidateRect(w, nullptr, TRUE);
|
||||
}
|
||||
@@ -255,7 +264,7 @@ void error(HWND w, const std::exception &e)
|
||||
MessageBoxW(w, wide(e.what()).c_str(), L"KSwitcher", MB_OK | MB_ICONWARNING);
|
||||
}
|
||||
HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc, int width, int height,
|
||||
DWORD extra = 0)
|
||||
DWORD extra = 0, bool resizable = true)
|
||||
{
|
||||
WNDCLASSW wc{};
|
||||
wc.hInstance = app.instance;
|
||||
@@ -267,7 +276,8 @@ HWND makeWindow(App &app, const wchar_t *cls, const wchar_t *title, WNDPROC proc
|
||||
RegisterClassW(&wc);
|
||||
UINT dpi = GetDpiForSystem();
|
||||
RECT rect{0, 0, MulDiv(width, dpi, 96), MulDiv(height, dpi, 96)};
|
||||
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_THICKFRAME | WS_MAXIMIZEBOX | extra;
|
||||
DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | extra;
|
||||
if (resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX;
|
||||
AdjustWindowRectExForDpi(&rect, style, FALSE, WS_EX_APPWINDOW, dpi);
|
||||
POINT cursor{};
|
||||
GetCursorPos(&cursor);
|
||||
@@ -350,6 +360,9 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
|
||||
SetPropW(w, L"Dpi", (HANDLE)(UINT_PTR)next);
|
||||
if (old)
|
||||
DeleteObject(old);
|
||||
// layoutProc consumes WM_DPICHANGED, so owner-drawn rows must be resized here.
|
||||
if (layout && layout->history)
|
||||
SendDlgItemMessageW(w, 102, LB_SETITEMHEIGHT, 0, scale(w, 88));
|
||||
fitToMonitor(w, *(RECT *)lp);
|
||||
arrangeWindow(w);
|
||||
}
|
||||
@@ -737,12 +750,6 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
v->pending.clear();
|
||||
++v->generation;
|
||||
return 0;
|
||||
case WM_DPICHANGED:
|
||||
{
|
||||
resizeForDpi(w, wp, lp);
|
||||
SendDlgItemMessageW(w, 102, LB_SETITEMHEIGHT, 0, scale(w, 88));
|
||||
return 0;
|
||||
}
|
||||
case WM_NCDESTROY:
|
||||
disposeFont(w);
|
||||
v->app->historyWindow = nullptr;
|
||||
@@ -812,6 +819,7 @@ void fillSettings(HWND w, SettingsView *v)
|
||||
check(w, 201, s.recent);
|
||||
check(w, 202, s.switchLayout);
|
||||
check(w, 208, s.writeLog);
|
||||
check(w, 209, s.screenshotAutoOpenEditor);
|
||||
check(w, 203, s.history);
|
||||
check(w, 204, s.autostart);
|
||||
check(w, 206, s.elevatedAutostart);
|
||||
@@ -822,6 +830,7 @@ void fillSettings(HWND w, SettingsView *v)
|
||||
value(w, 212, s.translit);
|
||||
value(w, 213, s.undo);
|
||||
value(w, 214, s.clipboard);
|
||||
value(w, 215, s.screenshot);
|
||||
value(w, 240, std::to_wstring(s.maxItems));
|
||||
value(w, 241, std::to_wstring(s.maxMiB));
|
||||
value(w, 242, std::to_wstring(s.maxImageMiB));
|
||||
@@ -861,6 +870,7 @@ Settings collectSettings(HWND w, SettingsView *v)
|
||||
s.recent = checked(w, 201);
|
||||
s.switchLayout = checked(w, 202);
|
||||
s.writeLog = checked(w, 208);
|
||||
s.screenshotAutoOpenEditor = checked(w, 209);
|
||||
s.history = checked(w, 203);
|
||||
s.autostart = checked(w, 204);
|
||||
s.elevatedAutostart = checked(w, 206);
|
||||
@@ -871,6 +881,7 @@ Settings collectSettings(HWND w, SettingsView *v)
|
||||
s.translit = value(w, 212);
|
||||
s.undo = value(w, 213);
|
||||
s.clipboard = value(w, 214);
|
||||
s.screenshot = value(w, 215);
|
||||
s.maxItems = integer(w, 240);
|
||||
s.maxMiB = integer(w, 241);
|
||||
s.maxImageMiB = integer(w, 242);
|
||||
@@ -1322,7 +1333,7 @@ void refreshHistory(App &app)
|
||||
search(app.historyWindow, v);
|
||||
}
|
||||
}
|
||||
void showHistory(App &app)
|
||||
void showHistory(App &app, bool visible)
|
||||
{
|
||||
if (!app.history)
|
||||
{
|
||||
@@ -1330,7 +1341,7 @@ void showHistory(App &app)
|
||||
return;
|
||||
}
|
||||
HWND foreground = GetForegroundWindow();
|
||||
if (app.historyWindow && IsWindowVisible(app.historyWindow) && foreground == app.historyWindow)
|
||||
if (visible && app.historyWindow && IsWindowVisible(app.historyWindow) && foreground == app.historyWindow)
|
||||
{
|
||||
auto *v = (HistoryView *)GetWindowLongPtrW(app.historyWindow, GWLP_USERDATA);
|
||||
if (v->items.size() > 1)
|
||||
@@ -1343,6 +1354,7 @@ void showHistory(App &app)
|
||||
{
|
||||
HWND w = makeWindow(app, L"KSwitcher.History", L"Clipboard history", historyProc, 760, 500);
|
||||
app.historyWindow = w;
|
||||
windowLayoutState(w)->history = true;
|
||||
((HistoryView *)GetWindowLongPtrW(w, GWLP_USERDATA))->rich = createRichText(w, 105, false);
|
||||
label(w, L"Search clipboard history", 16, 12, 500);
|
||||
edit(w, 101, 16, 38, 728);
|
||||
@@ -1359,6 +1371,7 @@ void showHistory(App &app)
|
||||
SetWindowSubclass(list, historyKey, 1, (DWORD_PTR)w);
|
||||
SetWindowSubclass(GetDlgItem(w, 101), historyKey, 1, (DWORD_PTR)w);
|
||||
}
|
||||
if (!visible) return;
|
||||
POINT cursor;
|
||||
GetCursorPos(&cursor);
|
||||
MONITORINFO monitor{sizeof(monitor)};
|
||||
@@ -1630,7 +1643,7 @@ void showSettings(App &app, bool visible)
|
||||
activateWindow(target);
|
||||
return;
|
||||
}
|
||||
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 780, 660);
|
||||
HWND w = makeWindow(app, L"KSwitcher.Settings", L"KSwitcher settings", settingsProc, 780, 660, 0, false);
|
||||
app.settingsWindow = w;
|
||||
label(w, L"Text conversion", 18, 16);
|
||||
checkbox(w, L"Convert recently typed text when nothing is selected", 201, 18, 43, 390);
|
||||
@@ -1642,23 +1655,24 @@ void showSettings(App &app, bool visible)
|
||||
checkbox(w, L"Write log", 208, 240, 184, 160);
|
||||
label(w, L"Click a shortcut and press the desired keys", 420, 16, 350);
|
||||
const wchar_t *names[] = {L"Next layout", L"Change case", L"Transliterate", L"Undo",
|
||||
L"Clipboard history"};
|
||||
for (int i = 0; i < 5; ++i)
|
||||
L"Clipboard history", L"Screen capture"};
|
||||
for (int i = 0; i < 6; ++i)
|
||||
{
|
||||
label(w, names[i], 420, 48 + i * 30, 115);
|
||||
hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220);
|
||||
}
|
||||
checkbox(w, L"Automatically correct layout (experimental)", 207, 420, 195, 350);
|
||||
label(w, L"Layout cycle — top to bottom", 18, 210, 400);
|
||||
checkbox(w, L"Open editor after screen capture", 209, 420, 224, 340);
|
||||
checkbox(w, L"Automatically correct layout (experimental)", 207, 18, 210, 395);
|
||||
label(w, L"Layout cycle — top to bottom", 18, 240, 400);
|
||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 220,
|
||||
18, 232, 470, 190);
|
||||
button(w, L"Move up", 232, 504, 232, 120);
|
||||
button(w, L"Move down", 233, 632, 232, 128);
|
||||
checkbox(w, L"Include in cycle", 230, 504, 275, 240);
|
||||
label(w, L"Direct layout shortcut", 504, 313, 240);
|
||||
hotkeyEdit(app, w, 231, 504, 340, 256);
|
||||
label(w, L"Backspace / Delete clears a shortcut", 504, 365, 256);
|
||||
button(w, L"Refresh layouts", 253, 504, 384, 150);
|
||||
18, 262, 470, 172);
|
||||
button(w, L"Move up", 232, 504, 262, 120);
|
||||
button(w, L"Move down", 233, 632, 262, 128);
|
||||
checkbox(w, L"Include in cycle", 230, 504, 300, 240);
|
||||
label(w, L"Direct layout shortcut", 504, 332, 240);
|
||||
hotkeyEdit(app, w, 231, 504, 356, 256);
|
||||
label(w, L"Backspace / Delete clears a shortcut", 504, 382, 256);
|
||||
button(w, L"Refresh layouts", 253, 504, 408, 150);
|
||||
label(w, L"History limits", 18, 444);
|
||||
label(w, L"Items", 18, 478, 50);
|
||||
edit(w, 240, 70, 474, 90);
|
||||
@@ -1718,7 +1732,8 @@ void showShortcuts(App &app)
|
||||
{
|
||||
auto &s = app.settings;
|
||||
std::wstring text = L"Next layout: " + s.toggle + L"\nChange case: " + s.casing + L"\nTransliterate: " +
|
||||
s.translit + L"\nUndo: " + s.undo + L"\nClipboard history: " + s.clipboard + L"\n";
|
||||
s.translit + L"\nUndo: " + s.undo + L"\nClipboard history: " + s.clipboard +
|
||||
L"\nScreen capture: " + s.screenshot + L"\n";
|
||||
for (size_t i = 0; i < std::min(app.layouts.size(), s.direct.size()); ++i)
|
||||
if (!s.direct[i].empty())
|
||||
text += L"\n" + app.layouts[i].name + L": " + s.direct[i];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#include "platform.h"
|
||||
namespace ks
|
||||
{
|
||||
inline constexpr wchar_t AppVersion[] = L"0.3.0";
|
||||
inline constexpr wchar_t AppVersion[] = L"0.6.2";
|
||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||
struct UpdateResult
|
||||
{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#include "app.h"
|
||||
#include "ui_geometry.h"
|
||||
#include "screenshot.h"
|
||||
#include <commctrl.h>
|
||||
#include <iostream>
|
||||
#include <cmath>
|
||||
namespace ks {
|
||||
struct InputRegressionAccess {
|
||||
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
|
||||
@@ -29,6 +31,410 @@ int main(int argc, char **argv) {
|
||||
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
|
||||
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
|
||||
require(app.window!=nullptr,"message-only test window");
|
||||
for(int variant=0;variant<3;++variant) for(int icon=0;icon<15;++icon)
|
||||
for(int size:{22,44,55}) {
|
||||
auto handle=(HICON)LoadImageW(app.instance,MAKEINTRESOURCEW(icon<9?300+variant*10+icon:400+variant*10+icon-9),IMAGE_ICON,size,size,0);
|
||||
require(handle!=nullptr,"Lucide toolbar icon loads at standard and high DPI");
|
||||
DestroyIcon(handle);
|
||||
}
|
||||
{
|
||||
ks::ScreenshotHistory history;
|
||||
std::vector<ks::ScreenshotMark> marks;
|
||||
require(!history.undo(marks) && !history.redo(marks),"empty annotation history is safe");
|
||||
history.remember(marks);
|
||||
marks.push_back({{20,20},{20,20},1,24});
|
||||
history.remember(marks);
|
||||
marks.push_back({{44,20},{100,20},0,15,8,1,0});
|
||||
require(history.undo(marks) && marks.size()==1 && history.canRedo(),"undo preserves state for redo");
|
||||
require(history.undo(marks) && marks.empty(),"multiple undo steps restore empty canvas");
|
||||
require(history.redo(marks) && marks[0].radius==24,"redo restores marker size");
|
||||
require(history.redo(marks) && marks.size()==2 && marks[1].thickness==8 && marks[1].fromNumber==1,
|
||||
"redo restores arrow style and endpoint links");
|
||||
require(!history.canRedo(),"redo becomes unavailable after last replay");
|
||||
history.undo(marks);
|
||||
history.remember(marks); marks[0].radius=40;
|
||||
require(!history.canRedo() && !history.redo(marks),"new edit discards redo branch");
|
||||
require(history.undo(marks) && marks[0].radius==24,"new branch remains undoable");
|
||||
ks::ScreenshotHistory bounded;
|
||||
for(int i=0;i<110;++i) { bounded.remember(marks); marks[0].radius=i; }
|
||||
int count=0; while(bounded.undo(marks)) ++count;
|
||||
require(count==100,"annotation undo history stays bounded");
|
||||
count=0; while(bounded.redo(marks)) ++count;
|
||||
require(count==100 && marks[0].radius==109,"bounded history can replay all retained edits");
|
||||
}
|
||||
{
|
||||
HDC dc=CreateCompatibleDC(nullptr);
|
||||
BITMAPINFO info{};
|
||||
info.bmiHeader={sizeof(BITMAPINFOHEADER),160,-100,1,32,BI_RGB};
|
||||
void *pixels{};
|
||||
auto bitmap=CreateDIBSection(dc,&info,DIB_RGB_COLORS,&pixels,nullptr,0);
|
||||
auto old=SelectObject(dc,bitmap);
|
||||
PatBlt(dc,0,0,160,100,WHITENESS);
|
||||
auto plain=ks::screenshotClip(dc,160,100,{});
|
||||
auto marked=ks::screenshotClip(dc,160,100,{{{30,30},{30,30},1},{{60,70},{130,70},0}});
|
||||
auto &bytes=marked.formats[0].data;
|
||||
BITMAPINFOHEADER header{}; memcpy(&header,bytes.data(),sizeof(header));
|
||||
require(header.biWidth==160&&header.biHeight==-100,"capture preserves source resolution and orientation");
|
||||
require(bytes.size()==sizeof(header)+160*100*4,"capture exports complete 32-bit DIB");
|
||||
auto pixel=[&](int x,int y) { auto offset=sizeof(header)+(y*160+x)*4; return RGB(bytes[offset+2],bytes[offset+1],bytes[offset]); };
|
||||
require(pixel(30,20)==RGB(225,42,50),"number badge renders in exported image");
|
||||
require(pixel(90,70)==RGB(225,42,50),"arrow shaft renders in exported image");
|
||||
require(pixel(5,5)==RGB(255,255,255),"unmarked pixels preserved");
|
||||
ks::ScreenshotMark blueArrow{{20,50},{140,50},0}; blueArrow.color=RGB(0,80,220);
|
||||
ks::ScreenshotMark greenBadge{{30,30},{30,30},1}; greenBadge.color=RGB(0,200,80);
|
||||
auto colored=ks::screenshotClip(dc,160,100,{blueArrow,greenBadge});
|
||||
auto colorAt=[&](int x,int y) {
|
||||
auto &b=colored.formats[0].data; auto at=sizeof(BITMAPINFOHEADER)+(y*160+x)*4;
|
||||
return RGB(b[at+2],b[at+1],b[at]);
|
||||
};
|
||||
require(colorAt(70,50)==blueArrow.color && colorAt(132,50)==blueArrow.color,
|
||||
"arrow shaft and head use object color");
|
||||
require(colorAt(30,20)==greenBadge.color,"badge fill uses object color");
|
||||
ks::ScreenshotHistory recolors;
|
||||
std::vector<ks::ScreenshotMark> coloredMarks{blueArrow,greenBadge};
|
||||
recolors.remember(coloredMarks); coloredMarks[0].color=RGB(200,0,100);
|
||||
recolors.remember(coloredMarks); coloredMarks[1].color=RGB(255,255,0);
|
||||
require(recolors.undo(coloredMarks) && coloredMarks[1].color==greenBadge.color,"undo restores badge color");
|
||||
require(recolors.undo(coloredMarks) && coloredMarks[0].color==blueArrow.color,"undo restores arrow color");
|
||||
require(recolors.redo(coloredMarks) && coloredMarks[0].color==RGB(200,0,100),"redo restores arrow recoloring");
|
||||
require(ks::cloneScreenshotMark(greenBadge,{greenBadge},160,100).color==greenBadge.color,
|
||||
"cloned badge retains customized color");
|
||||
ks::ScreenshotMark thickArrow{{20,50},{140,50},0,15,12};
|
||||
auto grip=ks::screenshotThicknessHandle(thickArrow);
|
||||
require(grip.x==80 && grip.y==72,"thickness grip sits beside arrow midpoint");
|
||||
auto thicker=ks::dragScreenshotMark(thickArrow,ks::MarkPart::Thickness,grip,{grip.x,grip.y+4},160,100);
|
||||
require(thicker.thickness==20 && thicker.from.x==20 && thicker.to.x==140,
|
||||
"dragging thickness grip changes width without changing endpoints");
|
||||
require(ks::dragScreenshotMark(thickArrow,ks::MarkPart::Thickness,grip,{grip.x+20,grip.y},160,100).thickness==12,
|
||||
"movement along arrow does not change thickness");
|
||||
require(ks::dragScreenshotMark(thickArrow,ks::MarkPart::Thickness,grip,{grip.x,0},160,100).thickness==1,
|
||||
"thickness grip respects minimum width");
|
||||
auto cloned=ks::cloneScreenshotMark(thickArrow,{},160,100);
|
||||
require(cloned.thickness==12 && cloned.from.x!=thickArrow.from.x && cloned.to.x-cloned.from.x==120,
|
||||
"cloned arrow is offset and preserves dimensions and style");
|
||||
auto thick=ks::screenshotClip(dc,160,100,{thickArrow});
|
||||
auto shaftEdge=sizeof(BITMAPINFOHEADER)+(54*160+40)*4;
|
||||
require(thick.formats[0].data[shaftEdge+2]==225 && thick.formats[0].data[shaftEdge+1]==42,
|
||||
"export renders edited arrow thickness");
|
||||
thickArrow.thickness=1;
|
||||
auto thin=ks::screenshotClip(dc,160,100,{thickArrow});
|
||||
require(thin.formats[0].data[shaftEdge+1]==255,"thin arrow leaves surrounding pixels untouched");
|
||||
thickArrow.thickness=12;
|
||||
require(ks::hitScreenshotMark(thickArrow,{60,55},1)==ks::MarkPart::Body,
|
||||
"thick arrow can be selected along its visible edge");
|
||||
require(ks::dragScreenshotMark(thickArrow,ks::MarkPart::End,{140,50},{145,70},160,100).thickness==12,
|
||||
"editing arrow geometry preserves thickness");
|
||||
ks::ScreenshotMark badge{{60,50},{60,50},2};
|
||||
auto clonedBadge=ks::cloneScreenshotMark(badge,{badge,{{90,20},{90,20},5}},160,100);
|
||||
require(clonedBadge.number==1 && clonedBadge.radius==15 && clonedBadge.from.x==76,
|
||||
"cloned badge fills first numbering gap and keeps diameter");
|
||||
require(ks::nextScreenshotNumber({})==1,"empty capture starts numbering at one");
|
||||
std::vector<ks::ScreenshotMark> numbered{
|
||||
{{10,10},{10,10},4},{{20,10},{20,10},1},{{30,10},{30,10},3},{{0,0},{100,0},0}};
|
||||
require(ks::nextScreenshotNumber(numbered)==2,"new badge fills interior gap regardless of stacking order");
|
||||
numbered.push_back({{40,10},{40,10},2});
|
||||
require(ks::nextScreenshotNumber(numbered)==5,"full sequence advances past existing numbers");
|
||||
numbered.erase(numbered.begin()+1);
|
||||
require(ks::nextScreenshotNumber(numbered)==1,"deleted first number is reused");
|
||||
require(numbered[0].number==4 && numbered[1].number==3,"number allocation never renumbers existing objects");
|
||||
std::vector<ks::ScreenshotMark> linked{
|
||||
{{30,50},{30,50},1}, {{130,50},{130,50},2}, {{30,50},{130,50},0}};
|
||||
ks::linkScreenshotEndpoint(linked[2],true,{30,50},linked,6);
|
||||
ks::linkScreenshotEndpoint(linked[2],false,{130,50},linked,6);
|
||||
ks::resolveScreenshotLinks(linked);
|
||||
require(linked[2].fromNumber==1 && linked[2].toNumber==2 && linked[2].from.x==45 && linked[2].to.x==115,
|
||||
"arrow endpoints attach to badge rims");
|
||||
auto clonedLinked=ks::cloneScreenshotMark(linked[2],linked,160,100);
|
||||
require(clonedLinked.fromNumber==0 && clonedLinked.toNumber==0,"cloned arrow does not retain original links");
|
||||
auto widthLinked=ks::dragScreenshotMark(linked[2],ks::MarkPart::Thickness,{80,70},{80,74},160,100);
|
||||
require(widthLinked.fromNumber==1 && widthLinked.toNumber==2,"thickness handle preserves endpoint links");
|
||||
linked[0].from.y=20; linked[0].to.y=20;
|
||||
linked[1].radius=25;
|
||||
ks::resolveScreenshotLinks(linked);
|
||||
require(linked[2].from.y>20 && linked[2].from.y<50 && linked[2].to.x<110,
|
||||
"linked arrows follow badge movement and resize");
|
||||
auto detached=ks::dragScreenshotMark(linked[2],ks::MarkPart::Start,linked[2].from,{10,90},160,100);
|
||||
require(detached.fromNumber==0 && detached.toNumber==2,"endpoint drag detaches only that endpoint");
|
||||
ks::linkScreenshotEndpoint(detached,true,{10,90},linked,6);
|
||||
require(detached.fromNumber==0,"dropping away from badges remains detached");
|
||||
auto translated=ks::dragScreenshotMark(linked[2],ks::MarkPart::Body,{80,35},{85,40},160,100);
|
||||
require(translated.fromNumber==0 && translated.toNumber==0,"moving whole arrow detaches both endpoints");
|
||||
auto lastPoint=linked[2].from;
|
||||
linked.erase(linked.begin());
|
||||
ks::resolveScreenshotLinks(linked);
|
||||
require(linked[1].fromNumber==0 && linked[1].toNumber==2 && linked[1].from.x==lastPoint.x,
|
||||
"deleting badge detaches without moving orphaned endpoint");
|
||||
linked.push_back({{10,10},{10,10},1});
|
||||
ks::resolveScreenshotLinks(linked);
|
||||
require(linked[1].fromNumber==0,"reused number cannot reconnect a deleted link");
|
||||
require(ks::hitScreenshotMark(badge,{60,50},20)==ks::MarkPart::Body,"badge center moves even with large hit tolerance");
|
||||
require(ks::hitScreenshotMark(badge,{75,50},6)==ks::MarkPart::Radius,"badge rim exposes resize handle");
|
||||
auto enlarged=ks::dragScreenshotMark(badge,ks::MarkPart::Radius,{75,50},{95,50},160,100);
|
||||
require(enlarged.radius==35 && enlarged.number==2,"badge resizes without changing its number");
|
||||
auto large=ks::screenshotClip(dc,160,100,{enlarged});
|
||||
const auto &largeBytes=large.formats[0].data;
|
||||
auto red=sizeof(BITMAPINFOHEADER)+(50*160+90)*4;
|
||||
require(largeBytes[red+2]==225 && largeBytes[red+1]==42,"export uses edited badge diameter");
|
||||
require(ks::dragScreenshotMark(badge,ks::MarkPart::Radius,{75,50},{60,50},160,100).radius==8,
|
||||
"badge diameter has a usable minimum");
|
||||
ks::ScreenshotMark arrow{{20,20},{100,60},0};
|
||||
require(ks::hitScreenshotMark(arrow,{60,40},5)==ks::MarkPart::Body,"arrow shaft selects whole arrow");
|
||||
require(ks::hitScreenshotMark(arrow,{100,60},5)==ks::MarkPart::End,"arrow tip selects endpoint");
|
||||
require(ks::hitScreenshotMark(arrow,{60,80},5)==ks::MarkPart::None,"empty canvas misses arrow");
|
||||
auto moved=ks::dragScreenshotMark(arrow,ks::MarkPart::Body,{60,40},{150,90},160,100);
|
||||
require(moved.to.x==159 && moved.to.y==99 && moved.to.x-moved.from.x==80 && moved.to.y-moved.from.y==40,
|
||||
"moving arrow preserves shape at image boundary");
|
||||
auto stretched=ks::dragScreenshotMark(arrow,ks::MarkPart::End,{100,60},{140,80},160,100);
|
||||
require(stretched.from.x==20 && stretched.from.y==20 && stretched.to.x==140 && stretched.to.y==80,
|
||||
"endpoint drag leaves opposite endpoint fixed");
|
||||
require(ks::screenshotClip(dc,160,100,{}).formats[0].data==plain.formats[0].data,
|
||||
"annotations do not modify original capture");
|
||||
ks::ScreenshotMark highlight{{20,50},{120,50},0,15,24};
|
||||
highlight.points={{20,50},{120,50}}; highlight.color=RGB(255,255,0);
|
||||
SetPixel(dc,60,50,RGB(0,0,0));
|
||||
auto yellow=ks::screenshotClip(dc,160,100,{highlight});
|
||||
auto readPixel=[](const ks::Clip &clip,int x,int y) {
|
||||
auto &b=clip.formats[0].data; auto offset=sizeof(BITMAPINFOHEADER)+(y*160+x)*4;
|
||||
return RGB(b[offset+2],b[offset+1],b[offset]);
|
||||
};
|
||||
require(readPixel(yellow,40,50)==RGB(255,255,153),"highlight is translucent in exported image");
|
||||
require(readPixel(yellow,60,50)==RGB(0,0,0),"highlight keeps black text legible");
|
||||
require(readPixel(yellow,40,10)==RGB(255,255,255),"highlight leaves pixels outside brush unchanged");
|
||||
highlight.points.push_back({20,50});
|
||||
require(readPixel(ks::screenshotClip(dc,160,100,{highlight}),40,50)==RGB(255,255,153),
|
||||
"overlapping a stroke with itself does not accumulate opacity");
|
||||
ks::ScreenshotHistory colorHistory;
|
||||
std::vector<ks::ScreenshotMark> strokes{highlight};
|
||||
colorHistory.remember(strokes); strokes[0].color=RGB(0,255,255);
|
||||
require(readPixel(ks::screenshotClip(dc,160,100,strokes),40,50)==RGB(153,255,255),
|
||||
"changing highlight color rerenders original stroke");
|
||||
require(colorHistory.undo(strokes) && strokes[0].color==RGB(255,255,0),"undo restores highlight color");
|
||||
require(colorHistory.redo(strokes) && strokes[0].color==RGB(0,255,255),"redo restores highlight recolor");
|
||||
require(ks::hitScreenshotMark(highlight,{80,55},1)==ks::MarkPart::Body,"highlight stroke is selectable");
|
||||
require(ks::hitScreenshotMark(highlight,{80,10},1)==ks::MarkPart::None,"highlight hit test excludes empty area");
|
||||
auto movedHighlight=ks::dragScreenshotMark(highlight,ks::MarkPart::Body,{20,50},{30,60},160,100);
|
||||
require(movedHighlight.points[1].x==130 && movedHighlight.points[1].y==60,"moving highlight translates entire stroke");
|
||||
auto cloneHighlight=ks::cloneScreenshotMark(highlight,{highlight},160,100);
|
||||
require(cloneHighlight.points[0].x!=highlight.points[0].x && cloneHighlight.color==highlight.color &&
|
||||
cloneHighlight.thickness==24 && cloneHighlight.number==0,"cloning highlight preserves brush and color");
|
||||
PatBlt(dc,0,0,160,100,WHITENESS);
|
||||
ks::ScreenshotMark line{{20,50},{140,50},0}; line.ends=ks::LineEnds::None;
|
||||
ks::ScreenshotMark textMark{{5,5},{5,5},0}; textMark.text=L"Hello &\r\nСвіт";
|
||||
textMark.fontSize=14; textMark.color=RGB(0,80,180);
|
||||
auto textBounds=ks::screenshotTextBounds(textMark);
|
||||
require(textBounds.right>30 && textBounds.bottom>30,"multiline Unicode text is measured");
|
||||
auto textImage=ks::screenshotClip(dc,160,100,{textMark});
|
||||
int textInk=0;
|
||||
for(int y=5;y<textBounds.bottom;++y) for(int x=5;x<textBounds.right;++x)
|
||||
if(readPixel(textImage,x,y)==textMark.color) ++textInk;
|
||||
require(textInk>20,"text exports visible colored glyphs");
|
||||
require(ks::hitScreenshotMark(textMark,{6,6},2)==ks::MarkPart::Body,"text is movable inside its bounds");
|
||||
require(ks::hitScreenshotMark(textMark,{150,90},2)==ks::MarkPart::None,"text excludes empty canvas");
|
||||
auto bigText=textMark; bigText.fontSize=28;
|
||||
auto bigBounds=ks::screenshotTextBounds(bigText);
|
||||
require(bigBounds.bottom>textBounds.bottom && bigBounds.right>textBounds.right,"font size scales text bounds");
|
||||
auto serifText=textMark; serifText.fontName=L"Courier New"; serifText.fontWeight=FW_BOLD; serifText.fontItalic=true;
|
||||
require(ks::screenshotClip(dc,160,100,{serifText}).formats[0].data!=textImage.formats[0].data,
|
||||
"font family and style affect exported glyphs");
|
||||
auto movedText=ks::dragScreenshotMark(textMark,ks::MarkPart::Body,{6,6},{16,16},160,100);
|
||||
auto rotatedText=textMark; rotatedText.rotation=90;
|
||||
auto rotatedBounds=ks::screenshotTextBounds(rotatedText);
|
||||
require(std::abs((rotatedBounds.right-rotatedBounds.left)-(textBounds.bottom-textBounds.top))<=1,
|
||||
"rotation transforms text bounds");
|
||||
POINT center{(textBounds.left+textBounds.right)/2,(textBounds.top+textBounds.bottom)/2};
|
||||
require(ks::hitScreenshotMark(rotatedText,center,2)==ks::MarkPart::Body,"rotated text hit test follows text");
|
||||
auto rotationGrip=ks::screenshotTextRotationHandle(textMark);
|
||||
auto rotation=ks::dragScreenshotMark(textMark,ks::MarkPart::Rotation,rotationGrip,{center.x+40,center.y-25},160,100,true);
|
||||
require(std::abs(std::remainder(rotation.rotation,15))<0.001 && rotation.rotation!=0,"Shift snaps rotation to 15 degrees");
|
||||
auto reverseRotation=ks::dragScreenshotMark(textMark,ks::MarkPart::Rotation,rotationGrip,{center.x-40,center.y-25},160,100,false);
|
||||
require(reverseRotation.rotation<0,"text rotates in the opposite direction");
|
||||
require(ks::screenshotClip(dc,160,100,{rotatedText}).formats[0].data!=textImage.formats[0].data,
|
||||
"rotation is included in exported text pixels");
|
||||
require(ks::cloneScreenshotMark(rotatedText,{rotatedText},160,100).rotation==90,"cloning preserves text rotation");
|
||||
ks::ScreenshotMark labeled{{20,70},{140,70},0}; labeled.label=L"Label"; labeled.fontSize=12;
|
||||
auto label=ks::screenshotArrowLabel(labeled);
|
||||
auto labelBounds=ks::screenshotTextBounds(label);
|
||||
POINT labelCenter{(labelBounds.left+labelBounds.right)/2,(labelBounds.top+labelBounds.bottom)/2};
|
||||
auto labelGrip=ks::screenshotTextRotationHandle(label);
|
||||
auto diagonal=labeled; diagonal.to={100,150};
|
||||
for(int degrees:{0,45,90,135,180,-135,-90,-45}) {
|
||||
double angle=degrees*3.141592653589793/180;
|
||||
auto turning=labeled; turning.from={100,100};
|
||||
turning.to={100+LONG(std::lround(80*std::cos(angle))),100+LONG(std::lround(80*std::sin(angle)))};
|
||||
auto box=ks::screenshotTextBounds(ks::screenshotArrowLabel(turning));
|
||||
double x=(box.left+box.right)/2.0-(turning.from.x+turning.to.x)/2.0;
|
||||
double y=(box.top+box.bottom)/2.0-(turning.from.y+turning.to.y)/2.0;
|
||||
require(std::abs(-x*std::sin(angle)+y*std::cos(angle)+24)<1.5,
|
||||
"caption keeps its perpendicular distance and side as line rotates");
|
||||
require(std::abs(x*std::cos(angle)+y*std::sin(angle))<1.5,
|
||||
"caption remains centered along rotated line");
|
||||
}
|
||||
auto beforeDrag=ks::screenshotTextBounds(ks::screenshotArrowLabel(diagonal));
|
||||
auto offsetDiagonal=ks::dragScreenshotMark(diagonal,ks::MarkPart::Label,{0,0},{12,-8},200,200);
|
||||
require(!offsetDiagonal.labelRotationManual,"moving caption preserves automatic rotation");
|
||||
auto movedCaptionSlope=ks::dragScreenshotMark(offsetDiagonal,ks::MarkPart::End,offsetDiagonal.to,{20,150},200,200);
|
||||
require(std::abs(ks::screenshotArrowLabel(movedCaptionSlope).rotation-90)<0.001,
|
||||
"repositioned caption continues following line angle");
|
||||
auto afterDrag=ks::screenshotTextBounds(ks::screenshotArrowLabel(offsetDiagonal));
|
||||
require(std::abs(afterDrag.left-beforeDrag.left-12)<=1 && std::abs(afterDrag.top-beforeDrag.top+8)<=1,
|
||||
"dragging diagonal caption follows pointer in screen coordinates");
|
||||
require(std::abs(ks::screenshotArrowLabel(diagonal).rotation-45)<0.001,"caption defaults to line angle");
|
||||
auto changedSlope=ks::dragScreenshotMark(diagonal,ks::MarkPart::End,diagonal.to,{20,150},200,200);
|
||||
require(std::abs(ks::screenshotArrowLabel(changedSlope).rotation-90)<0.001,"automatic caption follows endpoint movement");
|
||||
auto reversed=diagonal; std::swap(reversed.from,reversed.to);
|
||||
require(std::abs(ks::screenshotArrowLabel(reversed).rotation-45)<0.001,"automatic caption remains upright on reversed line");
|
||||
auto diagonalLabel=ks::screenshotArrowLabel(diagonal);
|
||||
auto diagonalGrip=ks::screenshotTextRotationHandle(diagonalLabel);
|
||||
auto clickOnly=ks::dragScreenshotMark(diagonal,ks::MarkPart::Rotation,diagonalGrip,diagonalGrip,200,200,true);
|
||||
require(!clickOnly.labelRotationManual,"clicking rotation handle without dragging preserves automatic angle");
|
||||
auto manual=ks::dragScreenshotMark(diagonal,ks::MarkPart::Rotation,diagonalGrip,{diagonalGrip.x+12,diagonalGrip.y},200,200,false);
|
||||
require(manual.labelRotationManual && manual.rotation>45 && manual.rotation<90,
|
||||
"manual rotation starts at displayed automatic angle without jumping");
|
||||
auto manualResized=ks::dragScreenshotMark(manual,ks::MarkPart::End,manual.to,{20,150},200,200);
|
||||
require(ks::screenshotArrowLabel(manualResized).rotation==manual.rotation,"manual angle survives line resizing");
|
||||
require(ks::cloneScreenshotMark(manual,{manual},200,200).labelRotationManual,"clone preserves manual rotation mode");
|
||||
ks::ScreenshotHistory autoAngleHistory; std::vector<ks::ScreenshotMark> autoAngles{diagonal};
|
||||
autoAngleHistory.remember(autoAngles); autoAngles[0]=manual;
|
||||
require(autoAngleHistory.undo(autoAngles) && !autoAngles[0].labelRotationManual,"undo restores automatic angle mode");
|
||||
require(autoAngleHistory.redo(autoAngles) && autoAngles[0].labelRotationManual,"redo restores manual angle mode");
|
||||
auto curvedCaption=diagonal; curvedCaption.freehand=true;
|
||||
curvedCaption.points={{20,70},{60,70},{60,110},{100,150}};
|
||||
require(std::abs(ks::screenshotArrowLabel(curvedCaption).rotation-90)<0.001,"curve caption follows its middle segment");
|
||||
auto rotatedCaption=ks::dragScreenshotMark(labeled,ks::MarkPart::Rotation,labelGrip,
|
||||
{labelCenter.x+35,labelCenter.y-20},160,100,true);
|
||||
require(rotatedCaption.rotation!=0 && std::abs(std::remainder(rotatedCaption.rotation,15))<0.001,
|
||||
"arrow caption rotation snaps to 15 degrees");
|
||||
require(rotatedCaption.from.x==labeled.from.x && rotatedCaption.from.y==labeled.from.y &&
|
||||
rotatedCaption.to.x==labeled.to.x && rotatedCaption.to.y==labeled.to.y &&
|
||||
rotatedCaption.labelOffset.x==labeled.labelOffset.x && rotatedCaption.labelOffset.y==labeled.labelOffset.y,
|
||||
"rotating caption preserves arrow geometry and caption position");
|
||||
require(ks::screenshotArrowLabel(rotatedCaption).rotation==rotatedCaption.rotation,
|
||||
"linked caption uses the regular text rotation");
|
||||
require(ks::screenshotClip(dc,160,100,{rotatedCaption}).formats[0].data!=ks::screenshotClip(dc,160,100,{labeled}).formats[0].data,
|
||||
"caption rotation is exported");
|
||||
require(ks::cloneScreenshotMark(rotatedCaption,{rotatedCaption},160,100).rotation==rotatedCaption.rotation,
|
||||
"cloning retains caption angle");
|
||||
ks::ScreenshotHistory captionHistory; std::vector<ks::ScreenshotMark> captionMarks{labeled};
|
||||
captionHistory.remember(captionMarks); captionMarks[0]=rotatedCaption;
|
||||
require(captionHistory.undo(captionMarks) && captionMarks[0].rotation==0,"undo restores caption angle");
|
||||
require(captionHistory.redo(captionMarks) && captionMarks[0].rotation==rotatedCaption.rotation,"redo restores caption angle");
|
||||
require(ks::hitScreenshotMark(labeled,labelCenter,2)==ks::MarkPart::Label,"arrow label is independently draggable");
|
||||
auto repositioned=ks::dragScreenshotMark(labeled,ks::MarkPart::Label,labelCenter,{labelCenter.x+10,labelCenter.y-8},160,100);
|
||||
require(repositioned.labelOffset.x==10 && repositioned.labelOffset.y==-32 && repositioned.from.x==20,
|
||||
"label drag changes only its offset");
|
||||
auto movedArrow=ks::dragScreenshotMark(labeled,ks::MarkPart::Body,{20,70},{25,75},160,100);
|
||||
auto movedLabel=ks::screenshotArrowLabel(movedArrow);
|
||||
require(movedLabel.from.x==label.from.x+5 && movedLabel.from.y==label.from.y+5,"label follows arrow movement");
|
||||
auto resizedArrow=ks::dragScreenshotMark(labeled,ks::MarkPart::End,labeled.to,{120,70},160,100);
|
||||
require(ks::screenshotArrowLabel(resizedArrow).from.x==label.from.x-10,"label follows arrow resizing");
|
||||
auto noLabel=labeled; noLabel.label.clear();
|
||||
require(ks::screenshotClip(dc,160,100,{labeled}).formats[0].data!=ks::screenshotClip(dc,160,100,{noLabel}).formats[0].data,
|
||||
"arrow label is included in clipboard export");
|
||||
require(ks::cloneScreenshotMark(labeled,{labeled},160,100).label==labeled.label,"cloning retains arrow label");
|
||||
ks::ScreenshotHistory annotationHistory; std::vector<ks::ScreenshotMark> annotations{rotatedText,labeled};
|
||||
annotationHistory.remember(annotations); annotations[0]=rotation; annotations[1]=repositioned;
|
||||
require(annotationHistory.undo(annotations) && annotations[0].rotation==90 && annotations[1].labelOffset.x==0,
|
||||
"undo restores rotation and label position");
|
||||
require(annotationHistory.redo(annotations) && annotations[0].rotation==rotation.rotation && annotations[1].labelOffset.x==10,
|
||||
"redo restores rotation and label position");
|
||||
require(movedText.from.x==15 && movedText.from.y==15 && movedText.text==textMark.text,"text movement preserves content");
|
||||
auto oversizedText=textMark; oversizedText.fontSize=100;
|
||||
auto oversizedBounds=ks::screenshotTextBounds(oversizedText);
|
||||
require(oversizedBounds.right-oversizedBounds.left>160 && oversizedBounds.bottom-oversizedBounds.top>100,
|
||||
"large text exceeds both canvas dimensions");
|
||||
for(int direction:{-1,1}) {
|
||||
auto moved=ks::dragScreenshotMark(oversizedText,ks::MarkPart::Body,{0,0},{direction*20,direction*20},160,100);
|
||||
require(moved.from.x==oversizedText.from.x+direction*20 && moved.from.y==oversizedText.from.y+direction*20,
|
||||
"oversized text moves in either direction on both axes");
|
||||
auto limited=ks::dragScreenshotMark(oversizedText,ks::MarkPart::Body,{0,0},{direction*10000,direction*10000},160,100);
|
||||
auto bounds=ks::screenshotTextBounds(limited);
|
||||
require(bounds.right>=16 && bounds.left<=144 && bounds.bottom>=16 && bounds.top<=84,
|
||||
"oversized text remains selectable at canvas edges");
|
||||
}
|
||||
auto clonedText=ks::cloneScreenshotMark(serifText,{serifText},160,100);
|
||||
require(clonedText.text==serifText.text && clonedText.fontName==serifText.fontName &&
|
||||
clonedText.fontWeight==FW_BOLD && clonedText.fontItalic,"text cloning preserves font and content");
|
||||
ks::ScreenshotHistory textHistory; std::vector<ks::ScreenshotMark> texts{textMark};
|
||||
textHistory.remember(texts); texts[0]=serifText; texts[0].text=L"Changed";
|
||||
require(textHistory.undo(texts) && texts[0].text==textMark.text && texts[0].fontName==textMark.fontName,
|
||||
"undo restores text and font");
|
||||
require(textHistory.redo(texts) && texts[0].text==L"Changed" && texts[0].fontItalic,
|
||||
"redo restores text edits");
|
||||
for(auto shape:{ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse}) {
|
||||
ks::ScreenshotMark figure{{30,20},{90,80},0}; figure.shape=shape;
|
||||
if(shape==ks::ScreenshotShape::Ellipse) figure.to.x=130;
|
||||
figure.color=RGB(0,140,80); figure.thickness=4;
|
||||
auto image=ks::screenshotClip(dc,160,100,{figure});
|
||||
int mid=(figure.from.x+figure.to.x)/2;
|
||||
require(readPixel(image,mid,20)==figure.color,"shape contour renders in chosen color");
|
||||
require(readPixel(image,mid,50)==RGB(255,255,255),"shape interior remains transparent");
|
||||
require(ks::hitScreenshotMark(figure,{mid,20},2)==ks::MarkPart::Body,"shape contour is movable");
|
||||
require(ks::hitScreenshotMark(figure,{mid,50},2)==ks::MarkPart::None,"shape interior does not intercept objects");
|
||||
auto resized=ks::dragScreenshotMark(figure,ks::MarkPart::End,figure.to,{100,85},160,100);
|
||||
require(resized.shape==shape && resized.to.x==100,"shape corner can resize object");
|
||||
if(shape==ks::ScreenshotShape::Circle)
|
||||
require(resized.to.x-resized.from.x==resized.to.y-resized.from.y,"circle remains round when resized");
|
||||
auto moved=ks::dragScreenshotMark(figure,ks::MarkPart::Body,{mid,20},{mid+5,25},160,100);
|
||||
require(moved.from.x==35 && moved.to.x==figure.to.x+5,"moving shape preserves dimensions");
|
||||
auto duplicate=ks::cloneScreenshotMark(figure,{figure},160,100);
|
||||
require(duplicate.shape==shape && duplicate.color==figure.color,"cloning preserves shape and color");
|
||||
ks::ScreenshotHistory history; std::vector<ks::ScreenshotMark> figures{figure};
|
||||
history.remember(figures); figures[0]=resized;
|
||||
require(history.undo(figures) && figures[0].to.x==figure.to.x,"shape resize can be undone");
|
||||
require(history.redo(figures) && figures[0].to.x==resized.to.x,"shape resize can be redone");
|
||||
}
|
||||
auto circleCorner=ks::constrainScreenshotShape(ks::ScreenshotShape::Circle,{60,60},{0,10},160,100);
|
||||
require(circleCorner.x==0 && circleCorner.y==0,"circle supports reverse drawing and canvas boundaries");
|
||||
auto solidLine=ks::screenshotClip(dc,160,100,{line});
|
||||
require(readPixel(solidLine,28,47)==RGB(255,255,255) && readPixel(solidLine,132,47)==RGB(255,255,255),
|
||||
"plain line has no arrowheads");
|
||||
for(auto ends:{ks::LineEnds::End,ks::LineEnds::Start,ks::LineEnds::Both}) {
|
||||
line.ends=ends;
|
||||
auto image=ks::screenshotClip(dc,160,100,{line});
|
||||
require((readPixel(image,28,47)==line.color)==(ends!=ks::LineEnds::End),"start arrowhead matches ends setting");
|
||||
require((readPixel(image,132,47)==line.color)==(ends!=ks::LineEnds::Start),"end arrowhead matches ends setting");
|
||||
}
|
||||
line.ends=ks::LineEnds::None;
|
||||
std::vector<ks::Bytes> styleImages;
|
||||
ks::ScreenshotMark shaky{{20,50},{140,50},0};
|
||||
shaky.freehand=true; shaky.ends=ks::LineEnds::Both;
|
||||
shaky.points={{20,50},{19,50},{40,50},{120,50},{141,50},{140,50}};
|
||||
auto shakyImage=ks::screenshotClip(dc,160,100,{shaky});
|
||||
require(readPixel(shakyImage,28,47)==shaky.color && readPixel(shakyImage,12,47)==RGB(255,255,255),
|
||||
"start arrowhead points outward despite initial mouse jitter");
|
||||
require(readPixel(shakyImage,132,47)==shaky.color && readPixel(shakyImage,148,47)==RGB(255,255,255),
|
||||
"end arrowhead points outward despite final mouse jitter");
|
||||
for(auto style:{ks::LineStyle::Solid,ks::LineStyle::Dotted,ks::LineStyle::Dashed,ks::LineStyle::DashDot}) {
|
||||
line.style=style;
|
||||
auto image=ks::screenshotClip(dc,160,100,{line});
|
||||
int ink=0,gaps=0;
|
||||
for(int x=25;x<135;++x) { if(readPixel(image,x,50)==line.color) ++ink; else ++gaps; }
|
||||
require(ink>0 && (style==ks::LineStyle::Solid?gaps==0:gaps>0),"line pattern exports visible ink and expected gaps");
|
||||
for(auto &previous:styleImages) require(previous!=image.formats[0].data,"line styles have distinct output");
|
||||
styleImages.push_back(image.formats[0].data);
|
||||
}
|
||||
ks::ScreenshotMark curve{{20,80},{140,30},0}; curve.freehand=true; curve.ends=ks::LineEnds::None;
|
||||
curve.points={{20,80},{50,20},{90,70},{140,30}}; curve.color=RGB(0,80,220);
|
||||
auto curveImage=ks::screenshotClip(dc,160,100,{curve});
|
||||
require(!curve.isHighlight() && readPixel(curveImage,50,20)==curve.color,"freehand line exports opaque curved path");
|
||||
require(ks::hitScreenshotMark(curve,{50,20},3)==ks::MarkPart::Body,"curve hit test follows actual stroke");
|
||||
auto curveMoved=ks::dragScreenshotMark(curve,ks::MarkPart::Body,{50,20},{55,25},160,100);
|
||||
require(curveMoved.points[1].x==55 && curveMoved.points[1].y==25,"curve movement preserves all intermediate points");
|
||||
auto curveEnd=ks::dragScreenshotMark(curve,ks::MarkPart::End,curve.to,{150,40},160,100);
|
||||
require(curveEnd.points.back().x==150 && curveEnd.points.back().y==40 && curveEnd.points[1].y==20,
|
||||
"curve endpoint editing preserves intermediate shape");
|
||||
curve.style=ks::LineStyle::DashDot; curve.ends=ks::LineEnds::Both;
|
||||
auto curveCopy=ks::cloneScreenshotMark(curve,{curve},160,100);
|
||||
require(curveCopy.freehand && curveCopy.style==curve.style && curveCopy.ends==curve.ends,"curve cloning preserves style and arrowheads");
|
||||
ks::ScreenshotHistory lineHistory;
|
||||
std::vector<ks::ScreenshotMark> lines{curve}; lineHistory.remember(lines);
|
||||
lines[0].style=ks::LineStyle::Dotted; lines[0].ends=ks::LineEnds::None;
|
||||
require(lineHistory.undo(lines) && lines[0].style==ks::LineStyle::DashDot && lines[0].ends==ks::LineEnds::Both,
|
||||
"undo restores line pattern and endpoints");
|
||||
require(lineHistory.redo(lines) && lines[0].style==ks::LineStyle::Dotted && lines[0].ends==ks::LineEnds::None,
|
||||
"redo restores line pattern and endpoints");
|
||||
SelectObject(dc,old); DeleteObject(bitmap); DeleteDC(dc);
|
||||
}
|
||||
auto event=[](DWORD vk,DWORD scan=0,bool own=false) {
|
||||
KBDLLHOOKSTRUCT k{}; k.vkCode=vk; k.scanCode=scan;
|
||||
k.flags=LLKHF_INJECTED|LLKHF_LOWER_IL_INJECTED;
|
||||
@@ -79,8 +485,37 @@ int main(int argc, char **argv) {
|
||||
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
|
||||
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
|
||||
ks::showSettings(app,false);
|
||||
if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
|
||||
ks::showHistory(app,false);
|
||||
auto history=app.historyWindow;
|
||||
require(history&&!IsWindowVisible(history),"history layout tested without changing focus");
|
||||
auto childRect=[&](int id) { RECT r{}; GetWindowRect(GetDlgItem(history,id),&r); MapWindowPoints(nullptr,history,(POINT*)&r,2); return r; };
|
||||
RECT historyFrame{}; GetWindowRect(history,&historyFrame);
|
||||
auto listBefore=childRect(102),searchBefore=childRect(101),buttonBefore=childRect(110);
|
||||
SetWindowPos(history,nullptr,0,0,historyFrame.right-historyFrame.left+120,historyFrame.bottom-historyFrame.top+180,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
auto listAfter=childRect(102),searchAfter=childRect(101),buttonAfter=childRect(110);
|
||||
require(listAfter.right-listBefore.right==120 && listAfter.bottom-listBefore.bottom==180,"history list fills added width and height");
|
||||
require(searchAfter.right-searchBefore.right==120 && searchAfter.top==searchBefore.top,"history search grows horizontally only");
|
||||
require(buttonAfter.top-buttonBefore.top==180 && buttonAfter.bottom-listAfter.bottom==buttonBefore.bottom-listBefore.bottom,"history actions stay below list after resize");
|
||||
SetWindowPos(history,nullptr,0,0,historyFrame.right-historyFrame.left,historyFrame.bottom-historyFrame.top,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
|
||||
require(EqualRect(&listBefore,&(listAfter=childRect(102))),"history resize roundtrip restores geometry");
|
||||
// Reproduce a row height left over from another DPI without changing desktop settings.
|
||||
UINT historyDpi=GetDpiForWindow(history);
|
||||
for (int staleDpi : {96, 144, 192, 240}) {
|
||||
SendDlgItemMessageW(history,102,LB_SETITEMHEIGHT,0,MulDiv(88,staleDpi,96));
|
||||
SendMessageW(history,WM_DPICHANGED,MAKELONG(historyDpi,historyDpi),(LPARAM)&historyFrame);
|
||||
require(SendDlgItemMessageW(history,102,LB_GETITEMHEIGHT,0,0)==MulDiv(88,historyDpi,96),
|
||||
"DPI change updates owner-drawn history row height through layout subclass");
|
||||
require(EqualRect(&listBefore,&(listAfter=childRect(102))),"DPI refresh preserves history list geometry");
|
||||
}
|
||||
auto settings=app.settingsWindow;
|
||||
require(SendDlgItemMessageW(settings,209,BM_GETCHECK,0,0)==BST_CHECKED,"automatic screenshot editor checkbox defaults on");
|
||||
require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus");
|
||||
for(auto pair:{std::pair{209,215},std::pair{209,207},std::pair{209,220},std::pair{209,232},std::pair{207,215},std::pair{207,232},std::pair{207,220},std::pair{232,230},std::pair{230,231},std::pair{231,253}}) {
|
||||
RECT first{},second{},overlap{};
|
||||
GetWindowRect(GetDlgItem(settings,pair.first),&first); GetWindowRect(GetDlgItem(settings,pair.second),&second);
|
||||
require(!IntersectRect(&overlap,&first,&second),"capture hotkey and layout controls do not overlap");
|
||||
}
|
||||
RECT frame{},client{},cancel{};
|
||||
GetWindowRect(settings,&frame);
|
||||
MONITORINFO monitor{sizeof(monitor)};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
param([string]$Executable = "$PSScriptRoot\..\build\staged\KSwitcher.Screenshot.exe")
|
||||
$ErrorActionPreference='Stop'
|
||||
if (Get-Process KSwitcher.Screenshot -ErrorAction SilentlyContinue) { throw 'Close existing screenshot editors before this test.' }
|
||||
Add-Type @'
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
public static class ScreenshotProcessTest {
|
||||
public delegate bool EnumProc(IntPtr w, IntPtr p);
|
||||
[DllImport("user32.dll")] static extern bool EnumWindows(EnumProc callback, IntPtr param);
|
||||
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr w, out uint pid);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr w, StringBuilder name, int count);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetWindowText(IntPtr w, StringBuilder name, int count);
|
||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w, uint msg, IntPtr wp, IntPtr lp);
|
||||
public static IntPtr[] Windows(int process, string cls) {
|
||||
var windows=new List<IntPtr>();
|
||||
EnumWindows((w,p)=>{ uint pid; GetWindowThreadProcessId(w,out pid);
|
||||
var name=new StringBuilder(256); GetClassName(w,name,256);
|
||||
if(pid==process && name.ToString()==cls) windows.Add(w); return true;
|
||||
},IntPtr.Zero); return windows.ToArray();
|
||||
}
|
||||
public static string Title(IntPtr w) { var s=new StringBuilder(256); GetWindowText(w,s,256); return s.ToString(); }
|
||||
}
|
||||
'@
|
||||
function Wait-For([scriptblock]$Check,[string]$Message) {
|
||||
for($attempt=0;$attempt -lt 100;$attempt++) { if (& $Check) { return }; Start-Sleep -Milliseconds 50 }
|
||||
throw $Message
|
||||
}
|
||||
function Send($Window,$Message,$Wparam=0,$Lparam=0) {
|
||||
[ScreenshotProcessTest]::PostMessage($Window,$Message,[IntPtr]$Wparam,[IntPtr]$Lparam) | Out-Null
|
||||
}
|
||||
function Capture-Windows { @([ScreenshotProcessTest]::Windows($script:process.Id,'KSwitcher.Capture')) }
|
||||
function Select-Region($Window) {
|
||||
Send $Window 0x201 1 ((40 -shl 16) -bor 40)
|
||||
Send $Window 0x200 1 ((240 -shl 16) -bor 360)
|
||||
Send $Window 0x202 0 ((240 -shl 16) -bor 360)
|
||||
Wait-For { (Capture-Windows) -notcontains $Window } 'Selection did not close after copying (notifications may be disabled).'
|
||||
}
|
||||
function Close-Editor($Window) {
|
||||
Send $Window 0x10
|
||||
Wait-For { (Capture-Windows) -notcontains $Window } 'Unedited copied screenshot did not close.'
|
||||
}
|
||||
function Latest-Token {
|
||||
(Get-ChildItem -LiteralPath (Join-Path $env:LOCALAPPDATA 'KSwitcher/ScreenshotNotifications') -Filter '*.capture' | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1).BaseName
|
||||
}
|
||||
$process=$null
|
||||
try {
|
||||
$process=Start-Process -FilePath $Executable -ArgumentList @("--auto-open-editor=0") -WindowStyle Hidden -PassThru
|
||||
Wait-For { (Capture-Windows).Count -eq 1 } 'First selection did not open.'
|
||||
Select-Region (Capture-Windows)[0]
|
||||
if (!$process.WaitForExit(5000)) { throw 'Process remained alive after notification.' }
|
||||
$token=Latest-Token
|
||||
$process=Start-Process -FilePath $Executable -ArgumentList @('--open',"kswitcher-screenshot:$token") -WindowStyle Hidden -PassThru
|
||||
Wait-For { (Capture-Windows).Count -eq 1 } 'Notification activation did not open an editor.'
|
||||
$first=(Capture-Windows)[0]
|
||||
$secondLaunch=Start-Process -FilePath $Executable -ArgumentList @('--open',"kswitcher-screenshot:$token") -WindowStyle Hidden -PassThru
|
||||
if (!$secondLaunch.WaitForExit(10000) -or $secondLaunch.ExitCode -ne 0) { throw 'Second activation did not hand off.' }
|
||||
Wait-For { (Capture-Windows).Count -eq 2 } 'Second editor did not open in same process.'
|
||||
$second=(Capture-Windows | Where-Object { $_ -ne $first })[0]
|
||||
if ([ScreenshotProcessTest]::Title($first) -eq [ScreenshotProcessTest]::Title($second)) { throw 'Editor indices collide.' }
|
||||
Close-Editor $first
|
||||
if ($process.HasExited) { throw 'Process exited with an editor open.' }
|
||||
Close-Editor $second
|
||||
if (!$process.WaitForExit(5000) -or $process.ExitCode -ne 0) { throw 'Last editor did not exit process.' }
|
||||
$process=Start-Process -FilePath $Executable -ArgumentList @("--auto-open-editor=0") -WindowStyle Hidden -PassThru
|
||||
Wait-For { (Capture-Windows).Count -eq 1 } 'New process did not open selection.'
|
||||
Send (Capture-Windows)[0] 0x100 27 # Esc cancels a selection only.
|
||||
if (!$process.WaitForExit(5000) -or $process.ExitCode -ne 0) { throw 'Cancelling selection did not exit process.' }
|
||||
'Passed screenshot process lifecycle checks'
|
||||
} finally {
|
||||
if ($process -and !$process.HasExited) { Stop-Process -Id $process.Id }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Redirect HKCU and the shortcut destination; never replace the user's registration.
|
||||
#include "../src/screenshot_notifications.h"
|
||||
#include <shlobj.h>
|
||||
#include <iostream>
|
||||
static std::wstring programs;
|
||||
static HRESULT testKnownFolder(REFKNOWNFOLDERID, DWORD, HANDLE, PWSTR *out)
|
||||
{
|
||||
*out=(PWSTR)CoTaskMemAlloc((programs.size()+1)*sizeof(wchar_t));
|
||||
if(!*out) return E_OUTOFMEMORY;
|
||||
wcscpy(*out,programs.c_str()); return S_OK;
|
||||
}
|
||||
#define SHGetKnownFolderPath testKnownFolder
|
||||
#include "../src/screenshot_activation.cpp"
|
||||
#undef SHGetKnownFolderPath
|
||||
int main()
|
||||
{
|
||||
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
|
||||
auto suffix=std::to_wstring(GetCurrentProcessId());
|
||||
auto keyPath=L"Software\\KSwitcherRegistrationTest-"+suffix;
|
||||
auto directory=std::filesystem::temp_directory_path()/(L"KSwitcherRegistrationTest-"+suffix);
|
||||
std::filesystem::create_directories(directory); programs=directory.wstring();
|
||||
HKEY isolated{}; int result=0;
|
||||
try {
|
||||
if(RegCreateKeyExW(HKEY_CURRENT_USER,keyPath.c_str(),0,nullptr,0,KEY_ALL_ACCESS,nullptr,&isolated,nullptr)!=ERROR_SUCCESS ||
|
||||
RegOverridePredefKey(HKEY_CURRENT_USER,isolated)!=ERROR_SUCCESS) throw std::runtime_error("registry isolation failed");
|
||||
ks::registerScreenshotNotifications();
|
||||
wchar_t command[32768]{}; DWORD size=sizeof(command);
|
||||
auto path=L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32";
|
||||
if(RegGetValueW(HKEY_CURRENT_USER,path,nullptr,RRF_RT_REG_SZ,nullptr,command,&size)!=ERROR_SUCCESS)
|
||||
throw std::runtime_error("COM server was not registered");
|
||||
auto helper=std::filesystem::path(ks::executable()).parent_path()/L"KSwitcher.Screenshot.exe";
|
||||
if(command!=L"\""+helper.wstring()+L"\" --toast-activated") throw std::runtime_error("COM server targets wrong executable");
|
||||
Microsoft::WRL::ComPtr<IShellLinkW> link;
|
||||
if(FAILED(CoCreateInstance(CLSID_ShellLink,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&link)))) throw std::runtime_error("read shortcut");
|
||||
Microsoft::WRL::ComPtr<IPersistFile> file; link.As(&file);
|
||||
if(FAILED(file->Load((directory/L"KSwitcher Screenshot.lnk").c_str(),STGM_READ))) throw std::runtime_error("shortcut missing");
|
||||
wchar_t target[32768]{}; link->GetPath(target,32768,nullptr,SLGP_RAWPATH);
|
||||
if(target!=helper.wstring()) throw std::runtime_error("shortcut targets wrong executable");
|
||||
Microsoft::WRL::ComPtr<IPropertyStore> properties; link.As(&properties);
|
||||
PROPVARIANT id{}; properties->GetValue(PKEY_AppUserModel_ID,&id);
|
||||
bool valid=id.vt==VT_LPWSTR && std::wstring(id.pwszVal)==L"HrynCo.KSwitcher.Screenshot";
|
||||
PropVariantClear(&id);
|
||||
if(!valid) throw std::runtime_error("shortcut identity missing");
|
||||
properties->GetValue(PKEY_AppUserModel_ToastActivatorCLSID,&id);
|
||||
valid=id.vt==VT_CLSID && *id.puuid==ks::ActivatorId; PropVariantClear(&id);
|
||||
if(!valid) throw std::runtime_error("shortcut activator missing");
|
||||
ks::registerScreenshotNotifications(); // Registration is safe to repeat.
|
||||
std::cout<<"Passed isolated screenshot registration checks\n";
|
||||
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; result=1; }
|
||||
RegOverridePredefKey(HKEY_CURRENT_USER,nullptr);
|
||||
if(isolated) RegCloseKey(isolated);
|
||||
RegDeleteTreeW(HKEY_CURRENT_USER,keyPath.c_str());
|
||||
std::filesystem::remove_all(directory);
|
||||
CoUninitialize(); return result;
|
||||
}
|
||||