Release v0.6.43: fix forwarded input and verify snippet pastes

This commit is contained in:
agrynco committed 2026-11-04 21:18:39 +02:00
1 parent a2b88f247c
commit 6621a49022
14 files changed
+239 -33

No files matched your search

+100 -17
View File
@@ -78,6 +78,8 @@ class Transaction
mutable bool invalidLogged{};
unsigned copyNumber{};
std::wstring adopted;
Bytes adoptedFormats;
bool trustedContentsObserved{};
std::unique_ptr<PasteProbe> selectionProof;
std::wstring provenSelection;
@@ -87,10 +89,26 @@ class Transaction
app.suspended = true;
try
{
expected = GetClipboardSequenceNumber();
snapshot.capture(app.window, expected);
if (expected != GetClipboardSequenceNumber())
throw std::runtime_error("Clipboard changed while taking snapshot");
for (unsigned attempt = 0; ; ++attempt)
{
expected = GetClipboardSequenceNumber();
try
{
snapshot.capture(app.window, expected);
if (expected != GetClipboardSequenceNumber())
throw std::runtime_error("Clipboard changed while taking snapshot");
break;
}
catch (...)
{
// No selection or clipboard write has happened yet. Recapture
// the latest clipboard after a concurrent publisher settles.
if (attempt >= 2 || !valid() || expected == GetClipboardSequenceNumber())
throw;
diagnosticEvent(L"Snapshot retry: clipboard changed during capture");
Sleep(20);
}
}
captured = true;
}
catch (...)
@@ -132,9 +150,19 @@ class Transaction
if (GetClipboardSequenceNumber() != expected)
{
auto now = GetClipboardSequenceNumber();
if (adopted.empty() || !ownsClipboard(target) || readClipboard(app.window, 32 * 1024 * 1024, ClipboardRead::Selection).text() != adopted ||
GetClipboardSequenceNumber() != now)
auto current = readClipboard(app.window, 32 * 1024 * 1024, ClipboardRead::Selection);
const bool sameContents = selectionClipboardIdentity(current) == adoptedFormats;
if (adopted.empty() || (!trustedContentsObserved && !ownsClipboard(target)) ||
!sameContents || GetClipboardSequenceNumber() != now)
{
diagnosticEvent(L"Clipboard check rejected: expectedSequence=" + std::to_wstring(expected) +
L" readSequence=" + std::to_wstring(now) + L" currentSequence=" +
std::to_wstring(GetClipboardSequenceNumber()) + L" owner=" +
std::to_wstring((uintptr_t)GetClipboardOwner()) + L" sameContents=" +
std::to_wstring(sameContents) + L" expectedText=" + diagnosticText(adopted) +
L" currentText=" + diagnosticText(current.text()));
throw std::runtime_error("Operation cancelled: another application changed the clipboard");
}
expected = now;
}
}
@@ -142,6 +170,8 @@ class Transaction
{
check();
adopted.clear();
adoptedFormats.clear();
trustedContentsObserved = false;
// 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.
@@ -164,10 +194,12 @@ class Transaction
const auto number = ++copyNumber;
if (!valid())
return {};
write(textClip(L"\x2063KSwitcher-" + std::to_wstring(GetTickCount64())));
const auto sentinel = L"\x2063KSwitcher-" + std::to_wstring(GetTickCount64());
write(textClip(sentinel));
DWORD before = expected;
diagnosticEvent(L"Copy begin number=" + std::to_wstring(number) + L" sentinelSequence=" +
std::to_wstring(before) + L" target=" + std::to_wstring((uintptr_t)target));
std::to_wstring(before) + L" target=" + std::to_wstring((uintptr_t)target) +
L" sentinel=" + diagnosticText(sentinel));
if (!valid() || !chord(L'C', MOD_CONTROL))
{
diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked");
@@ -179,6 +211,7 @@ class Transaction
DWORD changed = before;
ULONGLONG stable = 0;
bool changedAgain = false;
std::optional<Bytes> observedTargetCopy;
while (GetTickCount64() < deadline)
{
if (!valid())
@@ -193,6 +226,18 @@ class Transaction
changedAgain |= changed != before;
changed = now;
stable = GetTickCount64();
// Clipboard synchronizers can republish a legitimate copy under
// their own HWND. Record provenance before that handoff; an
// unobserved or altered foreign copy must still be rejected.
if (ownsClipboard(target))
{
auto candidate = readClipboard(app.window, 32 * 1024 * 1024, ClipboardRead::Selection);
if (GetClipboardSequenceNumber() == now && ownsClipboard(target) && !candidate.text().empty())
{
observedTargetCopy = selectionClipboardIdentity(candidate);
diagnosticEvent(L"Copy: target-owned selection observed number=" + std::to_wstring(number));
}
}
}
if (changed != before && GetTickCount64() - stable >= 60)
break;
@@ -224,16 +269,31 @@ class Transaction
L"Copy failed: context changed");
return {};
}
if ((GetClipboardOwner() ? !ownsClipboard(target) : changedAgain) ||
changed != GetClipboardSequenceNumber())
throw std::runtime_error("Operation cancelled: copy did not belong to the target application");
expected = changed;
const bool targetOwned = ownsClipboard(target);
auto clip = readClipboard(app.window, 32 * 1024 * 1024, ClipboardRead::Selection);
const auto formats = selectionClipboardIdentity(clip);
const bool verifiedRelay = observedTargetCopy && *observedTargetCopy == formats;
if (((GetClipboardOwner() ? !targetOwned : changedAgain) && !verifiedRelay) ||
changed != GetClipboardSequenceNumber())
{
diagnosticEvent(L"Copy rejected: observedTarget=" + std::to_wstring(observedTargetCopy.has_value()) +
L" verifiedRelay=" + std::to_wstring(verifiedRelay) + L" expectedSequence=" +
std::to_wstring(changed) + L" currentSequence=" + std::to_wstring(GetClipboardSequenceNumber()) +
L" owner=" + std::to_wstring((uintptr_t)GetClipboardOwner()) + L" targetText=" +
diagnosticText(observedTargetCopy ? decodeClip(*observedTargetCopy).text() : L"") +
L" currentText=" + diagnosticText(clip.text()));
throw std::runtime_error("Operation cancelled: copy did not belong to the target application");
}
if (!targetOwned && verifiedRelay)
diagnosticEvent(L"Copy: identical target selection republished by clipboard synchronizer");
expected = changed;
auto text = clip.text();
diagnosticEvent(L"Copy result number=" + std::to_wstring(number) + L" attempts=" +
std::to_wstring(attempts) + L" elapsedMs=" + std::to_wstring(GetTickCount64() - started) +
L" text=" + diagnosticText(text));
adopted = text;
adoptedFormats = formats;
trustedContentsObserved = targetOwned || verifiedRelay;
check();
for (auto &f : clip.formats)
{
@@ -359,6 +419,14 @@ class Transaction
if (!valid())
return false;
write(clip);
// Our verified payload is also trusted across an identical relay. The
// sentinel written by copy() deliberately does not get this allowance.
if (clip.formats.size() == 1 && clip.formats.front().id == CF_UNICODETEXT)
{
adopted = clip.text();
adoptedFormats = selectionClipboardIdentity(clip);
trustedContentsObserved = true;
}
// 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.
@@ -376,6 +444,10 @@ class Transaction
if (!chord(L'V', MOD_CONTROL))
throw std::runtime_error("Windows blocked simulated input; check application privilege levels");
Sleep(300);
// A clipboard synchronizer may replace our payload after Ctrl+V is
// queued but before the editor consumes it. Do not run snippet actions
// (especially Enter) after such a race.
check();
return true;
}
bool nativeSelectionIs(const std::wstring &original) const
@@ -955,6 +1027,15 @@ void App::replayTyping()
}
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
{
// MWB suppresses source-machine events that it forwards elsewhere. Let
// downstream hooks route the event before tracking it or starting a snippet.
// Call the chain exactly once; a local event can still be consumed below.
const auto routed = CallNextHookEx(nullptr, code, wp, lp);
return keyboardAfterHooks(code, wp, lp, routed);
}
LRESULT App::keyboardAfterHooks(int code, WPARAM wp, LPARAM lp, LRESULT routed)
{
if (routed) return routed;
if (code == HC_ACTION && current &&
(wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN || wp == WM_KEYUP || wp == WM_SYSKEYUP))
{
@@ -969,7 +1050,7 @@ LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN, mods))
return 1;
}
return CallNextHookEx(nullptr, code, wp, lp);
return routed;
}
LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
{
@@ -1398,11 +1479,11 @@ void App::transform(int command, int snippet)
}
}
bool sent = false;
const bool requireReadback = tx.recoveredSelection();
const bool requireReadback = tx.recoveredSelection() || snippet >= 0;
PasteProbe probe(((config.writeLog && manual) || requireReadback) && result != text, text, target);
if (requireReadback && result != text && probe.status != L"ready")
{
reason = L"recovered-selection-unavailable";
reason = snippet >= 0 ? L"snippet-verification-unavailable" : L"recovered-selection-unavailable";
std::lock_guard lock(stateMutex);
last.reset();
run.reset();
@@ -1416,7 +1497,7 @@ void App::transform(int command, int snippet)
chord(VK_RIGHT);
if (requireReadback && result != text && !sent)
{
reason = L"recovered-selection-paste-cancelled";
reason = snippet >= 0 ? L"snippet-paste-cancelled" : L"recovered-selection-paste-cancelled";
std::lock_guard lock(stateMutex);
last.reset();
run.reset();
@@ -1900,7 +1981,9 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
app->replayTyping();
return 0;
case TrayMessage:
if (LOWORD(lp) == WM_CONTEXTMENU)
if (LOWORD(lp) == NIN_BALLOONUSERCLICK)
showLogs(*app);
else if (LOWORD(lp) == WM_CONTEXTMENU)
app->trayMenu();
else if (LOWORD(lp) == WM_LBUTTONDBLCLK)
// The shell still owns the mouse gesture on the second button-down.
+1
View File
@@ -143,6 +143,7 @@ class App
void replayTyping();
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
static LRESULT keyboardAfterHooks(int, WPARAM, LPARAM, LRESULT);
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
};
void showHistory(App &, bool visible = true);
+14
View File
@@ -344,6 +344,20 @@ Bytes encodeClip(const Clip &clip)
}
return b;
}
Bytes selectionClipboardIdentity(const Clip &clip)
{
auto normalized = clip;
// GlobalSize includes allocation slack. A clipboard relay may allocate a
// different-sized block for the same NUL-terminated Unicode string.
for (auto &format : normalized.formats)
if (format.id == CF_UNICODETEXT)
format = textClip(clip.text()).formats.front();
std::sort(normalized.formats.begin(), normalized.formats.end(), [](const Format &a, const Format &b)
{
return std::tie(a.name, a.id) < std::tie(b.name, b.id);
});
return encodeClip(normalized);
}
Clip decodeClip(const Bytes &b)
{
size_t p = 0;
+1
View File
@@ -46,6 +46,7 @@ class History
std::vector<Entry> search(const std::wstring &, bool pinnedOnly = false) const;
};
Bytes encodeClip(const Clip &);
Bytes selectionClipboardIdentity(const Clip &);
Clip decodeClip(const Bytes &);
bool sameClipboardImage(const Clip &, const Clip &);
int runImageHistoryCapture(const std::filesystem::path &request);
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.42";
inline constexpr wchar_t AppVersion[] = L"0.6.43";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;