Compare commits

..

2 Commits

11 changed files with 203 additions and 31 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.30 LANGUAGES CXX RC) project(KSwitcher VERSION 0.6.32 LANGUAGES CXX RC)
if(NOT WIN32) if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs") message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif() endif()
+6 -1
View File
@@ -170,7 +170,8 @@ for shapes.
**Canvas zoom:** use **Ctrl++ / Ctrl+-** (including numpad keys) or **Ctrl+mouse wheel**. **Canvas zoom:** use **Ctrl++ / Ctrl+-** (including numpad keys) or **Ctrl+mouse wheel**.
Each step changes the view by 25%; the wheel zooms around the cursor, keyboard shortcuts around Each step changes the view by 25%; the wheel zooms around the cursor, keyboard shortcuts around
the viewport center. **Ctrl+0** restores the initial fit. Drag with the **middle mouse button** the viewport center. **Ctrl+0** restores the initial fit. Drag with the **middle mouse button**
to pan an enlarged image. Zoom ranges from 0.1× to 16× the initial fit; the status bar shows the to pan an enlarged image. Panning is limited to axes where the image exceeds the viewport;
an image that fits stays centered. Zoom ranges from 0.1× to 16× the initial fit; the status bar shows the
actual image scale. Zoom and pan do not change annotations, export resolution or undo history. actual image scale. Zoom and pan do not change annotations, export resolution or undo history.
The wheel without Ctrl still adjusts the object under the cursor. The wheel without Ctrl still adjusts the object under the cursor.
@@ -689,3 +690,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. 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 Keep the compatibility bridge in the first 100 releases returned by Gitea while
supporting old clients; their immutable update code only reads that page. 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 -18
View File
@@ -1,29 +1,19 @@
# Latest Windows release # Latest Windows release
## v0.6.30 — 2026-11-02 ## v0.6.32 — 2026-11-03
## Changes ## 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. - Fix the screenshot panning regression test for high-DPI displays. It now derives the image zoom from the actual viewport dimensions, centers the image, and checks that both axes have enough clearance for the tested drag.
- A previous-result verification mismatch clears stale repeat-conversion state. - At 240 DPI (250% scaling), the previous fixture's 1600 × 1200 image still fit inside the 2468 × 1389 viewport. The editor correctly prevented panning, but the test incorrectly expected a 15 × 10 pixel movement.
- 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. - Application behavior, shortcuts, defaults and settings are unchanged. The verified Viber trailing-space recovery from v0.6.31 remains included. No screenshot-editor workaround is needed for this test failure.
- The guarded retry and failed-paste readback handling require **Write log**, which enables manual-conversion readback. Clipboard restoration remains conditional on ownership.
## Validation ## 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. The full `build.ps1 -Stage -Test` run passed: 105 sync checks, shared wheel batching, 483 native regressions, 541 input/UI checks at 240 DPI, the full screenshot editor UI suite (including the corrected pan test), PNG saving, screenshot registration, 48 updater regressions and updater progress UI checks. All three packaged executable versions and all 12 manifest file sizes/SHA-256 hashes were verified; the package contains no personal data.
## Limitations ## Upgrade
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. 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.
**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. 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 37c8ba8a27279141ac74318c24adf8b4ff4f573d68d4abff571a2abef7e739a1 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" 1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1" 201 RCDATA "update.ps1"
1 VERSIONINFO 1 VERSIONINFO
FILEVERSION 0,6,30,0 FILEVERSION 0,6,32,0
PRODUCTVERSION 0,6,30,0 PRODUCTVERSION 0,6,32,0
FILEOS VOS_NT_WINDOWS32 FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP FILETYPE VFT_APP
BEGIN BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher" VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.30" VALUE "FileVersion", "0.6.32"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
+60 -3
View File
@@ -76,6 +76,8 @@ class Transaction
mutable bool invalidLogged{}; mutable bool invalidLogged{};
unsigned copyNumber{}; unsigned copyNumber{};
std::wstring adopted; std::wstring adopted;
std::unique_ptr<PasteProbe> selectionProof;
std::wstring provenSelection;
public: public:
Transaction(App &a, HWND t, uint64_t v) : app(a), target(t), version(v) 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; return text;
} }
bool recoveredSelection() const { return selectionProof != nullptr; }
bool select(size_t n) bool select(size_t n)
{ {
return selectPrevious(n, [&] { return valid(); }); return selectPrevious(n, [&] { return valid(); });
@@ -324,6 +345,15 @@ class Transaction
} }
bool paste(const Clip &clip) 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()) if (!valid())
return false; return false;
write(clip); write(clip);
@@ -1341,13 +1371,30 @@ void App::transform(int command, int snippet)
} }
} }
bool sent = false; 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) + diagnosticEvent(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
L" changed=" + std::to_wstring(result != text)); L" changed=" + std::to_wstring(result != text));
if (result != text) if (result != text)
sent = tx.paste(textClip(result)); sent = tx.paste(textClip(result));
else if (automatic && tx.valid()) else if (automatic && tx.valid())
chord(VK_RIGHT); 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) if (sent || result == text)
autoSelected = false; autoSelected = false;
reason = result == text ? L"conversion-unchanged" : reason = result == text ? L"conversion-unchanged" :
@@ -1357,7 +1404,7 @@ void App::transform(int command, int snippet)
reason = L"Conversion unchanged"; reason = L"Conversion unchanged";
verification = L"calculated-result-equals-original"; verification = L"calculated-result-equals-original";
} }
if (sent && manual && config.writeLog) if (sent && ((manual && config.writeLog) || requireReadback))
{ {
verification = probe.status; verification = probe.status;
if (tx.valid()) if (tx.valid())
@@ -1388,7 +1435,17 @@ void App::transform(int command, int snippet)
return; 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"; else verification = L"context-changed-after-paste";
} }
+18 -1
View File
@@ -13,7 +13,7 @@ class PasteProbe
public: public:
std::wstring status = L"not-requested"; std::wstring status = L"not-requested";
PasteProbe(bool enabled, const std::wstring &original) PasteProbe(bool enabled, HWND target)
{ {
if (!enabled) if (!enabled)
return; return;
@@ -25,6 +25,18 @@ class PasteProbe
automation->put_TransactionTimeout(200); automation->put_TransactionTimeout(200);
if (FAILED(automation->GetFocusedElement(&element)) || !element) if (FAILED(automation->GetFocusedElement(&element)) || !element)
return; 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; BOOL password = TRUE;
if (FAILED(element->get_CurrentIsPassword(&password)) || password) if (FAILED(element->get_CurrentIsPassword(&password)) || password)
{ {
@@ -35,6 +47,11 @@ class PasteProbe
(void **)&pattern)) || (void **)&pattern)) ||
!pattern) !pattern)
return; return;
}
PasteProbe(bool enabled, const std::wstring &original, HWND target = nullptr)
: PasteProbe(enabled, target)
{
if (!pattern) return;
auto selected = read(1, false); auto selected = read(1, false);
auto normalize = [](std::wstring value) auto normalize = [](std::wstring value)
{ {
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h" #include "platform.h"
namespace ks namespace ks
{ {
inline constexpr wchar_t AppVersion[] = L"0.6.30"; inline constexpr wchar_t AppVersion[] = L"0.6.32";
inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000; inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+93 -1
View File
@@ -29,6 +29,8 @@ int pasteBehavior = 0;
int enterPresses = 0; int enterPresses = 0;
int copyFailuresRemaining = 0, copyRequests = 0; int copyFailuresRemaining = 0, copyRequests = 0;
int droppedSelectionKeys = 0; int droppedSelectionKeys = 0;
bool trimCopiedSpaces = false;
int trimmedCopies = 0;
bool temporaryClipboardExcluded = true; bool temporaryClipboardExcluded = true;
bool clipboardAccessTimedOut = false; bool clipboardAccessTimedOut = false;
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR) 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; --copyFailuresRemaining;
return 0; 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 (message == WM_PASTE && pasteBehavior)
@@ -464,6 +497,65 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
require(text() == L"=тревога" && pasteBehavior == 0, require(text() == L"=тревога" && pasteBehavior == 0,
"native Edit retries one ignored paste only while original remains selected"); "native Edit retries one ignored paste only while original remains selected");
require(ks::readClipboard(app->window).text() == sentinel, "native paste retry restores clipboard"); 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(); finish();
break; break;
} }
@@ -868,7 +960,7 @@ int main(int argc, char **argv)
field = field =
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN, CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr); 20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
deadline = GetTickCount64() + 30000; deadline = GetTickCount64() + 60000;
SetWindowSubclass(field, observeEnter, 1, 0); SetWindowSubclass(field, observeEnter, 1, 0);
if (typosOnly || (argc > 1 && std::string(argv[1]) == "--typing-only")) if (typosOnly || (argc > 1 && std::string(argv[1]) == "--typing-only"))
stage = 20; stage = 20;
+11
View File
@@ -225,6 +225,17 @@ int main(int argc, char **argv)
require(c.zoom>zoom && std::abs(before.x-after.x)<=1 && std::abs(before.y-after.y)<=1, require(c.zoom>zoom && std::abs(before.x-after.x)<=1 && std::abs(before.y-after.y)<=1,
"Ctrl+wheel zoom keeps image point under cursor"); "Ctrl+wheel zoom keeps image point under cursor");
require(!c.history.canUndo(),"zoom does not add image edits to undo history"); require(!c.history.canUndo(),"zoom does not add image edits to undo history");
// A fixed zoom can still fit inside a high-DPI editor. Size the
// fixture from the actual viewport, with room for the tested drag
// on both axes, then start from a centered view.
const double panZoom=std::max({1.0,
double(c.canvas.right-c.canvas.left+128)/(c.view.right-c.view.left),
double(c.canvas.bottom-c.canvas.top+128)/(c.view.bottom-c.view.top)});
ks::zoomCanvas(window,c,panZoom,center);
c.panX=c.panY=0; ks::layout(window,c);
require(c.view.left+15<c.canvas.left && c.view.top+10<c.canvas.top &&
c.view.right>c.canvas.right && c.view.bottom>c.canvas.bottom,
"pan fixture has drag clearance on both axes within the supported zoom range");
auto view=c.view; auto view=c.view;
SendMessageW(window,WM_MBUTTONDOWN,MK_MBUTTON,MAKELPARAM(center.x,center.y)); SendMessageW(window,WM_MBUTTONDOWN,MK_MBUTTON,MAKELPARAM(center.x,center.y));
SendMessageW(window,WM_MOUSEMOVE,MK_MBUTTON,MAKELPARAM(center.x+15,center.y+10)); SendMessageW(window,WM_MOUSEMOVE,MK_MBUTTON,MAKELPARAM(center.x+15,center.y+10));