Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6621a49022 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KSwitcher VERSION 0.6.42 LANGUAGES CXX RC)
|
||||
project(KSwitcher VERSION 0.6.43 LANGUAGES CXX RC)
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||
endif()
|
||||
|
||||
@@ -75,6 +75,9 @@ Ctrl+Alt+Delete and Win+L; these cannot be assigned.
|
||||
|
||||
Keyboard and mouse input forwarded by Microsoft Mouse Without Borders follows the same input
|
||||
path as a local keyboard/mouse, including shortcuts, recent-word conversion and snippets.
|
||||
On the sending computer, KSwitcher ignores keyboard events consumed by another keyboard hook
|
||||
(including Mouse Without Borders forwarding). The receiving computer still processes delivered
|
||||
input normally, so both computers can run KSwitcher without starting duplicate snippet operations.
|
||||
KSwitcher marks its own generated input so copy/paste, selection, modifier releases and shell-mask
|
||||
keys cannot trigger recursive commands or overwrite the recorded typing run. Other injected input
|
||||
is accepted, including key-up events and VK-only keyboard events. Unicode packets do not provide
|
||||
@@ -274,10 +277,18 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
|
||||
of the complete expected text. Browser/editor compatibility depends on its handling of simulated input.
|
||||
- Selection conversion and verified recent input (physical scan codes, 2-second typing-run boundary).
|
||||
- Same-process clipboard ownership plus verified WebView2 descendants, including the Teams fix.
|
||||
Clipboard synchronizers such as Mouse Without Borders may republish a copy only when
|
||||
KSwitcher first observed it owned by the target application and the selection text and
|
||||
editor selection metadata remain identical. Unobserved or altered foreign copies are rejected.
|
||||
Snippets require accessibility verification of their selected trigger and pasted text,
|
||||
even with logging disabled. If verification is unavailable or the paste differs, expansion
|
||||
stops without running cursor or Enter actions. Clipboard changes during paste also cancel actions.
|
||||
- Teams recent-word conversion uses a single word-selection command and validates the copied range.
|
||||
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 snapshot interrupted by a concurrent clipboard publication is retried up to three total
|
||||
attempts before selection starts, provided the foreground and input context remain unchanged.
|
||||
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: with manual paste readback
|
||||
|
||||
Binary file not shown.
+11
-9
@@ -1,21 +1,23 @@
|
||||
# Latest Windows release
|
||||
|
||||
## v0.6.42 — 2026-11-04
|
||||
## v0.6.43 — 2026-11-04
|
||||
|
||||
## Changes
|
||||
- Replaced the history filter dropdown with a one-click **★ Pinned only** checkbox. Checked shows pinned entries; unchecked shows all entries.
|
||||
- The checkbox sits on the left, with the search field immediately alongside it. The search field keeps its right edge aligned with the history list when the window is resized.
|
||||
- Existing filter state, search, Unpin behavior and persistence across Esc are preserved. No shortcut or default changed. Clear history still deletes every entry after confirmation, including hidden entries.
|
||||
- Respect downstream keyboard-hook suppression before tracking input or starting snippets. This prevents source-side expansion of keys forwarded by Mouse Without Borders. Update KSwitcher on both computers, especially the keyboard-sharing source.
|
||||
- Verify snippet selection and pasted text through accessibility even with logging disabled. Unavailable verification, incorrect text or clipboard changes cancel further cursor/Enter actions. No snippet syntax, shortcut or default changed.
|
||||
- Accept identical clipboard relays only after observing target-owned content, preserving text and editor selection metadata checks. Unicode allocation padding and format order do not cause false mismatches; changed content remains rejected.
|
||||
- Retry clipboard snapshots interrupted by concurrent publication up to three times before selection, while input context remains unchanged.
|
||||
- Clicking a warning notification opens Logs. Copy rejection diagnostics include observed/current text, clipboard sequences and the sentinel for troubleshooting.
|
||||
|
||||
## Validation
|
||||
The dedicated 500-entry history integration suite verifies checkbox clicks, combined search/filter, removal after Unpin, remembered state after helper restart, text/image paste and helper shutdown. The compact layout was checked visually in the installed development build.
|
||||
Release regression checks cover dictionary equivalence (140,939 words), 105 sync checks, 319 native checks, 569 input checks, screenshot UI/save/registration, 48 updater checks and updater progress UI. The isolated snippet suite exercises an actual downstream forwarding hook, verifies no source-side copy or text changes, and checks normal expansion, Enter, buffered typing, copy retries and suppression of Enter after incorrect pasted text.
|
||||
|
||||
The release build also passed dictionary equivalence (140,939 words), 105 sync checks, 315 native checks, 561 input checks, screenshot UI/save/registration, 48 updater checks and updater progress UI.
|
||||
Live two-computer validation with both instances updated remains outstanding. An older instance on either computer can still interfere; the fix does not accept unrelated clipboard contents.
|
||||
|
||||
## 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.
|
||||
Choose **Update** in the tray menu on both computers. 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 its configurator and 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**. Keep all three executables together; history uses another instance of KSwitcher.exe, so no fourth executable is required. The package contains no personal settings, credentials or clipboard history.
|
||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. Keep all three executables together; history uses another instance of KSwitcher.exe. The package contains no personal settings, credentials or clipboard history.
|
||||
|
||||
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
||||
|
||||
@@ -1 +1 @@
|
||||
67cb67d4c08a8722fdd91bb513a9254d781cbee524d2e2eb39721251503b01c9 KSwitcher-win-x64.zip
|
||||
008f34a2a4a875acb07d21a39c0fad3a0cb63371edbcb91d393edf72543aea5e KSwitcher-win-x64.zip
|
||||
|
||||
+3
-3
@@ -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,42,0
|
||||
PRODUCTVERSION 0,6,42,0
|
||||
FILEVERSION 0,6,43,0
|
||||
PRODUCTVERSION 0,6,43,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.42"
|
||||
VALUE "FileVersion", "0.6.43"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
+100
-17
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -94,6 +94,9 @@ struct InputRegressionAccess {
|
||||
}
|
||||
static void notificationSetup(App &a) { a.updateNotifier = notify; }
|
||||
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
|
||||
static LRESULT routedKey(int code, UINT message, const KBDLLHOOKSTRUCT &key, LRESULT routed) {
|
||||
return App::keyboardAfterHooks(code,message,(LPARAM)&key,routed);
|
||||
}
|
||||
static bool updateTimer(App &a) {
|
||||
a.updateRunning=true; // Exercise timer scheduling without launching a network request.
|
||||
App::windowProc(a.window,WM_TIMER,5,0);
|
||||
@@ -648,6 +651,22 @@ int main(int argc, char **argv) {
|
||||
};
|
||||
auto commands=[&] { MSG m{}; int n=0; while(PeekMessageW(&m,app.window,WM_HOTKEY,WM_HOTKEY,PM_REMOVE)) ++n; return n; };
|
||||
auto f12=event(VK_F12);
|
||||
{
|
||||
const auto before = app.inputVersion.load();
|
||||
const auto recorded = app.run.keys;
|
||||
for (UINT message : {WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP})
|
||||
require(ks::InputRegressionAccess::routedKey(HC_ACTION,message,event('A',30),7)==7,
|
||||
"forwarded keyboard events preserve downstream suppression");
|
||||
require(app.inputVersion==before && app.run.keys==recorded && commands()==0,
|
||||
"forwarded events never track text, invalidate transactions or dispatch shortcuts");
|
||||
require(ks::InputRegressionAccess::routedKey(-1,WM_KEYDOWN,event('A',30),0)==0 &&
|
||||
app.inputVersion==before,"negative hook codes do not process keyboard input");
|
||||
require(ks::InputRegressionAccess::routedKey(HC_ACTION,WM_KEYDOWN,event('A',30,true),0)==0 &&
|
||||
app.inputVersion==before,"own input remains excluded after downstream routing");
|
||||
require(ks::InputRegressionAccess::routedKey(HC_ACTION,WM_KEYDOWN,event('A',30),0)==0 &&
|
||||
app.inputVersion>before,"delivered remote input is still processed on the receiver");
|
||||
app.run.reset();
|
||||
}
|
||||
require(!app.handleKeyboard(event(VK_LCONTROL),true,0),"remote modifier passes through");
|
||||
require(app.handleKeyboard(f12,true,MOD_CONTROL),"remote Ctrl+F12 handled");
|
||||
require(app.handleKeyboard(f12,true,MOD_CONTROL),"remote repeat suppressed");
|
||||
|
||||
+58
-1
@@ -33,6 +33,17 @@ bool trimCopiedSpaces = false;
|
||||
int trimmedCopies = 0;
|
||||
bool temporaryClipboardExcluded = true;
|
||||
bool clipboardAccessTimedOut = false;
|
||||
uint64_t forwardedVersion{};
|
||||
int forwardedEvents{};
|
||||
LRESULT CALLBACK forwardingHook(int code, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (code == HC_ACTION && reinterpret_cast<KBDLLHOOKSTRUCT *>(lp)->dwExtraInfo == 456)
|
||||
{
|
||||
++forwardedEvents;
|
||||
return 1; // Model MWB consuming source input routed to another machine.
|
||||
}
|
||||
return CallNextHookEx(nullptr, code, wp, lp);
|
||||
}
|
||||
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||||
{
|
||||
if (message == WM_KEYDOWN && wp == VK_LEFT && droppedSelectionKeys > 0 &&
|
||||
@@ -577,10 +588,41 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
require(ks::readClipboard(app->window).text() == sentinel, "selected and repeated conversions restore clipboard");
|
||||
finish();
|
||||
break;
|
||||
case 62:
|
||||
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
||||
captured = true;
|
||||
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
|
||||
app->settings.snippets = {{L";r", L"unexpected{enter}"}};
|
||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||
prepare(L"prefix ", false);
|
||||
forwardedVersion = app->inputVersion.load();
|
||||
copyRequests = forwardedEvents = 0;
|
||||
{
|
||||
std::vector<INPUT> inputs;
|
||||
for (WORD key : {WORD(VK_OEM_1), WORD('R')})
|
||||
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||||
{
|
||||
auto input = ks::keyboardInput(key, flags);
|
||||
input.ki.dwExtraInfo = 456;
|
||||
inputs.push_back(input);
|
||||
}
|
||||
require(SendInput((UINT)inputs.size(),inputs.data(),sizeof(INPUT))==inputs.size(),
|
||||
"forwarded snippet input submitted to real hook chain");
|
||||
}
|
||||
break;
|
||||
case 63:
|
||||
require(forwardedEvents==4,"downstream forwarding hook consumed every key event");
|
||||
require(app->inputVersion==forwardedVersion && copyRequests==0,
|
||||
"forwarded snippet does not start tracking or clipboard work on source computer");
|
||||
require(text()==L"prefix " && ks::readClipboard(app->window).text()==sentinel,
|
||||
"forwarded snippet leaves source editor and clipboard untouched");
|
||||
stage = 30;
|
||||
break;
|
||||
case 30:
|
||||
case 32:
|
||||
case 34:
|
||||
case 37:
|
||||
case 60:
|
||||
// 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.
|
||||
@@ -609,6 +651,11 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
enterPresses = 0;
|
||||
copyRequests = 0;
|
||||
copyFailuresRemaining = stage == 38 ? 10 : stage == 33 ? 1 : 0;
|
||||
if (stage == 61)
|
||||
{
|
||||
pasteBehavior = 2; // Editor inserts unrelated clipboard text.
|
||||
app->settings.writeLog = false;
|
||||
}
|
||||
temporaryClipboardExcluded = true;
|
||||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||||
prepare(L"prefix ", false);
|
||||
@@ -672,6 +719,13 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
require(ks::readClipboard(app->window).text() == sentinel,
|
||||
"exhausted copy retries restore the clipboard");
|
||||
copyFailuresRemaining = 0;
|
||||
stage = 60;
|
||||
break;
|
||||
case 61:
|
||||
require(text() == L"prefix other! x", "incorrect snippet paste preserves following typing");
|
||||
require(enterPresses == 0, "unverified snippet never submits Enter even without logging");
|
||||
require(ks::readClipboard(app->window).text() == sentinel, "rejected snippet restores clipboard");
|
||||
pasteBehavior = 0;
|
||||
finish();
|
||||
break;
|
||||
case 20:
|
||||
@@ -985,9 +1039,12 @@ int main(int argc, char **argv)
|
||||
if (typosOnly || (argc > 1 && std::string(argv[1]) == "--typing-only"))
|
||||
stage = 20;
|
||||
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
|
||||
stage = 30;
|
||||
stage = 62;
|
||||
SetTimer(host, 1, 100, nullptr);
|
||||
auto forwarding = SetWindowsHookExW(WH_KEYBOARD_LL,forwardingHook,GetModuleHandleW(nullptr),0);
|
||||
require(forwarding!=nullptr,"downstream forwarding fixture installed before application hook");
|
||||
app->execute();
|
||||
UnhookWindowsHookEx(forwarding);
|
||||
}
|
||||
std::filesystem::remove_all(root);
|
||||
}
|
||||
|
||||
@@ -517,6 +517,24 @@ int main(int argc, char **argv)
|
||||
auto encoded = ks::encodeClip(clip);
|
||||
require(ks::decodeClip(encoded).text() == clip.text(), "clipboard Unicode roundtrip");
|
||||
require(ks::decodeClip(encoded).formats[1].name == L"HTML Format", "registered format identity");
|
||||
auto selection = ks::textClip(L";pa");
|
||||
selection.formats.push_back({0, L"vscode-editor-data", {1, 2, 3}});
|
||||
auto relayed = selection;
|
||||
relayed.formats.front().data.resize(128, 0xab);
|
||||
std::reverse(relayed.formats.begin(), relayed.formats.end());
|
||||
require(ks::selectionClipboardIdentity(selection) == ks::selectionClipboardIdentity(relayed),
|
||||
"clipboard relay tolerates Unicode allocation slack and format order");
|
||||
relayed.formats.front().data[0] ^= 1;
|
||||
require(ks::selectionClipboardIdentity(selection) != ks::selectionClipboardIdentity(relayed),
|
||||
"clipboard relay rejects changed editor metadata");
|
||||
relayed = selection;
|
||||
relayed.formats.pop_back();
|
||||
require(ks::selectionClipboardIdentity(selection) != ks::selectionClipboardIdentity(relayed),
|
||||
"clipboard relay rejects missing editor metadata");
|
||||
relayed = selection;
|
||||
relayed.formats.front() = ks::textClip(L";pb").formats.front();
|
||||
require(ks::selectionClipboardIdentity(selection) != ks::selectionClipboardIdentity(relayed),
|
||||
"clipboard relay rejects changed Unicode text");
|
||||
auto encrypted = ks::protect(encoded);
|
||||
require(encrypted != encoded, "history ciphertext differs");
|
||||
require(ks::unprotect(encrypted) == encoded, "Windows DPAPI roundtrip");
|
||||
|
||||
Reference in New Issue
Block a user