diff --git a/CMakeLists.txt b/CMakeLists.txt index 8c1d9be..2adce1c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.6.35 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.6.36 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() diff --git a/README.md b/README.md index 7674650..a55d9ed 100644 --- a/README.md +++ b/README.md @@ -707,3 +707,7 @@ supporting old clients; their immutable update code only reads that page. ### Editors that omit copied trailing spaces From v0.6.31, KSwitcher recovers trailing ordinary spaces omitted by Ctrl+C only when Windows accessibility proves the complete selection. The same focused control and range are rechecked before paste, and the result is read back even without Write log. Recent-text conversion, automatic correction, repeat conversion and Undo retain exact verification. Missing letters, tabs, line breaks and unavailable or ambiguous selections are not reconstructed. No setting or shortcut has changed. + +### Notification registration and Viber — v0.6.36 + +Notification registration runs in KSwitcher.Screenshot.exe; keep all three executables together. Startup registration is asynchronous. Viber pastes allow 75 ms for clipboard notifications before Ctrl+V, retaining focus, ownership and selection checks. This mitigates an intermittent clipboard timing issue; it does not guarantee all Viber failures are resolved. UI Automation is unchanged. diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index 3c699f7..d35aaaf 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 c186a77..1c48595 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,18 +1,17 @@ # Latest Windows release -## v0.6.35 — 2026-11-04 +## v0.6.36 — 2026-11-04 ## Changes -- Reduced resident memory: the input predictor and correction worker share immutable dictionaries. Compact indexed blocks retain all 140,939 bundled words and their frequency ranks while reducing the EN/UK/RU lookup payload from approximately 3.06 MiB per copy to 0.90 MiB shared. Custom UTF-8 dictionary files, duplicate handling, frequency limits and personal dictionaries remain compatible. -- Closing clipboard history releases its duplicate row list and Windows list strings, retains selection/scroll identifiers for reopening, and rejects late search results and timers. Preview caches now have payload budgets of 1 MiB for thumbnails and 256 KiB for formatted text, plus the existing item-count limit. Visible entries are retained even if an unusually large viewport exceeds a budget. -- PNG decoding reads the existing input buffer without making another full PNG copy. DIB decoding allocates its temporary BMP buffer once at the required size. -- Idle maintenance releases diagnostic context older than 30 seconds and expired Undo text after its existing two-minute eligibility window, without interrupting an active operation. Completed sync buffers are released on success, failure and stale responses; unresolved conflicts remain available. -- No shortcut, default, on-disk history format or screenshot-editing behavior changed. The v0.6.34 four-direction movement cursors remain included. +- Notification registration now runs in the existing KSwitcher.Screenshot.exe helper. Startup launches it asynchronously and checks its exit status without blocking keyboard work; update notification registration waits in its background worker. Keep all three executables together. UI Automation and notification activation routes remain unchanged. +- Viber paste now allows 75 ms for clipboard notifications to settle before sending Ctrl+V, then rechecks input, focus and clipboard ownership. This mitigates a suspected clipboard timing race: an intermittent corrupted Cyrillic paste was reproduced on the prior build. It is not a guarantee that every Viber timing issue is resolved; no blind paste retries or relaxed selection checks were added. +- Added regression coverage for an explicitly selected middle range followed by a repeated shortcut without selection, preserving surrounding text, trailing spaces and the original clipboard. Updated the integration fixture to initialize shared predictor dictionaries. +- Added reproducible standalone COM, UI Automation and Shell memory measurement tools. No shortcuts, settings or defaults changed. ## Validation -The full `build.ps1 -Stage -Test` suite covers complete bundled dictionary word/rank equivalence, malformed/custom dictionary boundaries, 105 sync checks, shared wheel batching, 304 native regressions, 559 input/UI checks, screenshot editor UI and PNG saving, isolated screenshot registration, 48 updater regressions and updater progress UI checks. Cache assertions now run once outside the exception helper rather than being repeated inside it. All three executable versions and all 12 package manifest file sizes/SHA-256 hashes were verified; no personal data is packaged. +Full staged build and tests cover dictionary equivalence for all 140,939 words, 105 sync checks, wheel batching, 304 native checks, 559 input checks, screenshot UI/PNG/registration, 48 updater checks and updater progress UI. Conversion integration passed 43 assertions. Eight actual Viber conversions passed on the development build, including two complete long-text layout cycles and selected/repeated middle-word conversion; test messages were not sent. -Controlled local measurements of the final dictionary step, after equal restart/wait periods with three dictionaries enabled, reduced private resident memory from 7.08 to 4.65 MiB and private committed memory from 8.34 to 5.70 MiB. These compare consecutive development builds, not the published v0.6.34 with the full cumulative change. Randomized lookup tests were approximately 20% faster. Results depend on settings, workload, Windows and allocator state; these are not fixed memory limits. The 0.90 MiB dictionary figure excludes process/allocator overhead. +A controlled fresh-launch before/after comparison (five private-working-set samples each, same installation and settings) measured 4.69 → 4.18 MiB, about 0.51 MiB saved. This is one local comparison, not a fixed memory limit or a long-term idle guarantee. The prior compact dictionaries and cache/idle memory reductions remain included. ## 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. @@ -20,4 +19,4 @@ For manual installation, exit KSwitcher, close screenshot editors and extract th Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. Keep all three executables together. The package contains no personal settings, credentials or clipboard history. -Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag. +Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag. \ No newline at end of file diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index 5b43f2e..0f30906 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -6e36a582eec59b3cc4f0f9d6b3e05cd699371c05cae30a95c6eae51a838f7bfe KSwitcher-win-x64.zip +794d74113bddf13ffee7317cadee9c472bca3b84b3361796db58ff8023f66571 KSwitcher-win-x64.zip diff --git a/resources/app.rc b/resources/app.rc index 402d88b..91be8a8 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,35,0 - PRODUCTVERSION 0,6,35,0 + FILEVERSION 0,6,36,0 + PRODUCTVERSION 0,6,36,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.35" + VALUE "FileVersion", "0.6.36" END END BLOCK "VarFileInfo" diff --git a/src/app.cpp b/src/app.cpp index 6232a56..b359b02 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -358,6 +358,14 @@ class Transaction if (!valid()) return false; write(clip); + // Viber's Qt editor observes clipboard changes asynchronously. Give it + // a chance to discard the data object from Ctrl+C before requesting paste. + // Recheck ownership and input below; never retry a possibly applied paste. + if (lower(processName(target)) == L"viber") + { + Sleep(75); + diagnosticEvent(L"Paste: Viber clipboard notification settling interval completed"); + } for (int i = 0; i < 2; ++i) { check(); @@ -433,6 +441,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan } App::~App() { + if (notificationRegistration) CloseHandle(notificationRegistration); if (syncWorker.joinable()) syncWorker.join(); if (updateWorker.joinable()) updateWorker.join(); @@ -1893,6 +1902,14 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) case WM_TIMER: if (wp == 6) { + if (app->notificationRegistration && WaitForSingleObject(app->notificationRegistration, 0) == WAIT_OBJECT_0) + { + DWORD code = ERROR_GEN_FAILURE; + GetExitCodeProcess(app->notificationRegistration, &code); + if (code) log(L"Screenshot notification registration helper failed: " + std::to_wstring(code)); + CloseHandle(app->notificationRegistration); + app->notificationRegistration = nullptr; + } app->releaseIdleMemory(GetTickCount64()); app->pollSync(); return 0; @@ -2012,7 +2029,7 @@ int App::execute() log(std::wstring(L"Update completed; running version ") + AppVersion); if (startCommand) PostMessageW(window, WM_HOTKEY, startCommand, 0); - try { registerScreenshotNotifications(); } + try { notificationRegistration = startScreenshotNotificationRegistration(); } catch(const std::exception &e) { log(L"Screenshot notification registration: "+wide(e.what())); } auto screenshotActivation=startScreenshotActivationListener(); MSG msg; diff --git a/src/app.h b/src/app.h index 19d5918..a02d580 100644 --- a/src/app.h +++ b/src/app.h @@ -92,6 +92,7 @@ class App std::condition_variable jobsReady; std::deque> jobs; std::thread worker; + HANDLE notificationRegistration{}; std::thread historySearchWorker; std::condition_variable historySearchReady; std::function pendingHistorySearch; diff --git a/src/screenshot_activation.cpp b/src/screenshot_activation.cpp index a003e95..df27a3b 100644 --- a/src/screenshot_activation.cpp +++ b/src/screenshot_activation.cpp @@ -50,6 +50,30 @@ void registerScreenshotNotifications() // Notify the shell that notification identity/activation registration changed. SHChangeNotify(SHCNE_ASSOCCHANGED,SHCNF_IDLIST,nullptr,nullptr); } +HANDLE startScreenshotNotificationRegistration() +{ + auto path=std::filesystem::path(executable()).parent_path()/L"KSwitcher.Screenshot.exe"; + auto command=L"\""+path.wstring()+L"\" --register-notifications"; + STARTUPINFOW startup{sizeof(startup)}; + startup.dwFlags=STARTF_USESHOWWINDOW; startup.wShowWindow=SW_HIDE; + PROCESS_INFORMATION process{}; + if(!CreateProcessW(path.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_NO_WINDOW, + nullptr,path.parent_path().c_str(),&startup,&process)) + throw std::runtime_error(utf8(lastError(L"Cannot start notification registration helper"))); + CloseHandle(process.hThread); + return process.hProcess; +} +void registerScreenshotNotificationsInHelper() +{ + auto process=startScreenshotNotificationRegistration(); + auto wait=WaitForSingleObject(process,10000); + DWORD code=ERROR_GEN_FAILURE; + bool completed=wait==WAIT_OBJECT_0 && GetExitCodeProcess(process,&code); + CloseHandle(process); + if(!completed || code) + throw std::runtime_error(completed ? "Notification registration helper failed: "+std::to_string(code) + : "Notification registration helper did not complete within 10 seconds"); +} bool validScreenshotToken(const std::wstring &id) { if(id.size()!=36) return false; diff --git a/src/screenshot_notifications.cpp b/src/screenshot_notifications.cpp index d25118e..70548f3 100644 --- a/src/screenshot_notifications.cpp +++ b/src/screenshot_notifications.cpp @@ -206,7 +206,7 @@ bool notifyUpdateAvailable(const std::wstring &version) { try { // Reuse the screenshot notification identity, Windows settings and COM activator. - registerScreenshotNotifications(); + registerScreenshotNotificationsInHelper(); ComPtr manager; String managerClass(L"Windows.UI.Notifications.ToastNotificationManager"); check(RoGetActivationFactory(managerClass.value,IID_PPV_ARGS(&manager))); diff --git a/src/screenshot_notifications.h b/src/screenshot_notifications.h index b5d3bfa..53e10dd 100644 --- a/src/screenshot_notifications.h +++ b/src/screenshot_notifications.h @@ -4,6 +4,10 @@ namespace ks { bool notifyScreenshot(const Clip &); std::string screenshotNotificationError(); void registerScreenshotNotifications(); +// Caller owns the returned process handle; the helper exits after registration. +HANDLE startScreenshotNotificationRegistration(); +// For background notification workers only; bounded wait, never on the UI thread. +void registerScreenshotNotificationsInHelper(); int runScreenshotToastActivator(); DWORD startScreenshotActivationListener(); bool validScreenshotToken(const std::wstring &); diff --git a/src/update.h b/src/update.h index 833c7d1..ee7f42a 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.35"; +inline constexpr wchar_t AppVersion[] = L"0.6.36"; inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000; diff --git a/tests/integration.cpp b/tests/integration.cpp index 431cad0..c053a2d 100644 --- a/tests/integration.cpp +++ b/tests/integration.cpp @@ -529,6 +529,9 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp) require(text() == L"wrong ", "unrelated selection remains rejected with trimmed clipboard"); app->settings.autoCorrect = true; app->settings.autoLanguages = {L"en", L"ru"}; + // The worker now shares the predictor's immutable dictionaries. + // This fixture changes settings directly instead of applySettings. + app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages); app->autoCorrector = ks::AutoCorrector{}; ActivateKeyboardLayout(app->layouts[en].handle, 0); prepare(L"ghbdtn ", false); @@ -556,9 +559,22 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp) require(trimmedCopies >= 7, "fixture actually stripped spaces during recovery scenarios"); pasteBehavior = 0; trimCopiedSpaces = false; - finish(); + // User-selected middle range, followed by the same command with + // no selection: retain both surrounding text and trailing spaces. + prepare(L"OLDghbdtn NEXT", false); + SendMessageW(field, EM_SETSEL, 3, 11); + app->transform(ks::Direct + ru); break; } + case 58: + require(text() == L"OLDпривет NEXT", "explicit middle selection preserves surrounding text"); + app->transform(ks::Direct + en); + break; + case 59: + require(text() == L"OLDghbdtn NEXT", "hotkey without selection reconverts only the previous middle range"); + require(ks::readClipboard(app->window).text() == sentinel, "selected and repeated conversions restore clipboard"); + finish(); + break; case 30: case 32: case 34: diff --git a/tests/screenshot_registration.cpp b/tests/screenshot_registration.cpp index 4e9cc0a..e4790d2 100644 --- a/tests/screenshot_registration.cpp +++ b/tests/screenshot_registration.cpp @@ -5,9 +5,11 @@ static std::wstring programs; static std::wstring activationExecutable, activationCommand; static int activationCalls{}; +static DWORD launchFlags{}; static bool inheritsHandles{}; static BOOL WINAPI testCreateProcess(LPCWSTR path, LPWSTR command, LPSECURITY_ATTRIBUTES, - LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, LPCWSTR, LPSTARTUPINFOW, LPPROCESS_INFORMATION) + LPSECURITY_ATTRIBUTES, BOOL inherit, DWORD flags, LPVOID, LPCWSTR, LPSTARTUPINFOW, LPPROCESS_INFORMATION) { + launchFlags=flags; inheritsHandles=inherit; ++activationCalls; activationExecutable=path; activationCommand=command; SetLastError(ERROR_CANCELLED); return FALSE; // Inspect activation without starting any program. } @@ -55,6 +57,14 @@ int main() 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. + bool launchFailed=false; + try { ks::startScreenshotNotificationRegistration(); } + catch(const std::exception &) { launchFailed=true; } + if(!launchFailed || activationExecutable!=helper.wstring() || + activationCommand!=L"\""+helper.wstring()+L"\" --register-notifications" || + !(launchFlags&CREATE_NO_WINDOW) || inheritsHandles) + throw std::runtime_error("registration must launch the sibling helper and propagate failure"); + activationCalls=0; ks::Activator activator(helper.wstring(),false); activator.Activate(L"HrynCo.KSwitcher.Screenshot",L"check-update",nullptr,0); auto main=(helper.parent_path()/L"KSwitcher.exe").wstring(); diff --git a/tools/measure-memory-components.ps1 b/tools/measure-memory-components.ps1 new file mode 100644 index 0000000..a7e68a2 --- /dev/null +++ b/tools/measure-memory-components.ps1 @@ -0,0 +1,61 @@ +param([ValidateRange(1,20)][int]$Runs=3,[ValidateRange(100,10000)][int]$SettleMilliseconds=1500) +# Private resident pages are counted externally, so the measurement buffer is +# allocated in PowerShell, not in the measured process. Never trim working sets. +$ErrorActionPreference='Stop' +$root=Split-Path $PSScriptRoot -Parent +$output=Join-Path $root 'build/memory-components' +New-Item -ItemType Directory -Force $output | Out-Null +$exe=Join-Path $output 'probe.exe' +& "$root/.tools/w64devkit/bin/g++.exe" -std=c++20 -Os -static -s -DUNICODE -D_UNICODE -D_WIN32_WINNT=0x0A00 "$PSScriptRoot/memory-components.cpp" -o $exe -lole32 -loleaut32 -lshell32 -lpropsys -lshlwapi -luuid -luiautomationcore +if($LASTEXITCODE) { throw 'Probe compilation failed' } +Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class ComponentWorkingSet { + [DllImport("psapi.dll", SetLastError=true)] static extern bool QueryWorkingSet(IntPtr process,IntPtr buffer,int size); + public static long PrivateBytes(IntPtr process) { + int size=4*1024*1024; + IntPtr buffer=Marshal.AllocHGlobal(size); + try { + if(!QueryWorkingSet(process,buffer,size)) throw new System.ComponentModel.Win32Exception(); + long count=Marshal.ReadIntPtr(buffer).ToInt64(), pages=0; + if(count>(size/IntPtr.Size)-1) throw new InvalidOperationException("Working set buffer is too small"); + for(int i=0;i +#include +#include +#include +#include +#include +#include +#include + +static void check(HRESULT hr) { + if (FAILED(hr)) { fprintf(stderr,"HRESULT=%08lx\n",(unsigned long)hr); exit(2); } +} +static void phase(const char *name) { + printf("%s\n",name); fflush(stdout); + if (getchar()==EOF) exit(3); +} +int main(int argc,char **argv) { + const char *mode=argc>1?argv[1]:"baseline"; + const bool baseline=!strcmp(mode,"baseline"); + const bool shell=!strcmp(mode,"shell") || !strcmp(mode,"combined"); + const bool uia=!strcmp(mode,"uia") || !strcmp(mode,"focused") || !strcmp(mode,"combined"); + phase("baseline"); + if(!baseline) check(CoInitializeEx(nullptr,!strcmp(mode,"sta") || shell ? COINIT_APARTMENTTHREADED : COINIT_MULTITHREADED)); + phase("com"); + IShellLinkW *link{}; IPropertyStore *store{}; IPersistFile *file{}; + IUIAutomation2 *automation{}; IUIAutomationElement *element{}; IUIAutomationTextPattern *pattern{}; + wchar_t temporary[MAX_PATH]{},shortcut[MAX_PATH]{},exe[MAX_PATH]{}; + if(shell) { + check(CoCreateInstance(CLSID_ShellLink,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&link))); + GetModuleFileNameW(nullptr,exe,MAX_PATH); + check(link->SetPath(exe)); check(link->SetIconLocation(exe,0)); + check(link->QueryInterface(IID_PPV_ARGS(&store))); + PROPVARIANT value{}; check(InitPropVariantFromString(L"KSwitcher.MemoryProbe",&value)); + check(store->SetValue(PKEY_AppUserModel_ID,value)); PropVariantClear(&value); + GUID id{0xe3f78047,0xf0ad,0x49b6,{0x90,0x74,0x41,0x19,0x31,0x70,0x61,0x67}}; + value.vt=VT_CLSID; value.puuid=&id; + check(store->SetValue(PKEY_AppUserModel_ToastActivatorCLSID,value)); check(store->Commit()); + PWSTR programs{}; check(SHGetKnownFolderPath(FOLDERID_Programs,0,nullptr,&programs)); CoTaskMemFree(programs); + GetTempPathW(MAX_PATH,temporary); if(!GetTempFileNameW(temporary,L"ksm",0,shortcut)) return 4; + check(link->QueryInterface(IID_PPV_ARGS(&file))); check(file->Save(shortcut,TRUE)); + } + if(uia) { + check(CoCreateInstance(CLSID_CUIAutomation8,nullptr,CLSCTX_INPROC_SERVER,IID_PPV_ARGS(&automation))); + check(automation->put_ConnectionTimeout(200)); check(automation->put_TransactionTimeout(200)); + if(strcmp(mode,"uia")) { + HRESULT focused=automation->GetFocusedElement(&element); + fprintf(stderr,"focus=%08lx\n",(unsigned long)focused); + if(SUCCEEDED(focused) && element) { + BOOL password=TRUE,enabled=FALSE; CONTROLTYPEID type{}; + element->get_CurrentIsPassword(&password); element->get_CurrentIsEnabled(&enabled); element->get_CurrentControlType(&type); + fprintf(stderr,"type=%d enabled=%d protected=%d\n",type,enabled,password); + if(!password) fprintf(stderr,"pattern=%08lx\n",(unsigned long)element->GetCurrentPatternAs(UIA_TextPatternId,IID_PPV_ARGS(&pattern))); + } + } + } + phase("active"); + if(pattern) pattern->Release(); if(element) element->Release(); if(automation) automation->Release(); + if(file) file->Release(); if(store) store->Release(); if(link) link->Release(); + if(shortcut[0]) DeleteFileW(shortcut); + phase("released"); + if(!baseline) { CoFreeUnusedLibrariesEx(0,0); CoUninitialize(); } + phase("uninitialized"); + return 0; +}