diff --git a/CMakeLists.txt b/CMakeLists.txt index 59e6dc3..2eff0bb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.6.18 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.6.19 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() diff --git a/README.md b/README.md index edba120..39b1ebb 100644 --- a/README.md +++ b/README.md @@ -226,11 +226,16 @@ thumbnails and source labels aligned with the scaled fonts and spacing. If the editor's word boundary differs from the recorded typing, it falls back to paced character selection: Teams can coalesce a burst of Shift+Left events into just one character. - Clipboard snapshot/restoration with concurrent-copy, foreground and input-version checks. + A silently dropped copy is retried up to three total attempts while the clipboard and input + context remain unchanged. Temporary transaction contents are excluded from Windows clipboard + history and cloud synchronization. Paste is not blindly retried. - Case cycling, Russian/Ukrainian transliteration, verified undo and repeated conversion. - Text snippets across layouts, multiline insertion and a verified `{cursor}` position; `{enter}` presses Enter after insertion and cursor placement (once per marker), before buffered typing resumes. For example, `rider .{enter}` inserts `rider .` and presses Enter. Automatic correction is suppressed during snippet expansion and for typing buffered by that expansion; the global setting is preserved. + If the trigger cannot be verified after the copy attempts, it is preserved, queued typing resumes, + and no Enter action is sent. `{{cursor}}` and `{{enter}}` insert literal markers. Snippets are stored as plaintext settings, as in [Typlo](https://github.com/bitcodepro/Typlo). - Clipboard history for text, HTML/RTF, file lists, DIB and PNG images; plain-text paste; full-text search, pinning, deletion, full text viewing and image previews. diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index cc47294..0ac2211 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 942c397..20b154d 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,18 +1,18 @@ # Latest Windows release -## v0.6.18 — 2026-10-31 +## v0.6.19 — 2026-10-31 ## Changes -- Verify the actual editor text after manual conversion using read-only Windows Accessibility, without moving the caret, changing selection or copying again. Record unchanged calculations, original text remaining, unexpected readback and unavailable verification separately. -- Replace the event-only diagnostic list with sortable Time, Program, Command, Source layout, Target layout and Result columns; add program/command/result filters and a separate panel with original, expected and read-back text and operation context. -- Keep successful operations only in a bounded in-memory context (40 events, 30 seconds). Only problem operations create file rows; verified successes do not fill the log. Diagnostics work in normal Release builds and the additional readback check is skipped when logging is disabled. -- Refresh English and Ukrainian Wiki documentation through v0.6.18, including previously undocumented snippet, updater and constrained-pencil changes since v0.6.14. - -## Limitations -Some editors do not expose their text through Windows Accessibility. Changed focus, protected or unsupported controls, provider-specific character units, delayed text updates and the 4096-character readback limit can prevent verification; these cases are recorded as unverified rather than successful. Historical unstructured entries remain readable without retroactive filter fields. +- Retry a silently dropped Ctrl+C up to three total attempts, only while the transaction clipboard remains unchanged and the original input context is still valid. Failed snippet verification preserves the trigger and following input without sending Enter. +- Exclude temporary transaction clipboard contents from Windows clipboard history and cloud synchronization. The original clipboard snapshot is still restored after the operation when ownership permits. +- Fix snippet integration test isolation: restore a multiline editor after earlier single-line scenarios, reset layout/correction state, coordinate test-editor clipboard access and disable unrelated background update checks. +- Add deterministic coverage for one dropped copy and exhausted retries. Keep exact assertions for Enter ordering, buffered typing, clipboard restoration and subsequent auto-correction. ## Validation -Passed 230 native regression checks, 472 input/UI checks, screenshot editor UI and notification registration checks, 48 updater checks, updater progress checks, 45 isolated configurator process checks, conversion-specific editor integration and diagnostic viewer checks. Verified that 1000 successful operations create no log file and that a subsequent failure creates one row with bounded context. The broader editor integration suite still intermittently fails in snippet Enter/concurrent-input scenarios; similar failures reproduce with the pre-change code. +The fixes passed 230 native regression checks, 472 input/UI checks, screenshot editor UI and notification registration checks, 48 updater checks and updater progress checks. The full editor integration suite passed all 63 assertions; conversion-specific diagnostics also passed. The final snippet suite passed 20 consecutive runs, including injected copy failures and the three-attempt limit. The v0.6.19 staged Release build also passed the native, input/UI, screenshot and updater checks; the installed application was not replaced or restarted. + +## Limitations +The fix does not retry paste blindly: an editor may already have changed its selection or text. Clipboard contention, editor capabilities and input privileges can still prevent an operation. Existing accessibility readback limitations and problem-only diagnostic logging remain unchanged. **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. diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index 66f3219..8af43a8 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -3e76d8a566c623a3084c00cc909f4cc7c8a6edb38f9c06adbb32edfd4cfcb5b7 KSwitcher-win-x64.zip +8b2008e97ca625b7eebdf509c038d6d1f6c5c853dce6da1b939abaa18223e26d KSwitcher-win-x64.zip diff --git a/resources/app.rc b/resources/app.rc index 8ace164..7fce710 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,18,0 - PRODUCTVERSION 0,6,18,0 + FILEVERSION 0,6,19,0 + PRODUCTVERSION 0,6,19,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.18" + VALUE "FileVersion", "0.6.19" END END BLOCK "VarFileInfo" diff --git a/src/app.cpp b/src/app.cpp index b90b156..9ac5337 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -124,7 +124,13 @@ class Transaction { check(); adopted.clear(); - app.ownSequence = expected = writeClipboard(app.window, c); + // These are temporary transaction contents, not a user's clipboard entry. + // In particular, do not invite the Windows history service to read our + // sentinel while the target editor is trying to open the clipboard. + auto temporary = c; + const std::wstring exclusion = L"ExcludeClipboardContentFromMonitorProcessing"; + temporary.formats.push_back({RegisterClipboardFormatW(exclusion.c_str()), exclusion, Bytes{0}}); + app.ownSequence = expected = writeClipboard(app.window, temporary); // A concurrent writer can win immediately after CloseClipboard, before we read its sequence. // Never adopt that writer's sequence as our own and paste its content. if (GetClipboardOwner() != app.window) @@ -146,6 +152,8 @@ class Transaction return {}; } ULONGLONG deadline = GetTickCount64() + 2000; + ULONGLONG retryAt = GetTickCount64() + 250; + unsigned attempts = 1; DWORD changed = before; ULONGLONG stable = 0; bool changedAgain = false; @@ -162,6 +170,21 @@ class Transaction } if (changed != before && GetTickCount64() - stable >= 60) break; + // Native editors can silently drop Ctrl+C if another process has + // the clipboard open. Retry only while our sentinel is untouched; + // never overwrite a copy result or another application's content. + if (changed == before && attempts < 3 && GetTickCount64() >= retryAt && + !GetOpenClipboardWindow()) + { + check(); + if (!valid() || GetClipboardSequenceNumber() != before) + return {}; + if (!chord(L'C', MOD_CONTROL)) + return {}; + diagnosticEvent(L"Copy retry: clipboard unchanged after Ctrl+C"); + ++attempts; + retryAt = GetTickCount64() + 250; + } Sleep(10); } if (changed == before || !valid()) diff --git a/src/update.h b/src/update.h index ecb15d7..0f50084 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.18"; +inline constexpr wchar_t AppVersion[] = L"0.6.19"; 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 7e3871b..1d6c080 100644 --- a/tests/integration.cpp +++ b/tests/integration.cpp @@ -25,8 +25,51 @@ bool captured = false, failed = false; bool conversionOnly = false; int pasteBehavior = 0; int enterPresses = 0; +int copyFailuresRemaining = 0, copyRequests = 0; +bool temporaryClipboardExcluded = true; +bool clipboardAccessTimedOut = false; LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR) { + // This bare EDIT control is our test fixture, not a full editor with + // clipboard retries. Keep clipboard access on its owning UI thread while + // it handles Ctrl+C/Ctrl+V, so unrelated Windows clipboard listeners cannot + // make the fixture silently discard the operation under test. + struct ClipboardAccess + { + bool opened = false; + ~ClipboardAccess() { if (opened) CloseClipboard(); } + } access; + if (message == WM_CHAR && (wp == 3 || wp == 22)) + { + auto deadline = GetTickCount64() + 1000; + do + { + if (OpenClipboard(w)) { access.opened = true; break; } + Sleep(10); + } while (GetTickCount64() < deadline); + if (!access.opened) + { + clipboardAccessTimedOut = true; + return 0; + } + } + if ((message == WM_CHAR && wp == 3) || message == WM_PASTE) + { + temporaryClipboardExcluded &= IsClipboardFormatAvailable( + RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing")) != FALSE; + if (message == WM_CHAR) + { + ++copyRequests; + // Native EDIT silently ignores Ctrl+C when OpenClipboard fails. + // Reproduce that outcome deterministically, without depending on + // the timing of the Windows clipboard-history service. + if (copyFailuresRemaining > 0) + { + --copyFailuresRemaining; + return 0; + } + } + } if (message == WM_PASTE && pasteBehavior) { if (pasteBehavior == 2) SendMessageW(w, EM_REPLACESEL, TRUE, (LPARAM)L"other!"); @@ -51,13 +94,15 @@ std::string diagnosticLog() size_t diagnosticStart{}; void require(bool ok, const char *name) { - if (!ok) + if (!ok || clipboardAccessTimedOut) { wchar_t observed[2048]{}; GetWindowTextW(field, observed, 2048); std::cerr << "Editor at failure: " << ks::utf8(observed) << "; foregroundSame=" << (GetForegroundWindow() == host) << "; inputVersion=" << app->inputVersion.load() << '\n'; + if (clipboardAccessTimedOut) + throw std::runtime_error("Test editor could not acquire clipboard within 1000 ms"); throw std::runtime_error(name); } std::cout << "PASS " << name << '\n'; @@ -109,6 +154,9 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp) return 0; if (msg == WM_TIMER) { + // Input tests must not start a network update check. Its close veto can + // otherwise leave a completed test running after the editor is gone. + if (app->window) KillTimer(app->window, 5); try { if (GetTickCount64() > deadline) @@ -257,6 +305,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp) DestroyWindow(field); field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_PASSWORD, 20, 20, 590, 30, host, nullptr, GetModuleHandleW(nullptr), nullptr); + SetWindowSubclass(field, observeEnter, 1, 0); prepare(L"ghbdtn ", false); record(L"ghbdtn "); app->transform(ks::AutoCorrect); @@ -268,6 +317,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp) DestroyWindow(field); field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER, 20, 20, 590, 30, host, nullptr, GetModuleHandleW(nullptr), nullptr); + SetWindowSubclass(field, observeEnter, 1, 0); prepare(L"(ljvf ", false); record(L"(ljvf "); app->transform(ks::AutoCorrect); @@ -347,6 +397,18 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp) case 30: case 32: case 34: + case 37: + // Earlier password/word tests replace the original multiline + // editor with a single-line EDIT. Restore the snippet fixture: + // a single-line control cannot insert the tested Enter action. + if (!(GetWindowLongPtrW(field, GWL_STYLE) & ES_MULTILINE)) + { + DestroyWindow(field); + field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | + ES_MULTILINE | ES_WANTRETURN, 20, 20, 590, 230, host, nullptr, + GetModuleHandleW(nullptr), nullptr); + SetWindowSubclass(field, observeEnter, 1, 0); + } if (!captured) { saved = ks::readClipboard(app->window, 64 * 1024 * 1024); @@ -355,9 +417,16 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp) app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel)); app->settings.autoCorrect = stage == 35; app->settings.autoLanguages = {L"en", L"ru"}; + app->settings.cycle = {app->layouts[en].id, app->layouts[ru].id}; + app->settings.switchLayout = false; + app->autoCorrector = ks::AutoCorrector{}; + app->autoPredictor = ks::AutoCorrector{}; app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages); app->settings.snippets = {{L";r", stage == 31 ? L"rider .{cursor}" : L"rider .{cursor}{enter}"}}; enterPresses = 0; + copyRequests = 0; + copyFailuresRemaining = stage == 38 ? 10 : stage == 33 ? 1 : 0; + temporaryClipboardExcluded = true; ActivateKeyboardLayout(app->layouts[en].handle, 0); prepare(L"prefix ", false); { @@ -384,6 +453,10 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp) break; case 33: require(text() == L"prefix rider .\r\n x", "Enter action precedes buffered typing"); + require(copyRequests == 2 && copyFailuresRemaining == 0, + "snippet retries a silently dropped copy without duplicating input"); + require(temporaryClipboardExcluded, + "temporary snippet clipboard contents are excluded from Windows history"); require(enterPresses == 1, "Enter marker sends an actual Enter key press"); require(ks::readClipboard(app->window).text() == sentinel, "Enter snippet restores clipboard"); stage = 34; @@ -407,6 +480,15 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp) break; case 36: require(text() == L"prefix rider .\r\nghbdtn привет ", "automatic correction resumes for subsequent normal typing"); + stage = 37; + break; + case 38: + require(copyRequests == 3, "copy retries are bounded to three attempts"); + require(text() == L"prefix ;r x" && enterPresses == 0, + "exhausted copy retries preserve trigger and following input without Enter"); + require(ks::readClipboard(app->window).text() == sentinel, + "exhausted copy retries restore the clipboard"); + copyFailuresRemaining = 0; finish(); break; case 20: