Compare commits

...

5 Commits

28 changed files with 330 additions and 109 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.0 LANGUAGES CXX RC) project(KSwitcher VERSION 0.6.1 LANGUAGES CXX RC)
if(NOT WIN32) if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs") message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif() endif()
+22 -7
View File
@@ -4,7 +4,11 @@ Native C++20 / Win32 rewrite of the Windows functionality of [Typlo](https://git
No .NET, Avalonia, WebView, Qt, external graphics engine, or separately installed C++ runtime. No .NET, Avalonia, WebView, Qt, external graphics engine, or separately installed C++ runtime.
The original [Typlo](https://github.com/bitcodepro/Typlo) project and its data remain separate. The original [Typlo](https://github.com/bitcodepro/Typlo) project and its data remain separate.
**[User guide / Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki)** — setup, features, shortcuts, dictionaries, screenshots and troubleshooting (Ukrainian). **[User guide / Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki)** — setup, features, shortcuts, dictionaries, screenshots and troubleshooting in **English** and **[Українська](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Home-uk)**. Each wiki page links to its translation.
[![Support KSwitcher development — buy the developer a coffee](resources/support/donate-en.png)](https://send.monobank.ua/jar/88h2TddKfS)
[Donate via Monobank](https://send.monobank.ua/jar/88h2TddKfS) · Thank you for supporting KSwitcher.
## Run ## Run
@@ -71,9 +75,15 @@ physical key positions and therefore reset recent-word tracking; selection conve
Press **Win+Shift+S**, choose **Capture screen region…** in the tray menu, or run 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 `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. at normal brightness. Escape cancels. Selection and annotation frames are buffered to avoid flicker.
The selected area is copied immediately and Windows shows a notification with a thumbnail. The selected area is copied immediately, the fullscreen selection is hidden, and a Windows notification
Click the notification to open the editor, including after the screenshot process has exited. is sent. Click the notification to open the editor. Enable **Open editor after screen capture** in settings
If notifications are disabled or cannot be sent, the already-copied screenshot opens in the editor. to open it automatically; this option is off by default. An immediate notification error still opens the editor.
Existing saved preferences are preserved. Windows Do Not Disturb can send notifications directly to the
notification center without showing a banner.
The preference applies to each new capture, including while other editors are open.
KSwitcher also sends a notification with a thumbnail. Notification identity and activation are registered
at resident startup and refreshed before sending; registration/submission errors are written to the log.
Click the notification to reopen the original screenshot, including after the screenshot process has exited.
Originals in the local notification cache are DPAPI-encrypted. Thumbnail PNGs live in a separate 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 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. broker can load them even when the profile directory is inaccessible to it.
@@ -150,7 +160,9 @@ Captures use ordinary clipboard history settings and limits. Protected video and
Select **Update** in the tray menu to check the stable Releases of Select **Update** in the tray menu to check the stable Releases of
[this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases). [this repository](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/releases).
A startup check also reports an available version, but never installs it automatically. A background check runs shortly after startup and every 6 hours while KSwitcher is running.
It reports each available version once per session and never installs automatically.
Network failures stay in diagnostics; the next scheduled check retries. You can also check manually at any time.
Confirm the offered version to download and install it. The updater uses the existing Confirm the offered version to download and install it. The updater uses the existing
`KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64 `KSwitcher-win-x64.zip` and `SHA256SUMS.txt` assets, checks the checksum, package contents, x64
executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen executable identity and version, then closes and restarts KSwitcher. Downloads and staging happen
@@ -169,6 +181,8 @@ controls and fonts. Settings has a fixed form size, without manual resizing or m
utility windows remain resizable. Windows stay within a remaining monitor's work area after display 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 changes and provide scrollbars when their content does not fit. Tab navigation scrolls the focused
field into view; Shift+wheel scrolls horizontally. field into view; Shift+wheel scrolls horizontally.
Clipboard history also updates its preview row heights when DPI changes, keeping text,
thumbnails and source labels aligned with the scaled fonts and spacing.
## Implemented ## Implemented
@@ -201,8 +215,9 @@ field into view; Shift+wheel scrolls horizontally.
- Single instance and `--history`, `--convert`, `--case`, `--transliterate`, `--settings`, - Single instance and `--history`, `--convert`, `--case`, `--transliterate`, `--settings`,
`--snippets`, `--exit` commands. `--convert` cycles layouts. `--snippets`, `--exit` commands. `--convert` cycles layouts.
Default shortcuts match [Typlo](https://github.com/bitcodepro/Typlo): Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration), Default text shortcuts match [Typlo](https://github.com/bitcodepro/Typlo): Ctrl+Alt+Shift+Space (cycle), C (case), T (transliteration),
Z (undo), 1/2/3 (first layouts), and Ctrl+Shift+V (history). Z (undo), and 1/2/3 (first layouts). Clipboard history defaults to **Win+V** for new settings;
existing saved shortcuts are preserved. While KSwitcher runs, this binding opens its history instead of Windows clipboard history.
History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste, History controls: Enter to paste, Shift+Enter for plain text, Ctrl+1…9 for quick paste,
Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide. Ctrl+P to pin/unpin, Delete to delete, Ctrl+O for the full item, Escape to hide.
Binary file not shown.
+19 -21
View File
@@ -1,28 +1,26 @@
# Latest Windows release # Latest Windows release
## v0.4.1 — 2026-10-28 ## v0.6.3 — 2026-10-29
- Fix Settings spacing around Screen capture, automatic layout correction and layout controls. - Hide the fullscreen capture selection before submitting a notification when automatic editor opening is disabled. Previously the selection remained visible during submission, which could suppress the banner under Windows fullscreen notification rules.
- Verify update checksums through .NET SHA-256 without requiring the PowerShell Get-FileHash command. This fixes updates from processes launched with a conflicting PowerShell module environment. - Default to capture → notification → click → editor. **Open editor after screen capture** is now off by default; explicitly saved preferences are preserved.
- Capture a rectangular screen region with Win+Shift+S (configurable), with dimmed selection and independent editor windows. - Retain the editor fallback for immediate notification errors.
- Annotate screenshots with numbered markers, arrows, straight/freehand lines, highlights, rectangles, circles, ellipses and inline text. - Check for updates in the background every 6 hours after the startup check. Notify once per version per session, retry after network failures at the next interval, and install only after confirmation.
- Edit color, line style, arrowheads, thickness, font and size. Link arrow endpoints to markers and attach captions.
- Captions follow line angle and keep their relative offset until manually rotated. Text rotation supports Shift snapping in 15-degree steps.
- Move objects one pixel with arrow keys; clone with Ctrl+D; undo/redo edits. Numbered markers reuse gaps.
- Compact Lucide toolbar, contextual status bar, pencil cursor and confirmation before discarding an uncopied screenshot. Escape no longer closes the editor.
- Preserve the separate on-demand configurator and configurable capture shortcut.
Verified: 173 native checks, 379 input/rendering checks, screenshot editor UI integration checks, Verified: 176 native checks, 403 input/layout checks at 240 DPI, screenshot editor UI integration,
29 updater checks, including missing Get-FileHash coverage, and package validation. isolated notification registration checks, and 45 updater checks. The new UI regression test failed
before the fix and passes afterward: the selection is hidden at notification submission.
Banner behavior still needs confirmation on the affected laptop. Windows Do Not Disturb can
send notifications directly to the notification center; asynchronous toast failures are not yet monitored.
**Upgrade from v0.3.0 or v0.4.0:** use Update in the tray menu. Both executables are supported. **Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the
If an older updater reports missing `Get-FileHash`, restart KSwitcher normally from Explorer v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
(outside a PowerShell 7 session) or install this ZIP manually once. v0.4.1 removes that dependency. For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder.
**Upgrade from v0.2.3 or earlier:** install manually once because those updaters reject the additional configurator EXE. Keep personal `data/` and customized dictionaries.
Exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains both executables, Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains the resident,
offline dictionaries, documentation and license notices; no personal settings or clipboard history. configurator and screenshot executables, a release manifest, offline dictionaries, documentation
and license notices; no personal settings or clipboard history.
Build and package: Build and package:
@@ -31,5 +29,5 @@ Build and package:
.\package.ps1 -PublishRelease .\package.ps1 -PublishRelease
``` ```
Commit the archive, checksum and sources together. Publish both assets on a stable release whose Commit the archive, checksum and sources together. Publish both assets on a stable
tag matches the executable version. `release-vX.Y.Z` tag matching the executable version. Keep `v0.4.2` as the last legacy tag.
+1 -1
View File
@@ -1 +1 @@
e1d11c833adab5190ec49af1bb4eb25babdd6658aafb7423f9c502a1e71b2581 KSwitcher-win-x64.zip a43786b06878da0f069dbad0fa29587a736dd2d8904c7bc991f5cf0c0570a16f KSwitcher-win-x64.zip
+8
View File
@@ -51,6 +51,14 @@ try {
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' } if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
& .\build\KSwitcherScreenshotUITests.exe & .\build\KSwitcherScreenshotUITests.exe
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' } if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
try {
& $compiler @common tests/screenshot_registration.cpp build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
if ($LASTEXITCODE) { throw 'Screenshot registration test build failed' }
& $registrationTest
if ($LASTEXITCODE) { throw 'Screenshot registration tests failed' }
} finally { Remove-Item -LiteralPath $registrationTest -ErrorAction SilentlyContinue }
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath & "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/update.ps1 -Executable $executablePath
if ($LASTEXITCODE) { throw 'Updater tests failed' } if ($LASTEXITCODE) { throw 'Updater tests failed' }
} }
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest" 1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1" 201 RCDATA "update.ps1"
1 VERSIONINFO 1 VERSIONINFO
FILEVERSION 0,6,0,0 FILEVERSION 0,6,3,0
PRODUCTVERSION 0,6,0,0 PRODUCTVERSION 0,6,3,0
FILEOS VOS_NT_WINDOWS32 FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP FILETYPE VFT_APP
BEGIN BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher" VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.0" VALUE "FileVersion", "0.6.3"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="330" viewBox="0 0 960 330">
<rect x="1" y="1" width="958" height="328" rx="24" fill="#F5F3FF" stroke="#DDD6FE" stroke-width="2"/>
<g font-family="Segoe UI" fill="#241B46">
<text x="40" y="58" font-size="20" font-weight="600" fill="#4F46E5">KSwitcher / Support development</text>
<text x="40" y="115" font-size="35" font-weight="700">KSwitcher part of your day?</text>
<text x="40" y="159" font-size="35" font-weight="700">Buy the developer a coffee.</text>
<text x="40" y="202" font-size="20" fill="#655B7C">Your donation helps the tool keep growing.</text>
<rect x="40" y="238" width="350" height="54" rx="12" fill="#4F46E5"/>
<text x="215" y="272" text-anchor="middle" font-size="20" font-weight="600" fill="white">Support via monobank</text>
</g>
<g transform="translate(759 48) rotate(10 66 66)">
<rect y="8" width="132" height="132" rx="26" fill="#C4B5FD"/>
<rect width="132" height="132" rx="26" fill="#4F46E5"/>
<g transform="translate(14 14) scale(3.25)"><path d="M8 7H12V25H8ZM11 15L22 6L25 9L14 18Z" fill="white"/><path d="M11 14L25 24L22 27L10 18Z" fill="#5EEAD4"/></g>
</g>
<g transform="translate(699 172) rotate(-8 67 60)">
<rect y="7" width="142" height="115" rx="22" fill="#C4B5FD"/>
<rect width="142" height="115" rx="22" fill="white" stroke="#DDD6FE" stroke-width="2"/>
<g stroke="#4F46E5" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M43 41H92V66Q92 83 75 83H60Q43 83 43 66Z"/>
<path d="M92 46H99Q115 46 111 58Q108 67 92 66M37 92H100M56 28V18M69 28V18M82 28V18"/>
</g></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="330" viewBox="0 0 960 330">
<rect x="1" y="1" width="958" height="328" rx="24" fill="#F5F3FF" stroke="#DDD6FE" stroke-width="2"/>
<g font-family="Segoe UI" fill="#241B46">
<text x="40" y="58" font-size="20" font-weight="600" fill="#4F46E5">KSwitcher / Підтримати розробку</text>
<text x="40" y="115" font-size="35" font-weight="700">KSwitcher щодня під рукою?</text>
<text x="40" y="159" font-size="35" font-weight="700">Пригостіть розробника кавою.</text>
<text x="40" y="202" font-size="20" fill="#655B7C">Ваш донат допомагає розвивати інструмент.</text>
<rect x="40" y="238" width="350" height="54" rx="12" fill="#4F46E5"/>
<text x="215" y="272" text-anchor="middle" font-size="20" font-weight="600" fill="white">Підтримати через monobank</text>
</g>
<g transform="translate(759 48) rotate(10 66 66)">
<rect y="8" width="132" height="132" rx="26" fill="#C4B5FD"/>
<rect width="132" height="132" rx="26" fill="#4F46E5"/>
<g transform="translate(14 14) scale(3.25)"><path d="M8 7H12V25H8ZM11 15L22 6L25 9L14 18Z" fill="white"/><path d="M11 14L25 24L22 27L10 18Z" fill="#5EEAD4"/></g>
</g>
<g transform="translate(699 172) rotate(-8 67 60)">
<rect y="7" width="142" height="115" rx="22" fill="#C4B5FD"/>
<rect width="142" height="115" rx="22" fill="white" stroke="#DDD6FE" stroke-width="2"/>
<g stroke="#4F46E5" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M43 41H92V66Q92 83 75 83H60Q43 83 43 66Z"/>
<path d="M92 46H99Q115 46 111 58Q108 67 92 66M37 92H100M56 28V18M69 28V18M82 28V18"/>
</g></g></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+8 -3
View File
@@ -1254,8 +1254,9 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
if (MessageBoxW(hwnd, prompt.c_str(), L"KSwitcher Update", MB_YESNO | MB_ICONQUESTION) == IDYES) if (MessageBoxW(hwnd, prompt.c_str(), L"KSwitcher Update", MB_YESNO | MB_ICONQUESTION) == IDYES)
app->checkUpdate(true, true, result->version); app->checkUpdate(true, true, result->version);
} }
else else if (app->notifiedUpdate != result->version)
{ {
app->notifiedUpdate = result->version;
NOTIFYICONDATAW icon{sizeof(icon)}; NOTIFYICONDATAW icon{sizeof(icon)};
icon.hWnd = hwnd; icon.uID = 1; icon.uFlags = NIF_INFO; icon.dwInfoFlags = NIIF_INFO; icon.hWnd = hwnd; icon.uID = 1; icon.uFlags = NIF_INFO; icon.dwInfoFlags = NIIF_INFO;
wcscpy_s(icon.szInfoTitle, L"KSwitcher update available"); wcscpy_s(icon.szInfoTitle, L"KSwitcher update available");
@@ -1274,7 +1275,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
else if (result->interactive) else if (result->interactive)
MessageBoxW(hwnd, (L"Update failed.\n\n" + result->error).c_str(), L"KSwitcher Update", MB_OK | MB_ICONERROR); MessageBoxW(hwnd, (L"Update failed.\n\n" + result->error).c_str(), L"KSwitcher Update", MB_OK | MB_ICONERROR);
else else
diagnosticEvent(L"Startup update check failed: " + result->error); diagnosticEvent(L"Background update check failed: " + result->error);
return 0; return 0;
} }
case WM_HOTKEY: case WM_HOTKEY:
@@ -1338,7 +1339,9 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
case WM_TIMER: case WM_TIMER:
if (wp == 5) if (wp == 5)
{ {
KillTimer(hwnd, 5); // After the initial 5-second check, repeat while the resident stays open.
// Rearm even when a check is already running or the previous one failed.
SetTimer(hwnd, 5, UpdateCheckIntervalMs, nullptr);
app->checkUpdate(false); app->checkUpdate(false);
return 0; return 0;
} }
@@ -1453,6 +1456,8 @@ int App::execute()
confirmUpdatedStartup(updateStartupEvent); confirmUpdatedStartup(updateStartupEvent);
if (startCommand) if (startCommand)
PostMessageW(window, WM_HOTKEY, startCommand, 0); PostMessageW(window, WM_HOTKEY, startCommand, 0);
try { registerScreenshotNotifications(); }
catch(const std::exception &e) { log(L"Screenshot notification registration: "+wide(e.what())); }
auto screenshotActivation=startScreenshotActivationListener(); auto screenshotActivation=startScreenshotActivationListener();
MSG msg; MSG msg;
while (GetMessageW(&msg, nullptr, 0, 0) > 0) while (GetMessageW(&msg, nullptr, 0, 0) > 0)
+1 -1
View File
@@ -85,7 +85,7 @@ class App
std::thread worker; std::thread worker;
std::thread updateWorker; std::thread updateWorker;
bool updateRunning{}, installingUpdate{}; bool updateRunning{}, installingUpdate{};
std::wstring availableUpdate; std::wstring availableUpdate, notifiedUpdate;
bool stop{}; bool stop{};
UINT taskbarCreated{}; UINT taskbarCreated{};
uint64_t pendingAutoVersion{}; uint64_t pendingAutoVersion{};
+2 -1
View File
@@ -48,6 +48,7 @@ struct Settings
bool elevatedAutostart = false; bool elevatedAutostart = false;
bool autoCorrect = false; bool autoCorrect = false;
bool writeLog = true; bool writeLog = true;
bool screenshotAutoOpenEditor = false;
int autoMaxRank = 0; int autoMaxRank = 0;
int autoMinLength = 3; int autoMinLength = 3;
std::wstring autoPrefixChars = L"([{«“‘\""; std::wstring autoPrefixChars = L"([{«“‘\"";
@@ -59,7 +60,7 @@ struct Settings
L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"}; L"Far", L"ConEmu64", L"putty", L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
int maxItems = 500, maxMiB = 200, maxImageMiB = 20; int maxItems = 500, maxMiB = 200, maxImageMiB = 20;
std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C", std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C",
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Ctrl+Shift+V", translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Win+V",
screenshot = L"Win+Shift+S"; screenshot = L"Win+Shift+S";
std::vector<std::wstring> cycle, direct, directIds; std::vector<std::wstring> cycle, direct, directIds;
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"}; std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
+11 -3
View File
@@ -127,6 +127,7 @@ struct Capture
std::unique_ptr<Surface> image; std::unique_ptr<Surface> image;
std::unique_ptr<Surface> dimmed, frame; std::unique_ptr<Surface> dimmed, frame;
bool editing{}, dragging{}, arrow{}, owned{}, copied{}; bool editing{}, dragging{}, arrow{}, owned{}, copied{};
bool autoOpenEditor;
bool highlight{}; bool highlight{};
bool freehand=true; bool freehand=true;
bool straight{}, textTool{}; bool straight{}, textTool{};
@@ -161,7 +162,7 @@ struct Capture
int hoveredTool=0; int hoveredTool=0;
MarkPart hoveredPart=MarkPart::None; MarkPart hoveredPart=MarkPart::None;
std::wstring statusText; std::wstring statusText;
explicit Capture(ScreenshotHost &a) : app(a) {} explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {}
~Capture() { ~Capture() {
if(pencilCursor) DestroyCursor(pencilCursor); if(pencilCursor) DestroyCursor(pencilCursor);
if (font) DeleteObject(font); if (font) DeleteObject(font);
@@ -812,10 +813,17 @@ void finishSelection(HWND w,Capture &c,DWORD (*publish)(HWND,const Clip &)=write
auto clip=screenshotClip(cropped.dc,cropped.width,cropped.height,{}); auto clip=screenshotClip(cropped.dc,cropped.width,cropped.height,{});
publish(c.app.window,clip); publish(c.app.window,clip);
c.copied=true; c.copied=true;
ShowWindow(w,SW_HIDE); // A successful toast submission does not guarantee that Windows shows a banner.
if(c.app.notify(clip)) DestroyWindow(w); if(c.autoOpenEditor) edit(w,c);
// Leave fullscreen capture before submitting the toast: Windows may suppress
// banners while a fullscreen application is visible.
else ShowWindow(w,SW_HIDE);
bool notified=c.app.notify(clip);
if(!c.autoOpenEditor) {
if(notified) DestroyWindow(w);
else edit(w,c); else edit(w,c);
} }
}
LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{ {
auto c = reinterpret_cast<Capture *>(GetWindowLongPtrW(w,GWLP_USERDATA)); auto c = reinterpret_cast<Capture *>(GetWindowLongPtrW(w,GWLP_USERDATA));
+1
View File
@@ -8,6 +8,7 @@ struct ScreenshotHost {
HWND window{}; HWND window{};
unsigned windows{}; unsigned windows{};
bool (*notify)(const Clip &){}; bool (*notify)(const Clip &){};
bool autoOpenEditor = false;
}; };
inline constexpr UINT ScreenshotRequest=WM_APP+71, ScreenshotIdle=WM_APP+72; inline constexpr UINT ScreenshotRequest=WM_APP+71, ScreenshotIdle=WM_APP+72;
void showScreenshot(ScreenshotHost &); void showScreenshot(ScreenshotHost &);
+45
View File
@@ -1,10 +1,55 @@
#include "screenshot_notifications.h" #include "screenshot_notifications.h"
#include <atomic> #include <atomic>
#include <shlobj.h>
#include <propkey.h>
#include <propvarutil.h>
#include <wrl/client.h>
namespace ks { namespace ks {
namespace { namespace {
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot"; constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
constexpr GUID ActivatorId{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}}; 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) bool validScreenshotToken(const std::wstring &id)
{ {
if(id.size()!=36) return false; if(id.size()!=36) return false;
+4 -2
View File
@@ -1,12 +1,14 @@
#include "app.h" #include "app.h"
namespace ks { namespace ks {
void launchScreenshot(App &) void launchScreenshot(App &app)
{ {
auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe"; auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe";
if (!std::filesystem::exists(path)) if (!std::filesystem::exists(path))
throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package."); throw std::runtime_error("KSwitcher.Screenshot.exe is missing. Extract the complete application package.");
auto command=L"\""+path.wstring()+L"\""; 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)}; STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{}; PROCESS_INFORMATION process{};
// Grant foreground rights before the child's first selection window opens. // Grant foreground rights before the child's first selection window opens.
+13 -3
View File
@@ -14,6 +14,8 @@ LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM lp)
SetWindowLongPtrW(window,GWLP_USERDATA,(LONG_PTR)host); SetWindowLongPtrW(window,GWLP_USERDATA,(LONG_PTR)host);
} }
if(host && message==ks::ScreenshotRequest) { if(host && message==ks::ScreenshotRequest) {
host->autoOpenEditor=(wp&1)!=0;
ks::setLoggingEnabled((wp&2)!=0);
try { ks::showScreenshot(*host); } try { ks::showScreenshot(*host); }
catch(const std::exception &e) { catch(const std::exception &e) {
MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screen capture",MB_OK|MB_ICONERROR); MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screen capture",MB_OK|MB_ICONERROR);
@@ -45,7 +47,12 @@ LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM lp)
int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int) int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
{ {
std::wstring token; std::wstring token;
bool autoOpenEditor=false, writeLog=false;
int argc{}; auto argv=CommandLineToArgvW(GetCommandLineW(),&argc); 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") { if(argc>=2 && std::wstring(argv[1])==L"--toast-activated") {
LocalFree(argv); return ks::runScreenshotToastActivator(); LocalFree(argv); return ks::runScreenshotToastActivator();
} }
@@ -54,7 +61,10 @@ int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
int result=0; try { ks::registerScreenshotNotifications(); } catch(...) { result=1; } int result=0; try { ks::registerScreenshotNotifications(); } catch(...) { result=1; }
CoUninitialize(); return result; CoUninitialize(); return result;
} }
if(argc>1) { 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") { if(argc==3 && std::wstring(argv[1])==L"--open") {
std::wstring uri=argv[2]; const std::wstring prefix=L"kswitcher-screenshot:"; std::wstring uri=argv[2]; const std::wstring prefix=L"kswitcher-screenshot:";
if(uri.starts_with(prefix)) token=uri.substr(prefix.size()); if(uri.starts_with(prefix)) token=uri.substr(prefix.size());
@@ -74,7 +84,7 @@ int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
DWORD pid{}; GetWindowThreadProcessId(existing,&pid); DWORD pid{}; GetWindowThreadProcessId(existing,&pid);
AllowSetForegroundWindow(pid); AllowSetForegroundWindow(pid);
DWORD_PTR accepted{}; DWORD_PTR accepted{};
if(SendMessageTimeoutW(existing,token.empty()?ks::ScreenshotRequest:WM_COPYDATA,0, 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) { token.empty()?0:(LPARAM)&open,SMTO_ABORTIFHUNG,2000,&accepted) && accepted) {
CloseHandle(mutex); return 0; CloseHandle(mutex); return 0;
} }
@@ -95,7 +105,7 @@ int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int)
// Toast protocol activation may run unelevated while the editor was launched by an elevated resident. // 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. // WM_COPYDATA accepts only a validated cache GUID, never a path or executable command.
ChangeWindowMessageFilterEx(host.window,WM_COPYDATA,MSGFLT_ALLOW,nullptr); ChangeWindowMessageFilterEx(host.window,WM_COPYDATA,MSGFLT_ALLOW,nullptr);
if(token.empty()) PostMessageW(host.window,ks::ScreenshotRequest,0,0); if(token.empty()) PostMessageW(host.window,ks::ScreenshotRequest,(autoOpenEditor?1:0)|(writeLog?2:0),0);
else SendMessageW(host.window,WM_COPYDATA,0,(LPARAM)&open); else SendMessageW(host.window,WM_COPYDATA,0,(LPARAM)&open);
MSG message{}; MSG message{};
while((result=GetMessageW(&message,nullptr,0,0))>0) { while((result=GetMessageW(&message,nullptr,0,0))>0) {
+4 -39
View File
@@ -26,7 +26,6 @@ using Microsoft::WRL::ComPtr;
namespace Notifications=ABI::Windows::UI::Notifications; namespace Notifications=ABI::Windows::UI::Notifications;
namespace Xml=ABI::Windows::Data::Xml::Dom; namespace Xml=ABI::Windows::Data::Xml::Dom;
constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot"; constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot";
constexpr GUID ActivatorId{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}};
std::string notificationError; 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); } } 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 { struct String {
@@ -91,41 +90,6 @@ void cleanCache(const std::filesystem::path &path)
} }
} }
} }
void registryString(const std::wstring &path,const wchar_t *name,const std::wstring &value)
{
HKEY key{};
if(RegCreateKeyExW(HKEY_CURRENT_USER,path.c_str(),0,nullptr,0,KEY_SET_VALUE,nullptr,&key,nullptr)!=ERROR_SUCCESS)
throw std::runtime_error("Cannot register screenshot notifications");
auto result=RegSetValueExW(key,name,0,REG_SZ,(const BYTE *)value.c_str(),DWORD((value.size()+1)*sizeof(wchar_t)));
RegCloseKey(key);
if(result!=ERROR_SUCCESS) throw std::runtime_error("Cannot register screenshot notification activation");
}
void registerNotifications()
{
auto exe=executable();
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",nullptr,L"\""+exe+L"\" --toast-activated");
registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",L"ServerExecutable",exe);
auto identity=std::wstring(L"Software\\Classes\\AppUserModelId\\")+AppId;
registryString(identity,L"DisplayName",L"KSwitcher Screenshot");
registryString(identity,L"IconUri",exe);
registryString(identity,L"CustomActivator",L"{e3f78047-f0ad-49b6-9074-411931706167}");
// A shortcut supplies desktop notification identity and its COM activation callback.
ComPtr<IShellLinkW> link; check(CoCreateInstance(CLSID_ShellLink,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&link)));
check(link->SetPath(exe.c_str())); check(link->SetIconLocation(exe.c_str(),0));
ComPtr<IPropertyStore> properties; check(link.As(&properties));
PROPVARIANT id{}; check(InitPropVariantFromString(AppId,&id));
auto result=properties->SetValue(PKEY_AppUserModel_ID,id); PropVariantClear(&id); check(result);
GUID activator=ActivatorId;
PROPVARIANT clsid{}; clsid.vt=VT_CLSID; clsid.puuid=&activator;
check(properties->SetValue(PKEY_AppUserModel_ToastActivatorCLSID,clsid));
check(properties->Commit());
PWSTR programs{}; check(SHGetKnownFolderPath(FOLDERID_Programs,0,nullptr,&programs));
auto shortcut=std::filesystem::path(programs)/L"KSwitcher Screenshot.lnk"; CoTaskMemFree(programs);
ComPtr<IPersistFile> file; check(link.As(&file)); check(file->Save(shortcut.c_str(),TRUE));
// The notification broker caches protocol handlers independently of ShellExecute.
SHChangeNotify(SHCNE_ASSOCCHANGED,SHCNF_IDLIST,nullptr,nullptr);
check(SetCurrentProcessExplicitAppUserModelID(AppId));
}
void savePreview(const Clip &clip,const std::filesystem::path &path) void savePreview(const Clip &clip,const std::filesystem::path &path)
{ {
auto pixels=decodeImage(clip,360,220); auto pixels=decodeImage(clip,360,220);
@@ -161,7 +125,8 @@ bool notifyScreenshot(const Clip &clip)
notificationError.clear(); notificationError.clear();
const char *stage="registration"; const char *stage="registration";
try { try {
registerNotifications(); registerScreenshotNotifications();
check(SetCurrentProcessExplicitAppUserModelID(AppId));
stage="notification manager"; stage="notification manager";
ComPtr<Notifications::IToastNotificationManagerStatics> manager; ComPtr<Notifications::IToastNotificationManagerStatics> manager;
String managerClass(L"Windows.UI.Notifications.ToastNotificationManager"); String managerClass(L"Windows.UI.Notifications.ToastNotificationManager");
@@ -212,9 +177,9 @@ bool notifyScreenshot(const Clip &clip)
stage="show"; stage="show";
check(notifier->Show(toast.Get())); check(notifier->Show(toast.Get()));
return true; return true;
} catch(const std::exception &e) { notificationError=std::string(stage)+": "+e.what(); return false; } // Clipboard is already populated; fall back to the editor. } catch(const std::exception &e) { notificationError=std::string(stage)+": "+e.what(); log(wide(notificationError)); return false; } // Clipboard is already populated; fall back to the editor.
} }
std::string screenshotNotificationError() { return notificationError; } std::string screenshotNotificationError() { return notificationError; }
void registerScreenshotNotifications() { registerNotifications(); }
} }
+3 -1
View File
@@ -151,6 +151,7 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
s.undo = str(j, "undoHotkey", s.undo); s.undo = str(j, "undoHotkey", s.undo);
s.clipboard = str(j, "clipboardHotkey", s.clipboard); s.clipboard = str(j, "clipboardHotkey", s.clipboard);
s.screenshot = str(j, "screenshotHotkey", s.screenshot); s.screenshot = str(j, "screenshotHotkey", s.screenshot);
s.screenshotAutoOpenEditor = j.value("screenshotAutoOpenEditor", false);
if (j.contains("layouts")) if (j.contains("layouts"))
{ {
s.cycle.clear(); s.cycle.clear();
@@ -205,7 +206,8 @@ Bytes encodeSettings(const Settings &s)
{"translitHotkey", utf8(s.translit)}, {"translitHotkey", utf8(s.translit)},
{"undoHotkey", utf8(s.undo)}, {"undoHotkey", utf8(s.undo)},
{"clipboardHotkey", utf8(s.clipboard)}, {"clipboardHotkey", utf8(s.clipboard)},
{"screenshotHotkey", utf8(s.screenshot)}}; {"screenshotHotkey", utf8(s.screenshot)},
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
j["layouts"] = Json::array(); j["layouts"] = Json::array();
for (auto &id : s.cycle) for (auto &id : s.cycle)
j["layouts"].push_back(utf8(id)); j["layouts"].push_back(utf8(id));
+6 -6
View File
@@ -360,6 +360,9 @@ void resizeForDpi(HWND w, WPARAM wp, LPARAM lp)
SetPropW(w, L"Dpi", (HANDLE)(UINT_PTR)next); SetPropW(w, L"Dpi", (HANDLE)(UINT_PTR)next);
if (old) if (old)
DeleteObject(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); fitToMonitor(w, *(RECT *)lp);
arrangeWindow(w); arrangeWindow(w);
} }
@@ -747,12 +750,6 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
v->pending.clear(); v->pending.clear();
++v->generation; ++v->generation;
return 0; return 0;
case WM_DPICHANGED:
{
resizeForDpi(w, wp, lp);
SendDlgItemMessageW(w, 102, LB_SETITEMHEIGHT, 0, scale(w, 88));
return 0;
}
case WM_NCDESTROY: case WM_NCDESTROY:
disposeFont(w); disposeFont(w);
v->app->historyWindow = nullptr; v->app->historyWindow = nullptr;
@@ -822,6 +819,7 @@ void fillSettings(HWND w, SettingsView *v)
check(w, 201, s.recent); check(w, 201, s.recent);
check(w, 202, s.switchLayout); check(w, 202, s.switchLayout);
check(w, 208, s.writeLog); check(w, 208, s.writeLog);
check(w, 209, s.screenshotAutoOpenEditor);
check(w, 203, s.history); check(w, 203, s.history);
check(w, 204, s.autostart); check(w, 204, s.autostart);
check(w, 206, s.elevatedAutostart); check(w, 206, s.elevatedAutostart);
@@ -872,6 +870,7 @@ Settings collectSettings(HWND w, SettingsView *v)
s.recent = checked(w, 201); s.recent = checked(w, 201);
s.switchLayout = checked(w, 202); s.switchLayout = checked(w, 202);
s.writeLog = checked(w, 208); s.writeLog = checked(w, 208);
s.screenshotAutoOpenEditor = checked(w, 209);
s.history = checked(w, 203); s.history = checked(w, 203);
s.autostart = checked(w, 204); s.autostart = checked(w, 204);
s.elevatedAutostart = checked(w, 206); s.elevatedAutostart = checked(w, 206);
@@ -1662,6 +1661,7 @@ void showSettings(App &app, bool visible)
label(w, names[i], 420, 48 + i * 30, 115); label(w, names[i], 420, 48 + i * 30, 115);
hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220); hotkeyEdit(app, w, 210 + i, 540, 44 + i * 30, 220);
} }
checkbox(w, L"Open editor after screen capture", 209, 420, 224, 340);
checkbox(w, L"Automatically correct layout (experimental)", 207, 18, 210, 395); checkbox(w, L"Automatically correct layout (experimental)", 207, 18, 210, 395);
label(w, L"Layout cycle — top to bottom", 18, 240, 400); 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, control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 220,
+2 -1
View File
@@ -2,8 +2,9 @@
#include "platform.h" #include "platform.h"
namespace ks namespace ks
{ {
inline constexpr wchar_t AppVersion[] = L"0.6.0"; inline constexpr wchar_t AppVersion[] = L"0.6.3";
inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
struct UpdateResult struct UpdateResult
{ {
std::wstring state, version, error; std::wstring state, version, error;
+31 -1
View File
@@ -7,6 +7,20 @@
namespace ks { namespace ks {
struct InputRegressionAccess { struct InputRegressionAccess {
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; } static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
static bool updateTimer(App &a) {
a.updateRunning=true; // Exercise timer scheduling without launching a network request.
App::windowProc(a.window,WM_TIMER,5,0);
bool first=KillTimer(a.window,5)!=FALSE;
App::windowProc(a.window,WM_TIMER,5,0);
bool repeated=KillTimer(a.window,5)!=FALSE;
a.updateRunning=false;
return first && repeated && !a.updateWorker.joinable();
}
static std::wstring updateResult(App &a,const wchar_t *state,const wchar_t *version) {
auto result=new UpdateResult; result->state=state; result->version=version;
App::windowProc(a.window,UpdateMessage,0,(LPARAM)result);
return a.notifiedUpdate;
}
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); } static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
}; };
} }
@@ -31,6 +45,12 @@ int main(int argc, char **argv) {
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor. // 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); 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"); require(app.window!=nullptr,"message-only test window");
require(ks::UpdateCheckIntervalMs==21600000,"automatic update interval is six hours");
require(ks::InputRegressionAccess::updateTimer(app),"update timer rearms and avoids overlapping checks");
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","background update records announced version");
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","same available version remains deduplicated");
require(ks::InputRegressionAccess::updateResult(app,L"error",L"")==L"99.0.0","network error preserves announced version");
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.1")==L"99.0.1","new version can be announced after an error");
for(int variant=0;variant<3;++variant) for(int icon=0;icon<15;++icon) for(int variant=0;variant<3;++variant) for(int icon=0;icon<15;++icon)
for(int size:{22,44,55}) { 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); auto handle=(HICON)LoadImageW(app.instance,MAKEINTRESOURCEW(icon<9?300+variant*10+icon:400+variant*10+icon-9),IMAGE_ICON,size,size,0);
@@ -499,9 +519,19 @@ int main(int argc, char **argv) {
require(buttonAfter.top-buttonBefore.top==180 && buttonAfter.bottom-listAfter.bottom==buttonBefore.bottom-listBefore.bottom,"history actions stay below list after resize"); 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); 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"); 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; auto settings=app.settingsWindow;
require(SendDlgItemMessageW(settings,209,BM_GETCHECK,0,0)==BST_UNCHECKED,"automatic screenshot editor checkbox defaults off");
require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus"); require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus");
for(auto pair:{std::pair{207,215},std::pair{207,232},std::pair{207,220},std::pair{232,230},std::pair{230,231},std::pair{231,253}}) { 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{}; RECT first{},second{},overlap{};
GetWindowRect(GetDlgItem(settings,pair.first),&first); GetWindowRect(GetDlgItem(settings,pair.second),&second); 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"); require(!IntersectRect(&overlap,&first,&second),"capture hotkey and layout controls do not overlap");
+2 -2
View File
@@ -46,7 +46,7 @@ function Latest-Token {
} }
$process=$null $process=$null
try { try {
$process=Start-Process -FilePath $Executable -WindowStyle Hidden -PassThru $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.' Wait-For { (Capture-Windows).Count -eq 1 } 'First selection did not open.'
Select-Region (Capture-Windows)[0] Select-Region (Capture-Windows)[0]
if (!$process.WaitForExit(5000)) { throw 'Process remained alive after notification.' } if (!$process.WaitForExit(5000)) { throw 'Process remained alive after notification.' }
@@ -63,7 +63,7 @@ try {
if ($process.HasExited) { throw 'Process exited with an editor open.' } if ($process.HasExited) { throw 'Process exited with an editor open.' }
Close-Editor $second Close-Editor $second
if (!$process.WaitForExit(5000) -or $process.ExitCode -ne 0) { throw 'Last editor did not exit process.' } if (!$process.WaitForExit(5000) -or $process.ExitCode -ne 0) { throw 'Last editor did not exit process.' }
$process=Start-Process -FilePath $Executable -WindowStyle Hidden -PassThru $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.' Wait-For { (Capture-Windows).Count -eq 1 } 'New process did not open selection.'
Send (Capture-Windows)[0] 0x100 27 # Esc cancels a selection only. 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.' } if (!$process.WaitForExit(5000) -or $process.ExitCode -ne 0) { throw 'Cancelling selection did not exit process.' }
+55
View File
@@ -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;
}
+26 -2
View File
@@ -164,18 +164,42 @@ int main()
SendMessageW(window,WM_CLOSE,0,0); SendMessageW(window,WM_CLOSE,0,0);
require(!IsWindow(window),"copied screenshot closes without confirmation"); require(!IsWindow(window),"copied screenshot closes without confirmation");
static bool published=false; static bool published=false;
app.notify=[](const ks::Clip &) { return published; }; static bool notificationAccepted=false;
static HWND notificationWindow{};
static bool expectHidden=false;
app.notify=[](const ks::Clip &) {
if(!published) throw std::runtime_error("notification preceded clipboard publication");
if(expectHidden && IsWindowVisible(notificationWindow))
throw std::runtime_error("selection overlay is still visible when notification is sent");
return notificationAccepted;
};
for(bool autoOpen : {true,false}) for (bool accepted : {true, false}) {
app.autoOpenEditor=autoOpen;
published=false;
notificationAccepted=accepted;
ks::Capture selection(app); ks::Capture selection(app);
selection.image=std::make_unique<ks::Surface>(100,100); selection.image=std::make_unique<ks::Surface>(100,100);
PatBlt(selection.image->dc,0,0,100,100,WHITENESS); PatBlt(selection.image->dc,0,0,100,100,WHITENESS);
selection.start={10,20}; selection.end={70,60}; selection.start={10,20}; selection.end={70,60};
auto overlay=CreateWindowW(cls.lpszClassName,L"Selection test",WS_POPUP,0,0,100,100,app.window,nullptr,app.instance,&selection); auto overlay=CreateWindowW(cls.lpszClassName,L"Selection test",WS_POPUP,0,0,100,100,app.window,nullptr,app.instance,&selection);
notificationWindow=overlay; expectHidden=!autoOpen;
ShowWindow(overlay,SW_SHOWNOACTIVATE);
ks::finishSelection(overlay,selection,[](HWND,const ks::Clip &clip)->DWORD { ks::finishSelection(overlay,selection,[](HWND,const ks::Clip &clip)->DWORD {
auto pixels=ks::decodeImage(clip); auto pixels=ks::decodeImage(clip);
if(pixels.width!=60 || pixels.height!=40) throw std::runtime_error("automatic copy did not crop selection"); if(pixels.width!=60 || pixels.height!=40) throw std::runtime_error("automatic copy did not crop selection");
published=true; return 0; published=true; return 0;
}); });
require(published && selection.copied && !IsWindow(overlay),"selection copies before notification and closes without editor"); if(!autoOpen && accepted) {
require(published && !IsWindow(overlay),"notification-first closes selection after successful submission");
continue;
}
require(published && selection.copied && IsWindowVisible(overlay) && selection.editing,
"selection opens editor whether notification is accepted or rejected");
require(selection.image->width==60 && selection.image->height==40,
"automatically opened editor contains the selected region");
SendMessageW(overlay,WM_CLOSE,0,0);
require(!IsWindow(overlay),"automatically copied screenshot closes without confirmation");
}
DestroyWindow(app.window); app.window=nullptr; DestroyWindow(app.window); app.window=nullptr;
CoUninitialize(); CoUninitialize();
std::cout<<"Passed screenshot editor UI integration checks\n"; std::cout<<"Passed screenshot editor UI integration checks\n";
+7
View File
@@ -430,6 +430,13 @@ int main(int argc, char **argv)
auto invalid = settings; auto invalid = settings;
auto captureSettings = settings; auto captureSettings = settings;
captureSettings.screenshot = L"Ctrl+Shift+F9"; captureSettings.screenshot = L"Ctrl+Shift+F9";
require(!captureSettings.screenshotAutoOpenEditor,"notification-first capture is the default");
captureSettings.screenshotAutoOpenEditor=true;
require(ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshotAutoOpenEditor,
"explicit automatic editor preference survives settings roundtrip");
captureSettings.screenshotAutoOpenEditor=false;
require(!ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshotAutoOpenEditor,
"notification-first preference survives settings roundtrip");
require(ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshot == captureSettings.screenshot, require(ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshot == captureSettings.screenshot,
"screen capture shortcut roundtrip"); "screen capture shortcut roundtrip");
captureSettings.screenshot = captureSettings.clipboard; captureSettings.screenshot = captureSettings.clipboard;