Compare commits

..

1 Commits

Author SHA1 Message Date
agrynco 89e506faae Release v0.6.31: verify trailing spaces omitted by editor copy 2026-11-03 14:45:35 +02:00
10 changed files with 189 additions and 27 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.30 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.31 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
+4
View File
@@ -689,3 +689,7 @@ Keep `v0.4.2` as the last legacy `vX.Y.Z` release. Publish full packages as
legacy version tag: clients that have not installed the bridge would select it.
Keep the compatibility bridge in the first 100 releases returned by Gitea while
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.
Binary file not shown.
+8 -16
View File
@@ -1,29 +1,21 @@
# Latest Windows release
## v0.6.30 — 2026-11-02
## v0.6.31 — 2026-11-03
## Changes
- A manual paste confirmed unsuccessful by readback no longer records the expected replacement as a previous result or switches the Windows layout. This prevents repeated commands from searching for text that was never inserted.
- A previous-result verification mismatch clears stale repeat-conversion state.
- A writable native Windows EDIT field may receive one guarded paste retry: readback must equal the original text, and fresh native checks must confirm the exact original selection, unchanged focus and input context. Other controls, protected/read-only fields and unknown/changed selections are not retried.
- The guarded retry and failed-paste readback handling require **Write log**, which enables manual-conversion readback. Clipboard restoration remains conditional on ownership.
- Fix conversion failures when an editor such as Viber omits trailing ASCII spaces from Ctrl+C. KSwitcher recovers those spaces only when Windows accessibility confirms the full selection in the target application's focused, non-password control. Exact recent-text and previous-result checks still apply.
- Recheck the same control and full selection before pasting a recovered range. Read back the replacement even when Write log is disabled; an unknown or mismatching result does not seed repeat conversion or switch the layout.
- The shared copy path supports manual conversion, automatic dictionary correction, repeat conversion and Undo. No shortcut, setting or default has changed.
## Validation
The final isolated conversion integration run passed: permanently blocked paste, unexpected editor content, clearing fictional previous results, ignored-first-paste recovery, clipboard restoration, repeat conversion and partial-selection recovery. Typo integration also passed, including Undo and uninterrupted typing. 541 input/UI checks (240 DPI), 105 sync/protocol checks, 483 native regressions and 48 updater checks passed. All release executable versions and manifest hashes were verified. Earlier interactive attempts were interrupted by concurrent input or a transient clipboard-restoration assertion; the final isolated rerun passed.
Live Viber validation converted `Ghbdsn ` to `Привіт ` and confirmed the retained trailing space without sending a message. Final isolated conversion and typo integration runs passed, including real clipboard-space stripping, two spaces within surrounding text, repeat conversion, Undo, missing/unrelated selection rejection, and blocked-paste state clearing with logging disabled. 483 native, 541 input/UI, 105 sync and 48 updater checks passed, as did wheel batching, screenshot PNG saving, screenshot registration and updater progress checks. Earlier integration attempts encountered clipboard contention/restoration and log-rotation assertions; the isolated final rerun passed. The unchanged screenshot UI suite still fails at `middle drag pans enlarged canvas`; screenshot implementation and test source are unchanged from v0.6.30. All three packaged executable versions and all 12 manifest file sizes/hashes were verified; no personal data is packaged.
## Limitations
The cause of every ignored paste is not established. This release does not claim to solve all asynchronous selection failures in ChatGPT/browser editors. Keep Write log enabled and send complete operation details if the issue recurs. Native retry is bounded to one attempt; it is not a general retry policy for other applications. All existing dictionary-correction and synchronization limitations remain.
Recovery requires a non-empty clipboard prefix and a single readable accessibility selection whose only additional characters are trailing ordinary spaces. Tabs, line breaks, missing letters, ambiguous or inaccessible selections are not reconstructed. Accessibility and editor timing can still prevent a conversion. This is not a general fix for all asynchronous editor failures.
**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, close screenshot editors 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 includes all three executables, a release manifest, offline dictionaries, documentation and license notices. No personal settings, passwords, server address or clipboard history are included.
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. Keep all three executables together. The package contains no personal settings, credentials or clipboard history.
Build and package:
```powershell
.\build.ps1 -Stage -Test
.\package.ps1 -PublishRelease
```
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.
+1 -1
View File
@@ -1 +1 @@
7e557851ab68a37a8211ea39de41e79314f460ef8ac6471be723d0cfe03cfba9 KSwitcher-win-x64.zip
c02b422a6a7af97f7ffc64d7a526a9fae523583493fcddeb4b80c9aee254667a KSwitcher-win-x64.zip
+3 -3
View File
@@ -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,30,0
PRODUCTVERSION 0,6,30,0
FILEVERSION 0,6,31,0
PRODUCTVERSION 0,6,31,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.30"
VALUE "FileVersion", "0.6.31"
END
END
BLOCK "VarFileInfo"
+60 -3
View File
@@ -76,6 +76,8 @@ class Transaction
mutable bool invalidLogged{};
unsigned copyNumber{};
std::wstring adopted;
std::unique_ptr<PasteProbe> selectionProof;
std::wstring provenSelection;
public:
Transaction(App &a, HWND t, uint64_t v) : app(a), target(t), version(v)
@@ -250,8 +252,27 @@ class Transaction
}
}
}
selectionProof.reset();
provenSelection.clear();
// Viber and some other editors omit trailing ASCII spaces from Ctrl+C.
// Recover only from a real selection, never from the recorded keys alone.
if (valid() && !text.empty() && text.size() < 8192 && text.back() != L' ')
{
auto proof = std::make_unique<PasteProbe>(true, target);
auto selected = proof->read(1, false);
if (selected && selected->size() > text.size() && selected->starts_with(text) &&
selected->find_first_not_of(L' ', text.size()) == std::wstring::npos && valid())
{
diagnosticEvent(L"Copy: trailing spaces verified via accessibility copied=" +
diagnosticText(text) + L" selected=" + diagnosticText(*selected));
text = *selected;
provenSelection = text;
selectionProof = std::move(proof);
}
}
return text;
}
bool recoveredSelection() const { return selectionProof != nullptr; }
bool select(size_t n)
{
return selectPrevious(n, [&] { return valid(); });
@@ -324,6 +345,15 @@ class Transaction
}
bool paste(const Clip &clip)
{
if (selectionProof)
{
auto selected = selectionProof->read(1, false);
if (!selected || *selected != provenSelection || !valid())
{
diagnosticEvent(L"Paste cancelled: recovered selection changed or unavailable");
return false;
}
}
if (!valid())
return false;
write(clip);
@@ -1341,13 +1371,30 @@ void App::transform(int command, int snippet)
}
}
bool sent = false;
PasteProbe probe(config.writeLog && manual && result != text, text);
const bool requireReadback = tx.recoveredSelection();
PasteProbe probe(((config.writeLog && manual) || requireReadback) && result != text, text, target);
if (requireReadback && result != text && probe.status != L"ready")
{
reason = L"recovered-selection-unavailable";
std::lock_guard lock(stateMutex);
last.reset();
run.reset();
return;
}
diagnosticEvent(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
L" changed=" + std::to_wstring(result != text));
if (result != text)
sent = tx.paste(textClip(result));
else if (automatic && tx.valid())
chord(VK_RIGHT);
if (requireReadback && result != text && !sent)
{
reason = L"recovered-selection-paste-cancelled";
std::lock_guard lock(stateMutex);
last.reset();
run.reset();
return;
}
if (sent || result == text)
autoSelected = false;
reason = result == text ? L"conversion-unchanged" :
@@ -1357,7 +1404,7 @@ void App::transform(int command, int snippet)
reason = L"Conversion unchanged";
verification = L"calculated-result-equals-original";
}
if (sent && manual && config.writeLog)
if (sent && ((manual && config.writeLog) || requireReadback))
{
verification = probe.status;
if (tx.valid())
@@ -1388,7 +1435,17 @@ void App::transform(int command, int snippet)
return;
}
}
else reason = L"Paste not verified";
else
{
reason = L"Paste not verified";
if (requireReadback)
{
std::lock_guard lock(stateMutex);
last.reset();
run.reset();
return;
}
}
}
else verification = L"context-changed-after-paste";
}
+18 -1
View File
@@ -13,7 +13,7 @@ class PasteProbe
public:
std::wstring status = L"not-requested";
PasteProbe(bool enabled, const std::wstring &original)
PasteProbe(bool enabled, HWND target)
{
if (!enabled)
return;
@@ -25,6 +25,18 @@ class PasteProbe
automation->put_TransactionTimeout(200);
if (FAILED(automation->GetFocusedElement(&element)) || !element)
return;
if (target)
{
DWORD process{};
GetWindowThreadProcessId(target, &process);
int focusedProcess{};
if (!process || FAILED(element->get_CurrentProcessId(&focusedProcess)) ||
process != static_cast<DWORD>(focusedProcess) || GetForegroundWindow() != target)
{
status = L"focused-control-changed";
return;
}
}
BOOL password = TRUE;
if (FAILED(element->get_CurrentIsPassword(&password)) || password)
{
@@ -35,6 +47,11 @@ class PasteProbe
(void **)&pattern)) ||
!pattern)
return;
}
PasteProbe(bool enabled, const std::wstring &original, HWND target = nullptr)
: PasteProbe(enabled, target)
{
if (!pattern) return;
auto selected = read(1, false);
auto normalize = [](std::wstring value)
{
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.30";
inline constexpr wchar_t AppVersion[] = L"0.6.31";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+93 -1
View File
@@ -29,6 +29,8 @@ int pasteBehavior = 0;
int enterPresses = 0;
int copyFailuresRemaining = 0, copyRequests = 0;
int droppedSelectionKeys = 0;
bool trimCopiedSpaces = false;
int trimmedCopies = 0;
bool temporaryClipboardExcluded = true;
bool clipboardAccessTimedOut = false;
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
@@ -77,6 +79,37 @@ LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_P
--copyFailuresRemaining;
return 0;
}
if (trimCopiedSpaces)
{
auto result = DefSubclassProc(w, message, wp, lp);
// Native EDIT closes the clipboard in its copy handler, including
// our outer opening. Reopen it before changing its published data.
if (!OpenClipboard(w))
{
clipboardAccessTimedOut = true;
return result;
}
auto handle = GetClipboardData(CF_UNICODETEXT);
auto data = handle ? static_cast<const wchar_t *>(GlobalLock(handle)) : nullptr;
if (data)
{
std::wstring copied(data);
GlobalUnlock(handle);
const auto originalSize = copied.size();
while (!copied.empty() && copied.back() == L' ') copied.pop_back();
auto replacement = GlobalAlloc(GMEM_MOVEABLE, (copied.size() + 1) * sizeof(wchar_t));
auto output = GlobalLock(replacement);
memcpy(output, copied.c_str(), (copied.size() + 1) * sizeof(wchar_t));
GlobalUnlock(replacement);
if (!EmptyClipboard() || !SetClipboardData(CF_UNICODETEXT, replacement))
{
GlobalFree(replacement);
clipboardAccessTimedOut = true;
}
else if (copied.size() != originalSize) ++trimmedCopies;
}
return result;
}
}
}
if (message == WM_PASTE && pasteBehavior)
@@ -464,6 +497,65 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
require(text() == L"=тревога" && pasteBehavior == 0,
"native Edit retries one ignored paste only while original remains selected");
require(ks::readClipboard(app->window).text() == sentinel, "native paste retry restores clipboard");
trimCopiedSpaces = true;
app->settings.writeLog = false;
prepare(L"OLDghbdtn NEXT", false);
SendMessageW(field, EM_SETSEL, 11, 11);
record(L"ghbdtn ");
app->transform(ks::Direct + ru);
break;
}
case 50:
require(text() == L"OLDпривет NEXT", "trimmed clipboard recovery preserves two spaces and surrounding text without logging");
app->transform(ks::Direct + en);
break;
case 51:
require(text() == L"OLDghbdtn NEXT", "repeat conversion preserves accessibility-proven spaces");
app->transform(ks::Undo);
break;
case 52:
require(text() == L"OLDпривет NEXT", "undo preserves accessibility-proven spaces");
prepare(L"ghbdtn", false);
record(L"ghbdtn ");
app->transform(ks::Direct + ru);
break;
case 53:
require(text() == L"ghbdtn", "missing real space cannot be inferred from tracked keys");
prepare(L"wrong ", false);
record(L"ghbdtn ");
app->transform(ks::Direct + ru);
break;
case 54:
require(text() == L"wrong ", "unrelated selection remains rejected with trimmed clipboard");
app->settings.autoCorrect = true;
app->settings.autoLanguages = {L"en", L"ru"};
app->autoCorrector = ks::AutoCorrector{};
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"ghbdtn ", false);
record(L"ghbdtn ");
app->transform(ks::AutoCorrect);
break;
case 55:
require(text() == L"привет ", "automatic correction recovers copied trailing space without logging");
app->transform(ks::Undo);
break;
case 56:
{
require(text() == L"ghbdtn ", "automatic correction undo retains recovered space");
require(ks::readClipboard(app->window).text() == sentinel, "space recovery restores clipboard");
pasteBehavior = 1;
prepare(L"ghbdtn ", false);
record(L"ghbdtn ");
app->transform(ks::Direct + ru);
break;
}
case 57:
{
require(text() == L"ghbdtn " && !ks::InputRegressionAccess::hasLast(*app),
"recovered selection with blocked paste clears repeat state even without logging");
require(trimmedCopies >= 7, "fixture actually stripped spaces during recovery scenarios");
pasteBehavior = 0;
trimCopiedSpaces = false;
finish();
break;
}
@@ -868,7 +960,7 @@ int main(int argc, char **argv)
field =
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
deadline = GetTickCount64() + 30000;
deadline = GetTickCount64() + 60000;
SetWindowSubclass(field, observeEnter, 1, 0);
if (typosOnly || (argc > 1 && std::string(argv[1]) == "--typing-only"))
stage = 20;