diff --git a/CMakeLists.txt b/CMakeLists.txt index de33c33..4b6a03a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.5.0 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.6.0 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() @@ -8,8 +8,8 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(kscore src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp) target_include_directories(kscore PUBLIC src PRIVATE vendor) target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00) -target_link_libraries(kscore PUBLIC user32 shell32 advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore) -add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_launcher.cpp src/configuration.cpp src/ui.cpp resources/app.rc) +target_link_libraries(kscore PUBLIC user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore) +add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc) target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP) target_link_libraries(KSwitcher PRIVATE kscore) add_executable(KSwitcherConfig WIN32 src/config_main.cpp src/configuration.cpp src/ui.cpp resources/app.rc) @@ -18,15 +18,15 @@ target_compile_definitions(KSwitcherConfig PRIVATE KS_CONFIG_APP) target_link_libraries(KSwitcherConfig PRIVATE kscore) add_executable(KSwitcherTests tests/tests.cpp) target_link_libraries(KSwitcherTests PRIVATE kscore) -add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) +add_executable(KSwitcherIntegration tests/integration.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) target_link_libraries(KSwitcherIntegration PRIVATE kscore) -add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) +add_executable(KSwitcherInputTests tests/input.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc) target_link_libraries(KSwitcherInputTests PRIVATE kscore) target_compile_definitions(KSwitcherInputTests PRIVATE KS_SCREENSHOT_RESOURCES) add_executable(KSwitcherScreenshotUITests tests/screenshot_ui.cpp resources/app.rc) target_link_libraries(KSwitcherScreenshotUITests PRIVATE kscore) target_compile_definitions(KSwitcherScreenshotUITests PRIVATE KS_SCREENSHOT_RESOURCES) -add_executable(KSwitcherScreenshot WIN32 src/screenshot_main.cpp src/screenshot.cpp resources/app.rc) +add_executable(KSwitcherScreenshot WIN32 src/screenshot_main.cpp src/screenshot_notifications.cpp src/screenshot_activation.cpp src/screenshot.cpp resources/app.rc) set_target_properties(KSwitcherScreenshot PROPERTIES OUTPUT_NAME "KSwitcher.Screenshot") target_compile_definitions(KSwitcherScreenshot PRIVATE KS_SCREENSHOT_RESOURCES) target_link_libraries(KSwitcherScreenshot PRIVATE kscore) diff --git a/README.md b/README.md index 27c66d6..2bb2142 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,16 @@ 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 editor opens with the selected area. Press **Ctrl+C** or **Enter** to copy it immediately, -or annotate first: **N** places sequential numbered red markers with a click, and **A** draws +The selected area is copied immediately and Windows shows a notification with a thumbnail. +Click the notification to open the editor, including after the screenshot process has exited. +If notifications are disabled or cannot be sent, the already-copied screenshot opens in the editor. +Originals in the local notification cache are DPAPI-encrypted. Thumbnail PNGs live in a separate +per-user cache under ProgramData, readable only by that user and SYSTEM, so the Windows notification +broker can load them even when the profile directory is inaccessible to it. +Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to +40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot. +In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window. +To annotate: **N** places sequential numbered red markers with a click, and **A** draws red arrows by dragging from the tail to the tip. Click an existing marker or arrow to select it; drag its body to move it. Drag the square handle on a number's right edge to change its diameter (the text scales with it), or either endpoint handle of an arrow to change its length and direction. diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index a692704..3feb245 100644 Binary files a/Release/KSwitcher-win-x64.zip and b/Release/KSwitcher-win-x64.zip differ diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index d6b8785..87225e4 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -7b9f07168023d2ad5c71672d0e699144bc15b11175eb05a7149b72b8be4f90b4 KSwitcher-win-x64.zip +e1d11c833adab5190ec49af1bb4eb25babdd6658aafb7423f9c502a1e71b2581 KSwitcher-win-x64.zip diff --git a/build.ps1 b/build.ps1 index ed31d8c..8e39556 100644 --- a/build.ps1 +++ b/build.ps1 @@ -13,8 +13,8 @@ New-Item -ItemType Directory -Force (Split-Path $executablePath) | Out-Null Push-Location $root try { $common = @('-std=c++20','-Os','-DNDEBUG','-DUNICODE','-D_UNICODE','-DWIN32_LEAN_AND_MEAN','-DNOMINMAX','-D_WIN32_WINNT=0x0A00','-Wall','-Wextra','-Wno-missing-field-initializers','-Isrc','-Ivendor','-ffunction-sections','-fdata-sections') - $libs = @('-luser32','-lshell32','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore') - foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','screenshot_main','screenshot_launcher','app','ui','main','configuration','config_main')) { + $libs = @('-luser32','-lshell32','-lshlwapi','-ladvapi32','-lcrypt32','-limm32','-lcomctl32','-lgdi32','-lole32','-loleaut32','-ltaskschd','-lcomdlg32','-luuid','-lwindowscodecs','-lruntimeobject','-luiautomationcore') + foreach ($name in @('core','platform','autostart','autocorrect','storage','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) { & $compiler @common -c "src/$name.cpp" -o "build/$name.o" if ($LASTEXITCODE) { throw "Compilation failed: $name" } } @@ -27,7 +27,7 @@ try { & windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' } New-Item -ItemType Directory -Force build/release | Out-Null - & $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath + & $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath if ($LASTEXITCODE) { throw 'Link failed' } Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force $configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe' @@ -35,7 +35,7 @@ try { if ($LASTEXITCODE) { throw 'Configurator link failed' } Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force $screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe' - & $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/screenshot.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath + & $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/core.o build/platform.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath if ($LASTEXITCODE) { throw 'Screenshot link failed' } Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force if ($Test) { @@ -43,11 +43,11 @@ try { if ($LASTEXITCODE) { throw 'Test build failed' } & .\build\KSwitcherTests.exe if ($LASTEXITCODE) { throw 'Tests failed' } - & $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe + & $compiler @common tests/input.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe if ($LASTEXITCODE) { throw 'Input test build failed' } & .\build\KSwitcherInputTests.exe if ($LASTEXITCODE) { throw 'Input tests failed' } - & $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe + & $compiler @common tests/screenshot_ui.cpp build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' } & .\build\KSwitcherScreenshotUITests.exe if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' } @@ -55,7 +55,7 @@ try { if ($LASTEXITCODE) { throw 'Updater tests failed' } } if ($Integration) { - & $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe + & $compiler @common tests/integration.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe if ($LASTEXITCODE) { throw 'Integration test build failed' } & .\build\KSwitcherIntegration.exe if ($LASTEXITCODE) { throw 'Integration tests failed' } diff --git a/resources/app.rc b/resources/app.rc index a00c157..328b590 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,5,0,0 - PRODUCTVERSION 0,5,0,0 + FILEVERSION 0,6,0,0 + PRODUCTVERSION 0,6,0,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.5.0" + VALUE "FileVersion", "0.6.0" END END BLOCK "VarFileInfo" diff --git a/src/app.cpp b/src/app.cpp index ec7d5c5..1d92c47 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -2,6 +2,7 @@ #include "../resources/resource.h" #include "ocr.h" #include "configuration.h" +#include "screenshot_notifications.h" #include #include #include @@ -1452,6 +1453,7 @@ int App::execute() confirmUpdatedStartup(updateStartupEvent); if (startCommand) PostMessageW(window, WM_HOTKEY, startCommand, 0); + auto screenshotActivation=startScreenshotActivationListener(); MSG msg; while (GetMessageW(&msg, nullptr, 0, 0) > 0) { @@ -1470,6 +1472,7 @@ int App::execute() TranslateMessage(&msg); DispatchMessageW(&msg); } + if(screenshotActivation) CoRevokeClassObject(screenshotActivation); return (int)msg.wParam; } } // namespace ks diff --git a/src/app.h b/src/app.h index 7245ff4..af82796 100644 --- a/src/app.h +++ b/src/app.h @@ -99,7 +99,7 @@ class App static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM); static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM); }; -void showHistory(App &); +void showHistory(App &, bool visible = true); void launchScreenshot(App &); void refreshHistory(App &); void showSettings(App &, bool visible = true); diff --git a/src/screenshot.cpp b/src/screenshot.cpp index 9772d19..7b384d8 100644 --- a/src/screenshot.cpp +++ b/src/screenshot.cpp @@ -1,4 +1,5 @@ #include "screenshot.h" +#include "ocr.h" #include "../resources/resource.h" #include #include @@ -801,6 +802,20 @@ void edit(HWND w, Capture &c) std::min(LONG(MulDiv(700,GetDpiForWindow(w),96)),work.bottom-work.top-40),SWP_FRAMECHANGED|SWP_SHOWWINDOW); layout(w,c); SetForegroundWindow(w); SetFocus(w); } +void finishSelection(HWND w,Capture &c,DWORD (*publish)(HWND,const Clip &)=writeClipboard) +{ + if(!c.app.notify) { edit(w,c); return; } + auto r=region(c.start,c.end); + if(r.right-r.left<2 || r.bottom-r.top<2) return; + Surface cropped(r.right-r.left,r.bottom-r.top); + BitBlt(cropped.dc,0,0,cropped.width,cropped.height,c.image->dc,r.left,r.top,SRCCOPY); + 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); +} LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) { auto c = reinterpret_cast(GetWindowLongPtrW(w,GWLP_USERDATA)); @@ -1100,7 +1115,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) if (msg==WM_LBUTTONUP) { c->dragging=false; ReleaseCapture(); - if (!c->editing) edit(w,*c); + if (!c->editing) { finishSelection(w,*c); return 0; } else if(c->part!=MarkPart::None) { if(c->part==MarkPart::Start || c->part==MarkPart::End) @@ -1513,6 +1528,22 @@ Clip screenshotClip(HDC source, int width, int height, const std::vector(app); + c->image=std::make_unique(pixels.width,pixels.height); + memcpy(c->image->pixels,pixels.bgra.data(),pixels.bgra.size()); + c->start={0,0}; c->end={(LONG)pixels.width,(LONG)pixels.height}; c->copied=true; + WNDCLASSW cls{}; cls.hInstance=app.instance; cls.lpfnWndProc=captureProc; cls.lpszClassName=L"KSwitcher.Capture"; + cls.style=CS_DBLCLKS; cls.hCursor=LoadCursorW(nullptr,IDC_CROSS); + cls.hIcon=LoadIconW(app.instance,MAKEINTRESOURCEW(IDI_KSWITCHER)); RegisterClassW(&cls); + auto window=CreateWindowExW(0,cls.lpszClassName,L"KSwitcher Screenshot",WS_POPUP,0,0,1,1, + app.window,nullptr,app.instance,c.get()); + if(!window) throw std::runtime_error("Cannot open screenshot editor"); + c->owned=true; ++app.windows; + auto state=c.release(); edit(window,*state); +} void showScreenshot(ScreenshotHost &app) { if (activeCapture) { showRequestedWindow(activeCapture); SetForegroundWindow(activeCapture); return; } diff --git a/src/screenshot.h b/src/screenshot.h index 640ad5b..6d0984f 100644 --- a/src/screenshot.h +++ b/src/screenshot.h @@ -7,9 +7,11 @@ struct ScreenshotHost { HINSTANCE instance{}; HWND window{}; unsigned windows{}; + bool (*notify)(const Clip &){}; }; inline constexpr UINT ScreenshotRequest=WM_APP+71, ScreenshotIdle=WM_APP+72; void showScreenshot(ScreenshotHost &); +void showScreenshotEditor(ScreenshotHost &,const Clip &); enum class LineStyle { Solid, Dotted, Dashed, DashDot }; enum class LineEnds { End, Start, Both, None }; enum class ScreenshotShape { None, Rectangle, Circle, Ellipse }; diff --git a/src/screenshot_activation.cpp b/src/screenshot_activation.cpp new file mode 100644 index 0000000..9fd87be --- /dev/null +++ b/src/screenshot_activation.cpp @@ -0,0 +1,100 @@ +#include "screenshot_notifications.h" +#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}}; +} +bool validScreenshotToken(const std::wstring &id) +{ + if(id.size()!=36) return false; + for(size_t i=0;i=L'0' && id[i]<=L'9') || (id[i]>=L'a' && id[i]<=L'f') || (id[i]>=L'A' && id[i]<=L'F'))) return false; + } + return true; +} + +namespace { +// Windows SDK INotificationActivationCallback ABI (notificationactivationcallback.h). +constexpr GUID CallbackId{0x53e31837,0x6600,0x4a81,{0x93,0x95,0x75,0xcf,0xfe,0x74,0x6f,0x94}}; +struct NotificationInput { LPCWSTR key,value; }; +struct NotificationCallback : IUnknown { + virtual HRESULT STDMETHODCALLTYPE Activate(LPCWSTR,LPCWSTR,const NotificationInput *,ULONG)=0; +}; +class Activator final : public NotificationCallback { + std::atomic references{1}; + DWORD thread=GetCurrentThreadId(); + std::wstring target; + bool quit; +public: + Activator(std::wstring path,bool shouldQuit):target(std::move(path)),quit(shouldQuit) {} + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID id,void **out) override { + if(!out) return E_POINTER; + *out=nullptr; + if(id!=IID_IUnknown && id!=CallbackId) return E_NOINTERFACE; + *out=static_cast(this); AddRef(); return S_OK; + } + ULONG STDMETHODCALLTYPE AddRef() override { return ++references; } + ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; } + HRESULT STDMETHODCALLTYPE Activate(LPCWSTR app,LPCWSTR args,const NotificationInput *,ULONG) override { + if(!app || std::wstring(app)!=AppId || !args || !validScreenshotToken(args)) return E_INVALIDARG; + auto path=target; auto command=L"\""+path+L"\" --open \"kswitcher-screenshot:"+args+L"\""; + STARTUPINFOW startup{sizeof(startup)}; PROCESS_INFORMATION process{}; + auto ok=CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_SUSPENDED,nullptr,nullptr,&startup,&process); + auto result=ok?S_OK:HRESULT_FROM_WIN32(GetLastError()); + if(ok) { + AllowSetForegroundWindow(process.dwProcessId); ResumeThread(process.hThread); + CloseHandle(process.hThread); CloseHandle(process.hProcess); + } + if(quit) PostThreadMessageW(thread,WM_QUIT,0,0); + return result; + } +}; +class ActivatorFactory final : public IClassFactory { + std::atomic references{1}; + std::wstring target; + bool quit; +public: + ActivatorFactory(std::wstring path,bool shouldQuit):target(std::move(path)),quit(shouldQuit) {} + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID id,void **out) override { + if(!out) return E_POINTER; + *out=nullptr; + if(id!=IID_IUnknown && id!=IID_IClassFactory) return E_NOINTERFACE; + *out=static_cast(this); AddRef(); return S_OK; + } + ULONG STDMETHODCALLTYPE AddRef() override { return ++references; } + ULONG STDMETHODCALLTYPE Release() override { auto count=--references; if(!count) delete this; return count; } + HRESULT STDMETHODCALLTYPE CreateInstance(IUnknown *outer,REFIID id,void **out) override { + if(outer) return CLASS_E_NOAGGREGATION; + auto object=new Activator(target,quit); auto result=object->QueryInterface(id,out); object->Release(); return result; + } + HRESULT STDMETHODCALLTYPE LockServer(BOOL) override { return S_OK; } +}; +} +int runScreenshotToastActivator() +{ + if(FAILED(CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED))) return 1; + MSG message{}; PeekMessageW(&message,nullptr,0,0,PM_NOREMOVE); + auto factory=new ActivatorFactory(executable(),true); DWORD registration{}; + auto result=CoRegisterClassObject(ActivatorId,factory,CLSCTX_LOCAL_SERVER,REGCLS_MULTIPLEUSE,®istration); + factory->Release(); + if(SUCCEEDED(result)) { + auto timer=SetTimer(nullptr,0,60000,[](HWND,UINT,UINT_PTR,DWORD){PostQuitMessage(0);}); + while(GetMessageW(&message,nullptr,0,0)>0) { TranslateMessage(&message); DispatchMessageW(&message); } + if(timer) KillTimer(nullptr,timer); + CoRevokeClassObject(registration); + } + CoUninitialize(); return FAILED(result)?1:0; +} +DWORD startScreenshotActivationListener() +{ + auto target=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe"; + auto factory=new ActivatorFactory(target.wstring(),false); + DWORD registration{}; + auto result=CoRegisterClassObject(ActivatorId,factory,CLSCTX_LOCAL_SERVER,REGCLS_MULTIPLEUSE,®istration); + factory->Release(); + return SUCCEEDED(result)?registration:0; +} + +} diff --git a/src/screenshot_main.cpp b/src/screenshot_main.cpp index 6c95df9..8b01e60 100644 --- a/src/screenshot_main.cpp +++ b/src/screenshot_main.cpp @@ -1,5 +1,8 @@ #include "screenshot.h" +#include "screenshot_notifications.h" #include +#include +#include namespace { constexpr wchar_t ControllerClass[]=L"KSwitcher.Screenshot.Controller"; @@ -18,6 +21,18 @@ LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM lp) } return TRUE; } + if(host && message==WM_COPYDATA) { + auto data=(COPYDATASTRUCT *)lp; + if(!data || data->dwData!=73 || data->cbData!=37*sizeof(wchar_t) || !data->lpData) return FALSE; + std::wstring token((const wchar_t *)data->lpData,36); + if(!ks::validScreenshotToken(token)) return FALSE; + try { ks::showScreenshotEditor(*host,ks::loadNotifiedScreenshot(token)); } + catch(const std::exception &e) { + MessageBoxW(window,ks::wide(e.what()).c_str(),L"Screenshot",MB_OK|MB_ICONINFORMATION); + PostMessageW(window,ks::ScreenshotIdle,0,0); + } + return TRUE; + } if(host && message==ks::ScreenshotIdle && !host->windows) { DestroyWindow(window); PostQuitMessage(0); @@ -29,6 +44,25 @@ LRESULT CALLBACK controller(HWND window,UINT message,WPARAM wp,LPARAM lp) int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int) { + std::wstring token; + int argc{}; auto argv=CommandLineToArgvW(GetCommandLineW(),&argc); + if(argc>=2 && std::wstring(argv[1])==L"--toast-activated") { + LocalFree(argv); return ks::runScreenshotToastActivator(); + } + if(argc==2 && std::wstring(argv[1])==L"--register-notifications") { + LocalFree(argv); CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED); + int result=0; try { ks::registerScreenshotNotifications(); } catch(...) { result=1; } + CoUninitialize(); return result; + } + if(argc>1) { + if(argc==3 && std::wstring(argv[1])==L"--open") { + std::wstring uri=argv[2]; const std::wstring prefix=L"kswitcher-screenshot:"; + if(uri.starts_with(prefix)) token=uri.substr(prefix.size()); + } + if(!ks::validScreenshotToken(token)) { LocalFree(argv); return 1; } + } + LocalFree(argv); + COPYDATASTRUCT open{73,DWORD((token.size()+1)*sizeof(wchar_t)),token.data()}; HANDLE mutex=CreateMutexW(nullptr,FALSE,L"Local\\KSwitcher.Screenshot.Singleton"); if(!mutex) return 1; // Wait through startup/shutdown races; the controller accepts requests only while alive. @@ -40,30 +74,36 @@ int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int) DWORD pid{}; GetWindowThreadProcessId(existing,&pid); AllowSetForegroundWindow(pid); DWORD_PTR accepted{}; - if(SendMessageTimeoutW(existing,ks::ScreenshotRequest,0,0,SMTO_ABORTIFHUNG,2000,&accepted) && accepted) { + if(SendMessageTimeoutW(existing,token.empty()?ks::ScreenshotRequest:WM_COPYDATA,0, + token.empty()?0:(LPARAM)&open,SMTO_ABORTIFHUNG,2000,&accepted) && accepted) { CloseHandle(mutex); return 0; } } Sleep(50); } if(!acquired) { CloseHandle(mutex); return 1; } - CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED); + RoInitialize(RO_INIT_SINGLETHREADED); INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_BAR_CLASSES}; InitCommonControlsEx(&controls); ks::ScreenshotHost host{instance}; + host.notify=ks::notifyScreenshot; WNDCLASSW cls{}; cls.hInstance=instance; cls.lpfnWndProc=controller; cls.lpszClassName=ControllerClass; RegisterClassW(&cls); host.window=CreateWindowW(ControllerClass,L"KSwitcher Screenshot",0,0,0,0,0,nullptr,nullptr,instance,&host); int result=1; if(host.window) { - PostMessageW(host.window,ks::ScreenshotRequest,0,0); + // Toast protocol activation may run unelevated while the editor was launched by an elevated resident. + // WM_COPYDATA accepts only a validated cache GUID, never a path or executable command. + ChangeWindowMessageFilterEx(host.window,WM_COPYDATA,MSGFLT_ALLOW,nullptr); + if(token.empty()) PostMessageW(host.window,ks::ScreenshotRequest,0,0); + else SendMessageW(host.window,WM_COPYDATA,0,(LPARAM)&open); MSG message{}; while((result=GetMessageW(&message,nullptr,0,0))>0) { TranslateMessage(&message); DispatchMessageW(&message); } result=result<0?1:0; } - CoUninitialize(); + RoUninitialize(); ReleaseMutex(mutex); CloseHandle(mutex); return result; } diff --git a/src/screenshot_notifications.cpp b/src/screenshot_notifications.cpp new file mode 100644 index 0000000..8502190 --- /dev/null +++ b/src/screenshot_notifications.cpp @@ -0,0 +1,220 @@ +#include "screenshot_notifications.h" +#include "ocr.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef __MINGW32__ +// This SDK aliases BYTE and boolean, producing duplicate template specializations. +// Notifications use the boolean specialization; IReference is unused here. +#define ____FIReference_1_BYTE_INTERFACE_DEFINED__ +#endif +#include +#include +#include + +namespace ks { +namespace { +using Microsoft::WRL::ComPtr; +namespace Notifications=ABI::Windows::UI::Notifications; +namespace Xml=ABI::Windows::Data::Xml::Dom; +constexpr wchar_t AppId[]=L"HrynCo.KSwitcher.Screenshot"; +constexpr GUID ActivatorId{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}}; +std::string notificationError; +void check(HRESULT result) { if(FAILED(result)) { char message[100]; snprintf(message,sizeof(message),"Windows screenshot notification failed (0x%08lx)",(unsigned long)result); throw std::runtime_error(message); } } +struct String { + HSTRING value{}; + explicit String(const std::wstring &s) { check(WindowsCreateString(s.c_str(),(UINT32)s.size(),&value)); } + ~String() { WindowsDeleteString(value); } +}; +std::filesystem::path cacheDirectory() +{ + PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_LocalAppData,0,nullptr,&folder)); + auto path=std::filesystem::path(folder)/L"KSwitcher"/L"ScreenshotNotifications"; + CoTaskMemFree(folder); std::filesystem::create_directories(path); return path; +} +std::filesystem::path previewDirectory() +{ + HANDLE token{}; + if(!OpenProcessToken(GetCurrentProcess(),TOKEN_QUERY,&token)) check(HRESULT_FROM_WIN32(GetLastError())); + DWORD size{}; GetTokenInformation(token,TokenUser,nullptr,0,&size); + std::vector data(size); + BOOL read=GetTokenInformation(token,TokenUser,data.data(),size,&size); + DWORD error=read?ERROR_SUCCESS:GetLastError(); CloseHandle(token); check(HRESULT_FROM_WIN32(error)); + auto user=reinterpret_cast(data.data())->User.Sid; + LPWSTR sidText{}; if(!ConvertSidToStringSidW(user,&sidText)) check(HRESULT_FROM_WIN32(GetLastError())); + std::wstring sid(sidText); LocalFree(sidText); + PWSTR folder{}; check(SHGetKnownFolderPath(FOLDERID_ProgramData,0,nullptr,&folder)); + auto path=std::filesystem::path(folder)/(L"KSwitcher.ScreenshotPreviews-"+sid); CoTaskMemFree(folder); + // The notification broker cannot load previews from some profile directories. + // Keep this cache outside the profile, private to the current user and SYSTEM. + auto sddl=L"O:"+sid+L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;"+sid+L")"; + PSECURITY_DESCRIPTOR security{}; + if(!ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(),SDDL_REVISION_1,&security,nullptr)) + check(HRESULT_FROM_WIN32(GetLastError())); + SECURITY_ATTRIBUTES attributes{sizeof(attributes),security,FALSE}; + BOOL created=CreateDirectoryW(path.c_str(),&attributes); + error=created?ERROR_SUCCESS:GetLastError(); + LocalFree(security); + if(error!=ERROR_ALREADY_EXISTS) check(HRESULT_FROM_WIN32(error)); + // Do not use a pre-created directory or junction controlled by another user. + DWORD flags=GetFileAttributesW(path.c_str()); + if(flags==INVALID_FILE_ATTRIBUTES || !(flags&FILE_ATTRIBUTE_DIRECTORY) || (flags&FILE_ATTRIBUTE_REPARSE_POINT)) + throw std::runtime_error("Invalid screenshot preview directory"); + PSID owner{}; PSECURITY_DESCRIPTOR existing{}; + error=GetNamedSecurityInfoW(const_cast(path.c_str()),SE_FILE_OBJECT,OWNER_SECURITY_INFORMATION, + &owner,nullptr,nullptr,nullptr,&existing); + check(HRESULT_FROM_WIN32(error)); + bool owned=EqualSid(owner,user); LocalFree(existing); + if(!owned) throw std::runtime_error("Screenshot preview directory belongs to another user"); + return path; +} +void cleanCache(const std::filesystem::path &path) +{ + std::vector files; + for(const auto &file:std::filesystem::directory_iterator(path)) + if(file.is_regular_file() && validScreenshotToken(file.path().stem().wstring()) && + (file.path().extension()==L".capture" || file.path().extension()==L".png")) files.push_back(file); + std::sort(files.begin(),files.end(),[](const auto &a,const auto &b){return a.last_write_time()>b.last_write_time();}); + uintmax_t size=0; + for(size_t i=0;i=40 || size>256*1024*1024 || std::filesystem::file_time_type::clock::now()-files[i].last_write_time()>std::chrono::hours(24)) { + std::error_code ignored; std::filesystem::remove(files[i].path(),ignored); + } + } +} +void registryString(const std::wstring &path,const wchar_t *name,const std::wstring &value) +{ + HKEY key{}; + if(RegCreateKeyExW(HKEY_CURRENT_USER,path.c_str(),0,nullptr,0,KEY_SET_VALUE,nullptr,&key,nullptr)!=ERROR_SUCCESS) + throw std::runtime_error("Cannot register screenshot notifications"); + auto result=RegSetValueExW(key,name,0,REG_SZ,(const BYTE *)value.c_str(),DWORD((value.size()+1)*sizeof(wchar_t))); + RegCloseKey(key); + if(result!=ERROR_SUCCESS) throw std::runtime_error("Cannot register screenshot notification activation"); +} +void registerNotifications() +{ + auto exe=executable(); + registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",nullptr,L"\""+exe+L"\" --toast-activated"); + registryString(L"Software\\Classes\\CLSID\\{e3f78047-f0ad-49b6-9074-411931706167}\\LocalServer32",L"ServerExecutable",exe); + auto identity=std::wstring(L"Software\\Classes\\AppUserModelId\\")+AppId; + registryString(identity,L"DisplayName",L"KSwitcher Screenshot"); + registryString(identity,L"IconUri",exe); + registryString(identity,L"CustomActivator",L"{e3f78047-f0ad-49b6-9074-411931706167}"); + // A shortcut supplies desktop notification identity and its COM activation callback. + ComPtr 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); + for(size_t i=3;i factory; check(CoCreateInstance(CLSID_WICImagingFactory,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&factory))); + ComPtr stream; check(factory->CreateStream(&stream)); check(stream->InitializeFromFilename(path.c_str(),GENERIC_WRITE)); + ComPtr encoder; check(factory->CreateEncoder(GUID_ContainerFormatPng,nullptr,&encoder)); + check(encoder->Initialize(stream.Get(),WICBitmapEncoderNoCache)); + ComPtr frame; check(encoder->CreateNewFrame(&frame,nullptr)); check(frame->Initialize(nullptr)); + check(frame->SetSize(pixels.width,pixels.height)); + auto format=GUID_WICPixelFormat32bppBGRA; check(frame->SetPixelFormat(&format)); + if(format!=GUID_WICPixelFormat32bppBGRA) throw std::runtime_error("Unsupported preview pixel format"); + check(frame->WritePixels(pixels.height,pixels.width*4,(UINT)pixels.bgra.size(),pixels.bgra.data())); + check(frame->Commit()); check(encoder->Commit()); +} +std::wstring escapeXml(const std::wstring &s) +{ + std::wstring result; + for(auto ch:s) { if(ch==L'&') result+=L"&"; else if(ch==L'\"') result+=L"""; else if(ch==L'<') result+=L"<"; else result+=ch; } + return result; +} +} +Clip loadNotifiedScreenshot(const std::wstring &id) +{ + if(!validScreenshotToken(id)) throw std::runtime_error("Invalid screenshot notification"); + auto directory=cacheDirectory(); cleanCache(directory); + auto path=directory/(id+L".capture"); + if(!std::filesystem::exists(path)) throw std::runtime_error("This screenshot notification has expired. Take a new screenshot."); + return Clip{{{CF_DIB,L"",unprotect(readFile(path,160*1024*1024))}}}; +} +bool notifyScreenshot(const Clip &clip) +{ + notificationError.clear(); + const char *stage="registration"; + try { + registerNotifications(); + stage="notification manager"; + ComPtr manager; + String managerClass(L"Windows.UI.Notifications.ToastNotificationManager"); + check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager))); + ComPtr notifier; String appId(AppId); + stage="notifier"; + check(manager->CreateToastNotifierWithId(appId.value,¬ifier)); + stage="settings"; + Notifications::NotificationSetting setting{}; + auto settingResult=notifier->get_Setting(&setting); + // A first-time sender has no settings record until its first notification. + if(settingResult!=HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) check(settingResult); + if(setting!=Notifications::NotificationSetting_Enabled) throw std::runtime_error("Screenshot notifications are disabled in Windows settings"); + stage="cache"; + auto directory=cacheDirectory(); cleanCache(directory); + GUID guid{}; check(CoCreateGuid(&guid)); wchar_t value[40]{}; StringFromGUID2(guid,value,40); + std::wstring id(value+1,36); + auto dib=std::find_if(clip.formats.begin(),clip.formats.end(),[](const auto &format){return format.id==CF_DIB;}); + if(dib==clip.formats.end() || dib->data.size()>128*1024*1024) return false; + atomicWrite(directory/(id+L".capture"),protect(dib->data)); + stage="preview"; + auto previews=previewDirectory(); cleanCache(previews); + auto preview=previews/(id+L".png"); savePreview(clip,preview); + cleanCache(previews); + cleanCache(directory); + stage="XML"; + String documentClass(L"Windows.Data.Xml.Dom.XmlDocument"); ComPtr documentInstance; + check(RoActivateInstance(documentClass.value,&documentInstance)); + ComPtr document; check(documentInstance.As(&document)); + ComPtr documentIO; check(document.As(&documentIO)); + auto uri=L"file:///"+preview.generic_wstring(); + String content(L"" + L"Screenshot copiedClick to edit and annotate\"Screenshot"); + check(documentIO->LoadXml(content.value)); + stage="toast factory"; + ComPtr toastFactory; String toastClass(L"Windows.UI.Notifications.ToastNotification"); + check(RoGetActivationFactory(toastClass.value,IID_PPV_ARGS(&toastFactory))); + ComPtr toast; check(toastFactory->CreateToastNotification(document.Get(),&toast)); + ComPtr values; + String propertyClass(L"Windows.Foundation.PropertyValue"); + check(RoGetActivationFactory(propertyClass.value,IID_PPV_ARGS(&values))); + FILETIME now{}; GetSystemTimeAsFileTime(&now); + ULARGE_INTEGER timestamp{}; timestamp.LowPart=now.dwLowDateTime; timestamp.HighPart=now.dwHighDateTime; + ABI::Windows::Foundation::DateTime expires{(INT64)(timestamp.QuadPart+24ull*60*60*10000000)}; + ComPtr boxed; check(values->CreateDateTime(expires,&boxed)); + ComPtr> expiration; + check(boxed.As(&expiration)); check(toast->put_ExpirationTime(expiration.Get())); + stage="show"; + check(notifier->Show(toast.Get())); + return true; + } catch(const std::exception &e) { notificationError=std::string(stage)+": "+e.what(); return false; } // Clipboard is already populated; fall back to the editor. +} +std::string screenshotNotificationError() { return notificationError; } +void registerScreenshotNotifications() { registerNotifications(); } + +} diff --git a/src/screenshot_notifications.h b/src/screenshot_notifications.h new file mode 100644 index 0000000..f1dad16 --- /dev/null +++ b/src/screenshot_notifications.h @@ -0,0 +1,11 @@ +#pragma once +#include "platform.h" +namespace ks { +bool notifyScreenshot(const Clip &); +std::string screenshotNotificationError(); +void registerScreenshotNotifications(); +int runScreenshotToastActivator(); +DWORD startScreenshotActivationListener(); +bool validScreenshotToken(const std::wstring &); +Clip loadNotifiedScreenshot(const std::wstring &); +} diff --git a/src/ui.cpp b/src/ui.cpp index 5f5eeff..d9bf601 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -31,7 +31,7 @@ HFONT font(HWND w) struct WindowLayout { int width{}, height{}, x{}, y{}; - bool scrolling{}, arranging{}; + bool scrolling{}, arranging{}, history{}; int wheelRemainder{}, horizontalWheelRemainder{}; std::vector> controls; // Original 96-DPI geometry; never rescale rounded pixels. }; @@ -68,9 +68,18 @@ void arrangeWindow(HWND w) layout->x = SetScrollInfo(w, SB_HORZ, &horizontal, TRUE); layout->y = SetScrollInfo(w, SB_VERT, &vertical, TRUE); } + RECT client{}; GetClientRect(w, &client); + int extraWidth = layout->history ? std::max(0, int(client.right) - scale(w, layout->width)) : 0; + int extraHeight = layout->history ? std::max(0, int(client.bottom) - scale(w, layout->height)) : 0; for (const auto &[child, r] : layout->controls) - SetWindowPos(child, nullptr, scale(w, r.left) - layout->x, scale(w, r.top) - layout->y, - scale(w, r.right - r.left), scale(w, r.bottom - r.top), SWP_NOACTIVATE | SWP_NOZORDER); + { + int id = GetDlgCtrlID(child); + SetWindowPos(child, nullptr, scale(w, r.left) - layout->x, + scale(w, r.top) - layout->y + (id >= 110 && id <= 114 ? extraHeight : 0), + scale(w, r.right - r.left) + (id == 101 || id == 102 ? extraWidth : 0), + scale(w, r.bottom - r.top) + (id == 102 ? extraHeight : 0), + SWP_NOACTIVATE | SWP_NOZORDER); + } layout->arranging = false; InvalidateRect(w, nullptr, TRUE); } @@ -1325,7 +1334,7 @@ void refreshHistory(App &app) search(app.historyWindow, v); } } -void showHistory(App &app) +void showHistory(App &app, bool visible) { if (!app.history) { @@ -1333,7 +1342,7 @@ void showHistory(App &app) return; } HWND foreground = GetForegroundWindow(); - if (app.historyWindow && IsWindowVisible(app.historyWindow) && foreground == app.historyWindow) + if (visible && app.historyWindow && IsWindowVisible(app.historyWindow) && foreground == app.historyWindow) { auto *v = (HistoryView *)GetWindowLongPtrW(app.historyWindow, GWLP_USERDATA); if (v->items.size() > 1) @@ -1346,6 +1355,7 @@ void showHistory(App &app) { HWND w = makeWindow(app, L"KSwitcher.History", L"Clipboard history", historyProc, 760, 500); app.historyWindow = w; + windowLayoutState(w)->history = true; ((HistoryView *)GetWindowLongPtrW(w, GWLP_USERDATA))->rich = createRichText(w, 105, false); label(w, L"Search clipboard history", 16, 12, 500); edit(w, 101, 16, 38, 728); @@ -1362,6 +1372,7 @@ void showHistory(App &app) SetWindowSubclass(list, historyKey, 1, (DWORD_PTR)w); SetWindowSubclass(GetDlgItem(w, 101), historyKey, 1, (DWORD_PTR)w); } + if (!visible) return; POINT cursor; GetCursorPos(&cursor); MONITORINFO monitor{sizeof(monitor)}; diff --git a/src/update.h b/src/update.h index a0af82b..bb04d7f 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.5.0"; +inline constexpr wchar_t AppVersion[] = L"0.6.0"; inline constexpr UINT UpdateMessage = WM_APP + 7; struct UpdateResult { diff --git a/tests/input.cpp b/tests/input.cpp index 66a639d..060d07e 100644 --- a/tests/input.cpp +++ b/tests/input.cpp @@ -485,6 +485,20 @@ int main(int argc, char **argv) { fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080}); require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported"); ks::showSettings(app,false); + if (!app.history) app.history=std::make_unique(temp/L"history-layout"); + ks::showHistory(app,false); + auto history=app.historyWindow; + require(history&&!IsWindowVisible(history),"history layout tested without changing focus"); + auto childRect=[&](int id) { RECT r{}; GetWindowRect(GetDlgItem(history,id),&r); MapWindowPoints(nullptr,history,(POINT*)&r,2); return r; }; + RECT historyFrame{}; GetWindowRect(history,&historyFrame); + auto listBefore=childRect(102),searchBefore=childRect(101),buttonBefore=childRect(110); + SetWindowPos(history,nullptr,0,0,historyFrame.right-historyFrame.left+120,historyFrame.bottom-historyFrame.top+180,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE); + auto listAfter=childRect(102),searchAfter=childRect(101),buttonAfter=childRect(110); + require(listAfter.right-listBefore.right==120 && listAfter.bottom-listBefore.bottom==180,"history list fills added width and height"); + require(searchAfter.right-searchBefore.right==120 && searchAfter.top==searchBefore.top,"history search grows horizontally only"); + require(buttonAfter.top-buttonBefore.top==180 && buttonAfter.bottom-listAfter.bottom==buttonBefore.bottom-listBefore.bottom,"history actions stay below list after resize"); + SetWindowPos(history,nullptr,0,0,historyFrame.right-historyFrame.left,historyFrame.bottom-historyFrame.top,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE); + require(EqualRect(&listBefore,&(listAfter=childRect(102))),"history resize roundtrip restores geometry"); auto settings=app.settingsWindow; 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}}) { diff --git a/tests/screenshot_process.ps1 b/tests/screenshot_process.ps1 index fce0e22..0776aab 100644 --- a/tests/screenshot_process.ps1 +++ b/tests/screenshot_process.ps1 @@ -35,24 +35,29 @@ function Select-Region($Window) { Send $Window 0x201 1 ((40 -shl 16) -bor 40) Send $Window 0x200 1 ((240 -shl 16) -bor 360) Send $Window 0x202 0 ((240 -shl 16) -bor 360) - Wait-For { [ScreenshotProcessTest]::Title($Window) -match 'Screenshot #' } 'Selection did not become an editor.' + Wait-For { (Capture-Windows) -notcontains $Window } 'Selection did not close after copying (notifications may be disabled).' } function Close-Editor($Window) { Send $Window 0x10 - Wait-For { @([ScreenshotProcessTest]::Windows($script:process.Id,'#32770')).Count -eq 1 } 'Missing close confirmation.' - $dialog=[ScreenshotProcessTest]::Windows($script:process.Id,'#32770')[0] - Send $dialog (0x400+102) 6 # TDM_CLICK_BUTTON / IDYES; no clipboard writes. - Wait-For { (Capture-Windows) -notcontains $Window } 'Editor did not close.' + Wait-For { (Capture-Windows) -notcontains $Window } 'Unedited copied screenshot did not close.' +} +function Latest-Token { + (Get-ChildItem -LiteralPath (Join-Path $env:LOCALAPPDATA 'KSwitcher/ScreenshotNotifications') -Filter '*.capture' | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1).BaseName } $process=$null try { $process=Start-Process -FilePath $Executable -WindowStyle Hidden -PassThru Wait-For { (Capture-Windows).Count -eq 1 } 'First selection did not open.' - $first=(Capture-Windows)[0]; Select-Region $first - $secondLaunch=Start-Process -FilePath $Executable -WindowStyle Hidden -PassThru - if (!$secondLaunch.WaitForExit(10000) -or $secondLaunch.ExitCode -ne 0) { throw 'Second launch did not hand off.' } - Wait-For { (Capture-Windows).Count -eq 2 } 'Second selection did not open in same process.' - $second=(Capture-Windows | Where-Object { $_ -ne $first })[0]; Select-Region $second + Select-Region (Capture-Windows)[0] + if (!$process.WaitForExit(5000)) { throw 'Process remained alive after notification.' } + $token=Latest-Token + $process=Start-Process -FilePath $Executable -ArgumentList @('--open',"kswitcher-screenshot:$token") -WindowStyle Hidden -PassThru + Wait-For { (Capture-Windows).Count -eq 1 } 'Notification activation did not open an editor.' + $first=(Capture-Windows)[0] + $secondLaunch=Start-Process -FilePath $Executable -ArgumentList @('--open',"kswitcher-screenshot:$token") -WindowStyle Hidden -PassThru + if (!$secondLaunch.WaitForExit(10000) -or $secondLaunch.ExitCode -ne 0) { throw 'Second activation did not hand off.' } + Wait-For { (Capture-Windows).Count -eq 2 } 'Second editor did not open in same process.' + $second=(Capture-Windows | Where-Object { $_ -ne $first })[0] if ([ScreenshotProcessTest]::Title($first) -eq [ScreenshotProcessTest]::Title($second)) { throw 'Editor indices collide.' } Close-Editor $first if ($process.HasExited) { throw 'Process exited with an editor open.' } diff --git a/tests/screenshot_ui.cpp b/tests/screenshot_ui.cpp index 6c96a7b..7917e80 100644 --- a/tests/screenshot_ui.cpp +++ b/tests/screenshot_ui.cpp @@ -4,6 +4,7 @@ #include int main() { + CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED); try { INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES}; InitCommonControlsEx(&controls); ks::ScreenshotHost app{GetModuleHandleW(nullptr)}; @@ -162,7 +163,21 @@ int main() require(IsWindow(window),"Escape does not close the screenshot editor"); SendMessageW(window,WM_CLOSE,0,0); require(!IsWindow(window),"copied screenshot closes without confirmation"); + static bool published=false; + app.notify=[](const ks::Clip &) { return published; }; + 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; + }); + require(published && selection.copied && !IsWindow(overlay),"selection copies before notification and closes without editor"); DestroyWindow(app.window); app.window=nullptr; + CoUninitialize(); std::cout<<"Passed screenshot editor UI integration checks\n"; } catch(const std::exception &e) { std::cerr<