Compare commits

..

3 Commits

10 changed files with 260 additions and 29 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.29 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.31 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
+9 -1
View File
@@ -271,7 +271,11 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
- 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.
history and cloud synchronization. Paste is not blindly retried: with manual paste readback
enabled by Write log, a native Windows EDIT gets at most one retry when readback reports the
original and fresh native queries confirm that exactly the original range is still selected.
Other controls and unknown/changed selections are not retried. A verified failed paste clears
repeat-conversion state and does not switch the Windows layout.
- 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.
@@ -685,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.
+9 -17
View File
@@ -1,29 +1,21 @@
# Latest Windows release
## v0.6.29 — 2026-11-02
## v0.6.31 — 2026-11-03
## Changes
- Added **Clear history… / Alt+H** directly to the Clipboard history window beside Edit image / Open item.
- Clearing requires confirmation, with **No** selected by default. It removes all saved entries, including pinned entries and entries hidden by the current search. Cancelling leaves history unchanged.
- The button follows the existing bottom action row when resizing. Settings retains its existing Clear history action.
- Clearing saved history does not clear the current Windows clipboard or disable future recording. This release does not change memory management.
- 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 build passed isolated checks for the new button, cancellation, confirmed deletion of pinned/search-hidden entries and persistence after reloading the history store. 48 updater regression checks passed; all executable versions and release-manifest hashes were verified. The full desktop-interaction suite was not rerun for this focused patch.
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.
## Known limitations
Existing asynchronous-editor selection/clipboard limitations remain; this release does not fix the reported ChatGPT selection mismatch. Dictionary typo correction and OpenBao synchronization remain experimental as documented in v0.6.28.
## Limitations
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 @@
c9c3e3579575a41d434201d4f37bf3829e53c1ef23c42cb1c077930dfe8fdfa6 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,29,0
PRODUCTVERSION 0,6,29,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.29"
VALUE "FileVersion", "0.6.31"
END
END
BLOCK "VarFileInfo"
+110 -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);
@@ -338,6 +368,32 @@ class Transaction
Sleep(300);
return true;
}
bool nativeSelectionIs(const std::wstring &original) const
{
if (!valid()) return false;
GUITHREADINFO gui{sizeof(gui)};
if (!GetGUIThreadInfo(GetWindowThreadProcessId(target, nullptr), &gui) || !gui.hwndFocus) return false;
wchar_t cls[32]{};
GetClassNameW(gui.hwndFocus, cls, 32);
if (_wcsicmp(cls, L"Edit") != 0 ||
(GetWindowLongPtrW(gui.hwndFocus, GWL_STYLE) & (ES_PASSWORD | ES_READONLY))) return false;
auto query = [&](UINT message, WPARAM wp, LPARAM lp, DWORD_PTR &result)
{
return SendMessageTimeoutW(gui.hwndFocus, message, wp, lp,
SMTO_ABORTIFHUNG | SMTO_BLOCK, 100, &result) != 0;
};
DWORD_PTR range{}, length{}, copied{};
if (!query(EM_GETSEL, 0, 0, range) || (DWORD)range == 0xffffffff ||
size_t(HIWORD(range) - LOWORD(range)) != original.size() ||
!query(WM_GETTEXTLENGTH, 0, 0, length) || length > 65534 || HIWORD(range) > length) return false;
std::wstring content(length + 1, L'\0');
if (!query(WM_GETTEXT, content.size(), (LPARAM)content.data(), copied) || copied != length) return false;
DWORD_PTR after{};
GUITHREADINFO focused{sizeof(focused)};
return query(EM_GETSEL, 0, 0, after) && after == range && valid() &&
GetGUIThreadInfo(GetWindowThreadProcessId(target, nullptr), &focused) && focused.hwndFocus == gui.hwndFocus &&
content.substr(LOWORD(range), original.size()) == original;
}
};
} // namespace
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
@@ -1263,6 +1319,11 @@ void App::transform(int command, int snippet)
actual = text;
verification = L"selection-mismatch";
reason = L"previous-result-verification-mismatch";
{
std::lock_guard lock(stateMutex);
last.reset();
run.reset();
}
if (tx.valid())
chord(VK_RIGHT);
return;
@@ -1310,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" :
@@ -1326,19 +1404,48 @@ 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())
{
auto observed = probe.read((int)caretSteps(result));
// A native Edit may silently ignore Ctrl+V while the clipboard is busy.
// Retry once only with fresh native proof that the exact original range
// is still selected. Never retry an unknown or changed editor state.
if (observed && newlines(*observed) == newlines(text) && tx.nativeSelectionIs(text))
{
diagnosticEvent(L"Paste retry: native Edit still selects the unchanged original");
if (tx.paste(textClip(result)) && tx.valid())
observed = probe.read((int)caretSteps(result));
else
observed.reset();
}
verification = probe.status;
if (observed && tx.valid())
{
actual = *observed;
reason = conversionReadbackOutcome(newlines(actual), newlines(text), newlines(result));
if (newlines(actual) != newlines(result))
{
std::lock_guard lock(stateMutex);
last.reset();
run.reset();
diagnosticEvent(L"Paste rejected by readback: previous result cleared; layout not switched");
return;
}
}
else
{
reason = L"Paste not verified";
if (requireReadback)
{
std::lock_guard lock(stateMutex);
last.reset();
run.reset();
return;
}
}
else reason = L"Paste not verified";
}
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.29";
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;
+108 -1
View File
@@ -9,6 +9,7 @@ namespace ks
{
struct InputRegressionAccess
{
static bool hasLast(App &app) { return app.last.has_value(); }
static bool deferring(App &app)
{
std::lock_guard lock(app.stateMutex);
@@ -28,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)
@@ -76,10 +79,42 @@ 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)
{
if (pasteBehavior == 3) { pasteBehavior = 0; return 0; }
if (pasteBehavior == 2) SendMessageW(w, EM_REPLACESEL, TRUE, (LPARAM)L"other!");
return 0;
}
@@ -371,6 +406,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
require(text() == L"ghbdtn" && rows.back().outcome == L"Original text remained",
"blocked paste records original text remaining in the actual editor");
require(ks::readClipboard(app->window).text() == sentinel, "readback preserves clipboard after blocked paste");
require(!ks::InputRegressionAccess::hasLast(*app), "failed paste does not retain a fictional previous result");
pasteBehavior = 2;
prepare(L"ghbdtn", true);
app->transform(ks::Direct + ru);
@@ -381,6 +417,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
require(text() == L"other!" && rows.back().outcome == L"Readback differs",
"unexpected editor content records readback mismatch");
require(!ks::InputRegressionAccess::hasLast(*app), "changed readback cannot seed repeat conversion");
pasteBehavior = 0;
prepare(L"привет", true);
app->transform(ks::Direct + ru);
@@ -449,6 +486,76 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
details.find(L"Selection initial match=0") != details.npos &&
details.find(L"SelectPrevious") != details.npos,
"incident includes clipboard and selection stages");
pasteBehavior = 3;
prepare(L"=nhtdjuf", true);
app->transform(ks::Direct + ru);
stage = 49;
break;
}
case 49:
{
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;
}
@@ -853,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;