diff --git a/README.md b/README.md index 93e157e..b5a14b6 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,13 @@ 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 `KSwitcher.exe --capture`. Drag a rectangle on the dimmed, frozen desktop; the selected area stays at normal brightness. Escape cancels. Selection and annotation frames are buffered to avoid flicker. -The selected area is copied immediately and Windows shows a notification with a thumbnail. -Click the notification to open the editor, including after the screenshot process has exited. -If notifications are disabled or cannot be sent, the already-copied screenshot opens in the editor. +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. @@ -173,6 +177,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 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 diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index 45fad12..b30a73a 100644 Binary files a/Release/KSwitcher-win-x64.zip and b/Release/KSwitcher-win-x64.zip differ diff --git a/Release/README.md b/Release/README.md index 4421f52..6c97e73 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,28 +1,26 @@ # Latest Windows release -## v0.4.1 — 2026-10-28 +## v0.6.2 — 2026-10-29 -- Fix Settings spacing around Screen capture, automatic layout correction and layout controls. -- Verify update checksums through .NET SHA-256 without requiring the PowerShell Get-FileHash command. This fixes updates from processes launched with a conflicting PowerShell module environment. -- Capture a rectangular screen region with Win+Shift+S (configurable), with dimmed selection and independent editor windows. -- Annotate screenshots with numbered markers, arrows, straight/freehand lines, highlights, rectangles, circles, ellipses and inline text. -- Edit color, line style, arrowheads, thickness, font and size. Link arrow endpoints to markers and attach captions. -- Captions follow line angle and keep their relative offset until manually rotated. Text rotation supports Shift snapping in 15-degree steps. -- Move objects one pixel with arrow keys; clone with Ctrl+D; undo/redo edits. Numbered markers reuse gaps. -- Compact Lucide toolbar, contextual status bar, pencil cursor and confirmation before discarding an uncopied screenshot. Escape no longer closes the editor. -- Preserve the separate on-demand configurator and configurable capture shortcut. +- 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: 173 native checks, 379 input/rendering checks, screenshot editor UI integration checks, -29 updater checks, including missing Get-FileHash coverage, and package validation. +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.3.0 or v0.4.0:** use Update in the tray menu. Both executables are supported. -If an older updater reports missing `Get-FileHash`, restart KSwitcher normally from Explorer -(outside a PowerShell 7 session) or install this ZIP manually once. v0.4.1 removes that dependency. -**Upgrade from v0.2.3 or earlier:** install manually once because those updaters reject the additional configurator EXE. -Exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries. +**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** with **SHA256SUMS.txt**. The package contains both executables, -offline dictionaries, documentation and license notices; no personal settings or clipboard history. +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. Build and package: @@ -31,5 +29,5 @@ Build and package: .\package.ps1 -PublishRelease ``` -Commit the archive, checksum and sources together. Publish both assets on a stable release whose -tag matches the executable version. +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. diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index e223de4..7320f04 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -9b2e2708eda40531659748c97d8c2d10f59cc98954bad7517b86e824fd889019 KSwitcher-win-x64.zip +e0c5a7b4f225ee32790f01ec916ae4706783b7e50b0bf6816c38ae2a6685d43d KSwitcher-win-x64.zip diff --git a/build.ps1 b/build.ps1 index 8e39556..cf1036e 100644 --- a/build.ps1 +++ b/build.ps1 @@ -51,6 +51,14 @@ try { 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' } } diff --git a/resources/app.rc b/resources/app.rc index b5c4ae9..79af45a 100644 --- a/resources/app.rc +++ b/resources/app.rc @@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur" 1 RT_MANIFEST "app.manifest" 201 RCDATA "update.ps1" 1 VERSIONINFO - FILEVERSION 0,6,1,0 - PRODUCTVERSION 0,6,1,0 + FILEVERSION 0,6,2,0 + PRODUCTVERSION 0,6,2,0 FILEOS VOS_NT_WINDOWS32 FILETYPE VFT_APP BEGIN @@ -20,7 +20,7 @@ BEGIN BEGIN VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "ProductName", "KSwitcher" - VALUE "FileVersion", "0.6.1" + VALUE "FileVersion", "0.6.2" END END BLOCK "VarFileInfo" diff --git a/src/app.cpp b/src/app.cpp index 1d92c47..d0aab56 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1453,6 +1453,8 @@ 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) diff --git a/src/core.h b/src/core.h index 989220e..2eec8a2 100644 --- a/src/core.h +++ b/src/core.h @@ -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"([{«“‘\""; diff --git a/src/screenshot.cpp b/src/screenshot.cpp index 7b384d8..4b0c0a3 100644 --- a/src/screenshot.cpp +++ b/src/screenshot.cpp @@ -127,6 +127,7 @@ struct Capture std::unique_ptr image; std::unique_ptr dimmed, frame; bool editing{}, dragging{}, arrow{}, owned{}, copied{}; + bool autoOpenEditor; bool highlight{}; bool freehand=true; bool straight{}, textTool{}; @@ -161,7 +162,7 @@ struct Capture int hoveredTool=0; MarkPart hoveredPart=MarkPart::None; std::wstring statusText; - explicit Capture(ScreenshotHost &a) : app(a) {} + explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {} ~Capture() { if(pencilCursor) DestroyCursor(pencilCursor); if (font) DeleteObject(font); @@ -812,9 +813,13 @@ void finishSelection(HWND w,Capture &c,DWORD (*publish)(HWND,const Clip &)=write auto clip=screenshotClip(cropped.dc,cropped.width,cropped.height,{}); publish(c.app.window,clip); c.copied=true; - ShowWindow(w,SW_HIDE); - if(c.app.notify(clip)) DestroyWindow(w); - else edit(w,c); + // A successful toast submission does not guarantee that Windows shows a banner. + if(c.autoOpenEditor) edit(w,c); + bool notified=c.app.notify(clip); + if(!c.autoOpenEditor) { + if(notified) DestroyWindow(w); + else edit(w,c); + } } LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) { diff --git a/src/screenshot.h b/src/screenshot.h index 6d0984f..3ba7bb7 100644 --- a/src/screenshot.h +++ b/src/screenshot.h @@ -8,6 +8,7 @@ struct ScreenshotHost { 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 &); diff --git a/src/screenshot_activation.cpp b/src/screenshot_activation.cpp index 9fd87be..43ed749 100644 --- a/src/screenshot_activation.cpp +++ b/src/screenshot_activation.cpp @@ -1,10 +1,55 @@ #include "screenshot_notifications.h" #include +#include +#include +#include +#include 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 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 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 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; diff --git a/src/screenshot_launcher.cpp b/src/screenshot_launcher.cpp index 190c2f3..e911aee 100644 --- a/src/screenshot_launcher.cpp +++ b/src/screenshot_launcher.cpp @@ -1,12 +1,14 @@ #include "app.h" namespace ks { -void launchScreenshot(App &) +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 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. diff --git a/src/screenshot_main.cpp b/src/screenshot_main.cpp index 8b01e60..b55af1e 100644 --- a/src/screenshot_main.cpp +++ b/src/screenshot_main.cpp @@ -14,6 +14,8 @@ LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM lp) 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); @@ -45,7 +47,12 @@ LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM 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(); } @@ -54,7 +61,10 @@ int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int) int result=0; try { ks::registerScreenshotNotifications(); } catch(...) { result=1; } 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") { std::wstring uri=argv[2]; const std::wstring prefix=L"kswitcher-screenshot:"; 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); AllowSetForegroundWindow(pid); 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) { 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. // WM_COPYDATA accepts only a validated cache GUID, never a path or executable command. ChangeWindowMessageFilterEx(host.window,WM_COPYDATA,MSGFLT_ALLOW,nullptr); - if(token.empty()) PostMessageW(host.window,ks::ScreenshotRequest,0,0); + 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) { diff --git a/src/screenshot_notifications.cpp b/src/screenshot_notifications.cpp index 8502190..97da244 100644 --- a/src/screenshot_notifications.cpp +++ b/src/screenshot_notifications.cpp @@ -26,7 +26,6 @@ using Microsoft::WRL::ComPtr; namespace Notifications=ABI::Windows::UI::Notifications; namespace Xml=ABI::Windows::Data::Xml::Dom; constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot"; -constexpr GUID ActivatorId{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}}; std::string notificationError; void check(HRESULT result) { if(FAILED(result)) { char message[100]; snprintf(message,sizeof(message),"Windows screenshot notification failed (0x%08lx)",(unsigned long)result); throw std::runtime_error(message); } } struct String { @@ -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 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 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 file; check(link.As(&file)); check(file->Save(shortcut.c_str(),TRUE)); - // The notification broker caches protocol handlers independently of ShellExecute. - SHChangeNotify(SHCNE_ASSOCCHANGED,SHCNF_IDLIST,nullptr,nullptr); - check(SetCurrentProcessExplicitAppUserModelID(AppId)); -} void savePreview(const Clip &clip,const std::filesystem::path &path) { auto pixels=decodeImage(clip,360,220); @@ -161,7 +125,8 @@ bool notifyScreenshot(const Clip &clip) notificationError.clear(); const char *stage="registration"; try { - registerNotifications(); + registerScreenshotNotifications(); + check(SetCurrentProcessExplicitAppUserModelID(AppId)); stage="notification manager"; ComPtr manager; String managerClass(L"Windows.UI.Notifications.ToastNotificationManager"); @@ -212,9 +177,9 @@ bool notifyScreenshot(const Clip &clip) stage="show"; check(notifier->Show(toast.Get())); return true; - } catch(const std::exception &e) { notificationError=std::string(stage)+": "+e.what(); return false; } // Clipboard is already populated; fall back to the editor. + } 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; } -void registerScreenshotNotifications() { registerNotifications(); } + } diff --git a/src/storage.cpp b/src/storage.cpp index 4b4f281..928908e 100644 --- a/src/storage.cpp +++ b/src/storage.cpp @@ -151,6 +151,7 @@ Settings decodeSettings(const Bytes &bytes, const std::vector &layouts) 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(); @@ -205,7 +206,8 @@ Bytes encodeSettings(const Settings &s) {"translitHotkey", utf8(s.translit)}, {"undoHotkey", utf8(s.undo)}, {"clipboardHotkey", utf8(s.clipboard)}, - {"screenshotHotkey", utf8(s.screenshot)}}; + {"screenshotHotkey", utf8(s.screenshot)}, + {"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}}; j["layouts"] = Json::array(); for (auto &id : s.cycle) j["layouts"].push_back(utf8(id)); diff --git a/src/ui.cpp b/src/ui.cpp index d9bf601..f7647e3 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -360,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); } @@ -747,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; @@ -822,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); @@ -872,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); @@ -1662,6 +1661,7 @@ void showSettings(App &app, bool visible) label(w, names[i], 420, 48 + i * 30, 115); 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); 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, diff --git a/src/update.h b/src/update.h index 15ac589..e9f0bb2 100644 --- a/src/update.h +++ b/src/update.h @@ -2,7 +2,7 @@ #include "platform.h" namespace ks { -inline constexpr wchar_t AppVersion[] = L"0.6.1"; +inline constexpr wchar_t AppVersion[] = L"0.6.2"; inline constexpr UINT UpdateMessage = WM_APP + 7; struct UpdateResult { diff --git a/tests/input.cpp b/tests/input.cpp index 060d07e..66d6075 100644 --- a/tests/input.cpp +++ b/tests/input.cpp @@ -499,9 +499,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"); 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{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{}; 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"); diff --git a/tests/screenshot_process.ps1 b/tests/screenshot_process.ps1 index 0776aab..91ebf5c 100644 --- a/tests/screenshot_process.ps1 +++ b/tests/screenshot_process.ps1 @@ -46,7 +46,7 @@ function Latest-Token { } $process=$null 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.' Select-Region (Capture-Windows)[0] 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.' } Close-Editor $second 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.' 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.' } diff --git a/tests/screenshot_registration.cpp b/tests/screenshot_registration.cpp new file mode 100644 index 0000000..af34b24 --- /dev/null +++ b/tests/screenshot_registration.cpp @@ -0,0 +1,55 @@ +// Redirect HKCU and the shortcut destination; never replace the user's registration. +#include "../src/screenshot_notifications.h" +#include +#include +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 link; + if(FAILED(CoCreateInstance(CLSID_ShellLink,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&link)))) throw std::runtime_error("read shortcut"); + Microsoft::WRL::ComPtr 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 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<(100,100); - PatBlt(selection.image->dc,0,0,100,100,WHITENESS); - 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); - ks::finishSelection(overlay,selection,[](HWND,const ks::Clip &clip)->DWORD { - auto pixels=ks::decodeImage(clip); - if(pixels.width!=60 || pixels.height!=40) throw std::runtime_error("automatic copy did not crop selection"); - published=true; return 0; - }); - require(published && selection.copied && !IsWindow(overlay),"selection copies before notification and closes without editor"); + static bool notificationAccepted=false; + app.notify=[](const ks::Clip &) { + if(!published) throw std::runtime_error("notification preceded clipboard publication"); + return notificationAccepted; + }; + for(bool autoOpen : {true,false}) for (bool accepted : {true, false}) { + app.autoOpenEditor=autoOpen; + published=false; + notificationAccepted=accepted; + ks::Capture selection(app); + selection.image=std::make_unique(100,100); + PatBlt(selection.image->dc,0,0,100,100,WHITENESS); + 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); + ks::finishSelection(overlay,selection,[](HWND,const ks::Clip &clip)->DWORD { + auto pixels=ks::decodeImage(clip); + if(pixels.width!=60 || pixels.height!=40) throw std::runtime_error("automatic copy did not crop selection"); + published=true; return 0; + }); + 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; CoUninitialize(); std::cout<<"Passed screenshot editor UI integration checks\n"; diff --git a/tests/tests.cpp b/tests/tests.cpp index 8742ef8..ce0e5b5 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -430,6 +430,10 @@ int main(int argc, char **argv) auto invalid = settings; auto captureSettings = settings; captureSettings.screenshot = L"Ctrl+Shift+F9"; + require(captureSettings.screenshotAutoOpenEditor,"editor opens by default"); + 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, "screen capture shortcut roundtrip"); captureSettings.screenshot = captureSettings.clipboard;