Implement native Windows KSwitcher with configurable hotkeys and clipboard history
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
#include "app.h"
|
||||
#include <iostream>
|
||||
|
||||
namespace
|
||||
{
|
||||
ks::App *app{};
|
||||
HWND host{}, field{};
|
||||
ks::Clip saved;
|
||||
bool captured = false, failed = false;
|
||||
int stage = 0, ru = -1, en = -1;
|
||||
ULONGLONG deadline{};
|
||||
std::wstring sentinel = L"KSwitcher integration clipboard sentinel";
|
||||
void require(bool ok, const char *name)
|
||||
{
|
||||
if (!ok)
|
||||
throw std::runtime_error(name);
|
||||
std::cout << "PASS " << name << '\n';
|
||||
}
|
||||
std::wstring text()
|
||||
{
|
||||
wchar_t buffer[2048]{};
|
||||
GetWindowTextW(field, buffer, 2048);
|
||||
return buffer;
|
||||
}
|
||||
void prepare(const wchar_t *value, bool select)
|
||||
{
|
||||
app->resetInput();
|
||||
SetWindowTextW(field, value);
|
||||
ks::activateWindow(host);
|
||||
SetFocus(field);
|
||||
int end = GetWindowTextLengthW(field);
|
||||
SendMessageW(field, EM_SETSEL, select ? 0 : end, end);
|
||||
if (GetForegroundWindow() != host)
|
||||
throw std::runtime_error("Test editor could not acquire foreground; no input was sent");
|
||||
}
|
||||
void record(const std::wstring &keys)
|
||||
{
|
||||
std::lock_guard lock(app->stateMutex);
|
||||
for (wchar_t c : keys)
|
||||
app->run.record(c, host, app->layouts[en].handle, GetTickCount64());
|
||||
}
|
||||
void finish()
|
||||
{
|
||||
KillTimer(host, 1);
|
||||
if (captured)
|
||||
{
|
||||
try
|
||||
{
|
||||
app->ownSequence = ks::writeClipboard(app->window, saved);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
DestroyWindow(host);
|
||||
PostMessageW(app->window, WM_CLOSE, 0, 0);
|
||||
}
|
||||
LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||
{
|
||||
if (msg == WM_TIMER)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (GetTickCount64() > deadline)
|
||||
throw std::runtime_error("Native integration timed out");
|
||||
if (app->busy)
|
||||
return 0;
|
||||
switch (stage++)
|
||||
{
|
||||
case 0:
|
||||
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
||||
captured = true;
|
||||
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
|
||||
prepare(L"ghbdtn", true);
|
||||
app->transform(ks::Direct + ru);
|
||||
break;
|
||||
case 1:
|
||||
std::cout << "Observed editor: " << ks::utf8(text()) << "; busy=" << app->busy
|
||||
<< "; foreground=" << (GetForegroundWindow() == host) << "\n";
|
||||
require(text() == L"привет", "selection conversion through actual Ctrl+C / Ctrl+V");
|
||||
require(ks::readClipboard(app->window).text() == sentinel, "clipboard restored");
|
||||
app->transform(ks::Undo);
|
||||
break;
|
||||
case 2:
|
||||
require(text() == L"ghbdtn", "verified undo");
|
||||
prepare(L"hello world", true);
|
||||
app->transform(ks::Case);
|
||||
break;
|
||||
case 3:
|
||||
require(text() == L"Hello World", "case conversion");
|
||||
app->transform(ks::Case);
|
||||
break;
|
||||
case 4:
|
||||
require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result");
|
||||
prepare(L"OLDghbdtn", false);
|
||||
record(L"ghbdtn");
|
||||
app->transform(ks::Direct + ru);
|
||||
break;
|
||||
case 5:
|
||||
std::cout << "Recent observed: " << ks::utf8(text()) << "\n";
|
||||
require(text() == L"OLDпривет", "recent-input conversion preserves adjacent older text");
|
||||
prepare(L"OLDabcdef", false);
|
||||
record(L"ghbdtn");
|
||||
app->transform(ks::Direct + ru);
|
||||
break;
|
||||
case 6:
|
||||
require(text() == L"OLDabcdef", "recent-input mismatch refuses replacement");
|
||||
app->settings.snippets = {{L";addr", L"LINE1\r\n{cursor}LINE2"}};
|
||||
prepare(L"prefix ;addr", false);
|
||||
record(L";addr");
|
||||
app->transform(0, 0);
|
||||
break;
|
||||
case 7:
|
||||
{
|
||||
require(text() == L"prefix LINE1\r\nLINE2", "multiline snippet expansion");
|
||||
DWORD start{}, end{};
|
||||
SendMessageW(field, EM_GETSEL, (WPARAM)&start, (LPARAM)&end);
|
||||
require(start == end && start == 14, "verified snippet cursor placement");
|
||||
require(ks::readClipboard(app->window).text() == sentinel,
|
||||
"clipboard restored after repeated copy transactions");
|
||||
finish();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
failed = true;
|
||||
std::cerr << "FAIL " << e.what() << '\n';
|
||||
finish();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (msg == WM_CLOSE)
|
||||
{
|
||||
failed = true;
|
||||
finish();
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProcW(w, msg, wp, lp);
|
||||
}
|
||||
} // namespace
|
||||
int main()
|
||||
{
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
auto root = std::filesystem::temp_directory_path() /
|
||||
(L"KSwitcher-integration-" + std::to_wstring(GetCurrentProcessId()));
|
||||
try
|
||||
{
|
||||
{
|
||||
ks::App instance(GetModuleHandleW(nullptr), false, root);
|
||||
app = &instance;
|
||||
app->settings.history = false;
|
||||
app->settings.switchLayout = false;
|
||||
for (int i = 0; i < (int)app->layouts.size(); ++i)
|
||||
{
|
||||
auto language = LOWORD((uintptr_t)app->layouts[i].handle);
|
||||
if (language == 0x409)
|
||||
en = i;
|
||||
if (language == 0x419)
|
||||
ru = i;
|
||||
}
|
||||
if (en < 0 || ru < 0)
|
||||
throw std::runtime_error(
|
||||
"Integration test requires installed English US and Russian layouts");
|
||||
WNDCLASSW cls{};
|
||||
cls.hInstance = GetModuleHandleW(nullptr);
|
||||
cls.lpfnWndProc = procedure;
|
||||
cls.lpszClassName = L"KSwitcher.TestEditor";
|
||||
cls.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1);
|
||||
RegisterClassW(&cls);
|
||||
host = CreateWindowW(cls.lpszClassName, L"KSwitcher — automated test editor",
|
||||
WS_OVERLAPPEDWINDOW | WS_VISIBLE, 100, 100, 650, 320, nullptr, nullptr,
|
||||
cls.hInstance, nullptr);
|
||||
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;
|
||||
SetTimer(host, 1, 100, nullptr);
|
||||
app->execute();
|
||||
}
|
||||
std::filesystem::remove_all(root);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
std::cerr << e.what() << '\n';
|
||||
failed = true;
|
||||
}
|
||||
CoUninitialize();
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#include "ocr.h"
|
||||
#include <iostream>
|
||||
int main()
|
||||
{
|
||||
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
try
|
||||
{
|
||||
BITMAPINFO info{};
|
||||
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
|
||||
info.bmiHeader.biWidth = 800;
|
||||
info.bmiHeader.biHeight = -150;
|
||||
info.bmiHeader.biPlanes = 1;
|
||||
info.bmiHeader.biBitCount = 32;
|
||||
void *data{};
|
||||
HBITMAP bitmap = CreateDIBSection(nullptr, &info, DIB_RGB_COLORS, &data, nullptr, 0);
|
||||
if (!bitmap)
|
||||
throw std::runtime_error("Cannot create OCR fixture");
|
||||
HDC dc = CreateCompatibleDC(nullptr);
|
||||
auto old = SelectObject(dc, bitmap);
|
||||
RECT rect{0, 0, 800, 150};
|
||||
FillRect(dc, &rect, (HBRUSH)GetStockObject(WHITE_BRUSH));
|
||||
HFONT font = CreateFontW(-54, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, 0, 0,
|
||||
ANTIALIASED_QUALITY, 0, L"Arial");
|
||||
auto oldFont = SelectObject(dc, font);
|
||||
SetTextColor(dc, RGB(0, 0, 0));
|
||||
SetBkColor(dc, RGB(255, 255, 255));
|
||||
TextOutW(dc, 20, 40, L"HELLO KSWITCHER", 15);
|
||||
GdiFlush();
|
||||
ks::Pixels pixels{800, 150, ks::Bytes((BYTE *)data, (BYTE *)data + 800 * 150 * 4)};
|
||||
SelectObject(dc, oldFont);
|
||||
SelectObject(dc, old);
|
||||
DeleteObject(font);
|
||||
DeleteObject(bitmap);
|
||||
DeleteDC(dc);
|
||||
ks::Clip clip{{ks::imageDib(pixels)}};
|
||||
auto decoded = ks::decodeImage(clip);
|
||||
if (decoded.width != 800 || decoded.height != 150)
|
||||
throw std::runtime_error("WIC decode size mismatch");
|
||||
auto text = ks::recognizeText(clip);
|
||||
std::cout << "OCR: " << ks::utf8(text) << '\n';
|
||||
if (ks::upper(text).find(L"HELLO") == std::wstring::npos)
|
||||
throw std::runtime_error("OCR fixture was not recognized");
|
||||
std::cout << "PASS native Windows OCR, DIB decoding and async result retrieval\n";
|
||||
CoUninitialize();
|
||||
return 0;
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
std::cerr << e.what() << '\n';
|
||||
CoUninitialize();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
#include "ocr.h"
|
||||
#include "richtext.h"
|
||||
#include "storage.h"
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <richedit.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
int passed = 0;
|
||||
void require(bool ok, const char *name)
|
||||
{
|
||||
if (!ok)
|
||||
throw std::runtime_error(name);
|
||||
++passed;
|
||||
}
|
||||
void rejects(const std::function<void()> &f, const char *name)
|
||||
{
|
||||
bool thrown = false;
|
||||
try
|
||||
{
|
||||
f();
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
thrown = true;
|
||||
}
|
||||
require(thrown, name);
|
||||
}
|
||||
} // namespace
|
||||
int main()
|
||||
{
|
||||
try
|
||||
{
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
HWND richParent = CreateWindowExW(0, L"STATIC", L"Rich preview test", WS_POPUP, 0, 0, 300, 200,
|
||||
nullptr, nullptr, GetModuleHandleW(nullptr), nullptr);
|
||||
HWND rich = ks::createRichText(richParent, 1, false);
|
||||
require(rich != nullptr, "native RichEdit loads without a browser runtime");
|
||||
auto styled = ks::textClip(L"plain bold italic under\nnext");
|
||||
std::string html = "Version:1.0\r\n<html><!--StartFragment--><div>plain <strong>bold</strong> "
|
||||
"<em>italic</em> <u>under</u><br>next</div><!--EndFragment--></html>";
|
||||
styled.formats.push_back({0, L"HTML Format", ks::Bytes(html.begin(), html.end())});
|
||||
require(ks::setRichText(rich, ks::richPreview(styled)),
|
||||
"HTML clipboard preview streams into RichEdit");
|
||||
auto effects = [&](LONG first, LONG last)
|
||||
{
|
||||
CHARRANGE range{first, last};
|
||||
SendMessageW(rich, EM_EXSETSEL, 0, (LPARAM)&range);
|
||||
CHARFORMAT2W format{};
|
||||
format.cbSize = sizeof(format);
|
||||
SendMessageW(rich, EM_GETCHARFORMAT, SCF_SELECTION, (LPARAM)&format);
|
||||
return format.dwEffects;
|
||||
};
|
||||
require((effects(6, 10) & CFE_BOLD) != 0, "HTML bold is displayed as native bold");
|
||||
require((effects(11, 17) & CFE_ITALIC) != 0, "HTML italic is displayed as native italic");
|
||||
require((effects(18, 23) & CFE_UNDERLINE) != 0, "HTML underline is displayed natively");
|
||||
require((effects(0, 5) & (CFE_BOLD | CFE_ITALIC | CFE_UNDERLINE)) == 0,
|
||||
"inline formatting does not leak into plain text");
|
||||
wchar_t rendered[256]{};
|
||||
GetWindowTextW(rich, rendered, 256);
|
||||
require(std::wstring(rendered).find(L"\r\nnext") != std::wstring::npos ||
|
||||
std::wstring(rendered).find(L"\rnext") != std::wstring::npos,
|
||||
"HTML line breaks are retained");
|
||||
html = "<!--StartFragment--><span style='font-weight:700'>Привіт & 🌍</span>"
|
||||
"<script>never displayed</script><!--EndFragment-->";
|
||||
styled.formats.back().data = ks::Bytes(html.begin(), html.end());
|
||||
ks::setRichText(rich, ks::richPreview(styled));
|
||||
GetWindowTextW(rich, rendered, 256);
|
||||
require(std::wstring(rendered) == L"Привіт & 🌍",
|
||||
"HTML entities and Unicode survive without script text");
|
||||
require((effects(0, 6) & CFE_BOLD) != 0, "inline CSS bold is rendered");
|
||||
auto plain = ks::textClip(L"{\\literal} Привіт");
|
||||
ks::setRichText(rich, ks::richPreview(plain));
|
||||
GetWindowTextW(rich, rendered, 256);
|
||||
require(std::wstring(rendered) == plain.text(), "plain Unicode and RTF metacharacters are escaped");
|
||||
std::string rtf = "{\\rtf1\\ansi \\b native}";
|
||||
plain.formats.push_back({0, L"Rich Text Format", ks::Bytes(rtf.begin(), rtf.end())});
|
||||
ks::setRichText(rich, ks::richPreview(plain));
|
||||
require((effects(0, 6) & CFE_BOLD) != 0, "original RTF formatting is preserved");
|
||||
DestroyWindow(richParent);
|
||||
ks::Layout en = ks::physicalLayout();
|
||||
en.id = L"en";
|
||||
ks::Layout ru{nullptr, L"ru", L"Russian", L"ё1234567890-=йцукенгшщзхъфывапролджэячсмитьбю.\\",
|
||||
L"Ё!\"№;%:?*()_+ЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ,/"};
|
||||
ks::Layout uk{nullptr, L"uk", L"Ukrainian", L"'1234567890-=йцукенгшщзхїфівапролджєячсмитьбю.\\",
|
||||
L"₴!\"№;%:?*()_+ЙЦУКЕНГШЩЗХЇФІВАПРОЛДЖЄЯЧСМИТЬБЮ,/"};
|
||||
require(en.normal.size() == ru.normal.size(), "physical maps align");
|
||||
require(en.normal.size() == uk.normal.size(), "Ukrainian maps align");
|
||||
require(ks::convert(L"ghbdtn", en, ru) == L"привет", "English to Russian");
|
||||
require(ks::convert(L"привіт", uk, en) == L"ghbdsn", "Ukrainian to English");
|
||||
require(ks::convert(L"hello привет", en, ru) == L"руддщ привет", "mixed text preserves target words");
|
||||
require(ks::convert(L"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words");
|
||||
std::vector<ks::Layout> layouts{en, ru, uk};
|
||||
require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion");
|
||||
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
|
||||
require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion");
|
||||
require(ks::cycleCase(L"hello world") == L"Hello World", "title case");
|
||||
require(ks::cycleCase(L"Hello World") == L"HELLO WORLD", "upper case");
|
||||
require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case");
|
||||
require(ks::transliterate(L"Привет") == L"Privet", "forward transliteration");
|
||||
require(ks::transliterate(L"Київ") == L"Kyiv", "Ukrainian transliteration");
|
||||
require(ks::transliterate(L"Shchuka") == L"Щука", "reverse transliteration");
|
||||
require(ks::parseTemplate(L"a{cursor}b").suffix == L"b", "snippet suffix");
|
||||
require(ks::parseTemplate(L"{{cursor}}").text == L"{cursor}", "escaped cursor");
|
||||
require(ks::parseTemplate(L"{cursor}{cursor}").cursors == 2, "duplicate cursor detection");
|
||||
require(ks::Hotkey::parse(L"Ctrl+Alt+Shift+Space")->key == VK_SPACE, "hotkey parse");
|
||||
require(!ks::Hotkey::parse(L"Ctrl+F25"), "invalid function key");
|
||||
require(!ks::Hotkey::parse(L"Ctrl+A+B"), "multiple keys rejected");
|
||||
require(ks::Hotkey::parse(L"")->key == 0, "disabled shortcut");
|
||||
ks::HotkeyHook hook;
|
||||
std::vector<std::pair<int, ks::Hotkey>> bindings{
|
||||
{5, {MOD_WIN, 'V'}}, {1, {MOD_ALT, VK_F12}}, {100, {MOD_CONTROL | MOD_SHIFT, 'U'}}};
|
||||
require(hook.update('V', true, MOD_WIN, bindings) == 5, "Win+V dispatches configured action");
|
||||
require(hook.update('V', true, MOD_WIN, bindings) == -1, "held hotkey does not repeat");
|
||||
require(hook.update('V', false, 0, {}) == -1, "key-up consumed after settings change");
|
||||
require(hook.update('V', true, MOD_CONTROL, bindings) == 0, "unassigned Ctrl+V unaffected");
|
||||
require(hook.update('V', true, MOD_WIN | MOD_SHIFT, bindings) == 0, "modifiers match exactly");
|
||||
require(hook.update('V', true, MOD_WIN, {}) == 0, "disabled binding leaves OS shortcut intact");
|
||||
require(hook.update(VK_F12, true, MOD_ALT, bindings) == 1, "conversion uses same hook");
|
||||
require(hook.update('U', true, MOD_CONTROL | MOD_SHIFT, bindings) == 100,
|
||||
"direct layout uses same hook");
|
||||
require(hook.update(VK_F12, false, 0, bindings) == -1, "independent key release consumed");
|
||||
require(hook.update('U', false, 0, bindings) == -1, "second key release consumed");
|
||||
require(hook.update('V', true, MOD_WIN, bindings) == 5, "hotkey activates again after release");
|
||||
bindings.push_back({2, {MOD_WIN | MOD_SHIFT, 'S'}});
|
||||
require(hook.update('S', true, MOD_WIN | MOD_SHIFT, bindings) == 2,
|
||||
"Win+Shift+S can replace the Snipping Tool shortcut");
|
||||
require(hook.update('S', false, 0, bindings) == -1, "Snipping Tool shortcut release consumed");
|
||||
for (UINT key :
|
||||
{UINT('K'), UINT(VK_F12), UINT(VK_SPACE), UINT(VK_OEM_PLUS), UINT(VK_LEFT), UINT(VK_NUMPAD7)})
|
||||
{
|
||||
ks::Hotkey recorded{MOD_CONTROL | MOD_ALT | MOD_SHIFT, key};
|
||||
require(ks::Hotkey::parse(recorded.format()) == recorded, "recorded shortcut roundtrip");
|
||||
}
|
||||
ks::Run run;
|
||||
run.record(L'g', (HWND)1, (HKL)1, 1);
|
||||
run.record(L'h', (HWND)1, (HKL)1, 2);
|
||||
require(run.match(L"пр", layouts, en) == 1, "recent input verification");
|
||||
require(run.match(L"xx", layouts, en) == -1, "unrelated selected text rejected");
|
||||
require(run.match(L"xпр", layouts, en) == -1,
|
||||
"word selection including older text is rejected before replacement");
|
||||
require(run.match(L"р", layouts, en) == -1,
|
||||
"coalesced Teams selection of only the final character is rejected");
|
||||
require(run.match(L"пр ", layouts, en) == -1,
|
||||
"word selection including unrecorded whitespace is rejected");
|
||||
run.record(L'b', (HWND)1, (HKL)1, 2002);
|
||||
require(run.keys == L"b", "pause resets run");
|
||||
run.record(L'c', (HWND)2, (HKL)1, 2003);
|
||||
require(run.keys == L"c", "window resets run");
|
||||
run.record(L'd', (HWND)2, (HKL)2, 2004);
|
||||
require(run.keys == L"d", "layout resets run");
|
||||
run.record(L' ', (HWND)2, (HKL)2, 2005);
|
||||
run.record(L'e', (HWND)2, (HKL)2, 2006);
|
||||
require(run.keys == L"e", "word boundary resets run");
|
||||
for (int i = 0; i < 513; ++i)
|
||||
run.record(L'x', (HWND)2, (HKL)2, 2007 + i);
|
||||
require(run.overflow && run.keys.empty(), "overflow does not select partial input");
|
||||
std::map<DWORD, std::pair<DWORD, std::wstring>> processes{
|
||||
{10, {1, L"ms-teams.exe"}}, {20, {10, L"msedgewebview2.exe"}},
|
||||
{30, {20, L"msedgewebview2.exe"}}, {40, {1, L"msedgewebview2.exe"}},
|
||||
{50, {10, L"other.exe"}}, {60, {60, L"msedgewebview2.exe"}}};
|
||||
auto read = [&](DWORD id) -> std::optional<std::pair<DWORD, std::wstring>>
|
||||
{
|
||||
auto it = processes.find(id);
|
||||
return it == processes.end() ? std::nullopt : std::optional(it->second);
|
||||
};
|
||||
require(ks::webViewDescendant(30, 10, read), "Teams renderer belongs to host");
|
||||
require(!ks::webViewDescendant(40, 10, read), "other WebView rejected");
|
||||
require(!ks::webViewDescendant(50, 10, read), "arbitrary child rejected");
|
||||
require(!ks::webViewDescendant(60, 10, read), "process cycles rejected");
|
||||
require(!ks::webViewDescendant(0, 0, read), "unknown process rejected");
|
||||
auto clip = ks::textClip(L"Привіт 🌍\r\nline 2");
|
||||
clip.formats.push_back({0, L"HTML Format", {1, 2, 3, 0}});
|
||||
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 encrypted = ks::protect(encoded);
|
||||
require(encrypted != encoded, "history ciphertext differs");
|
||||
require(ks::unprotect(encrypted) == encoded, "Windows DPAPI roundtrip");
|
||||
encrypted[encrypted.size() / 2] ^= 0x40;
|
||||
rejects([&] { ks::unprotect(encrypted); }, "tampering rejected");
|
||||
encoded.pop_back();
|
||||
rejects([&] { ks::decodeClip(encoded); }, "truncated clip rejected");
|
||||
auto temp = std::filesystem::temp_directory_path() /
|
||||
(L"KSwitcher-tests-" + std::to_wstring(GetCurrentProcessId()));
|
||||
std::filesystem::create_directories(temp);
|
||||
ks::Pixels pixels{2, 1, {10, 20, 30, 255, 40, 50, 60, 255}};
|
||||
ks::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}};
|
||||
auto thumbnail = ks::decodeImage(firstImage, 1, 1);
|
||||
require(thumbnail.width == 1 && thumbnail.height == 1 && thumbnail.bgra.size() == 4,
|
||||
"thumbnail decoding limits the resident pixel buffer");
|
||||
thumbnail = ks::decodeImage(firstImage, 208, 128);
|
||||
require(thumbnail.width == 2 && thumbnail.height == 1,
|
||||
"thumbnail decoding does not upscale small images");
|
||||
auto secondImage = firstImage;
|
||||
secondImage.formats[1].data = {4, 5, 6};
|
||||
secondImage.formats.push_back({0, L"DataObject", {9, 8, 7}});
|
||||
require(ks::sameClipboardImage(firstImage, secondImage), "OLE metadata does not duplicate an image");
|
||||
std::reverse(secondImage.formats.begin(), secondImage.formats.end());
|
||||
require(ks::sameClipboardImage(firstImage, secondImage),
|
||||
"format ordering does not duplicate an image");
|
||||
auto recoded = firstImage;
|
||||
recoded.formats[0].data[24] = 1; // Different bitmap resolution metadata; identical pixels.
|
||||
require(ks::sameClipboardImage(firstImage, recoded), "decoded image ignores resolution metadata");
|
||||
pixels.bgra[0]++;
|
||||
ks::Clip changedImage{{ks::imageDib(pixels)}};
|
||||
require(!ks::sameClipboardImage(firstImage, changedImage),
|
||||
"a changed pixel remains a distinct image");
|
||||
auto withText = firstImage;
|
||||
withText.formats.push_back(ks::textClip(L"caption").formats.front());
|
||||
require(!ks::sameClipboardImage(firstImage, withText),
|
||||
"image deduplication preserves accompanying text");
|
||||
auto withFiles = firstImage;
|
||||
withFiles.formats.push_back({CF_HDROP, L"", {1}});
|
||||
require(!ks::sameClipboardImage(firstImage, withFiles), "image deduplication preserves file copies");
|
||||
{
|
||||
ks::History history(temp / L"images");
|
||||
ks::Settings defaults;
|
||||
history.add(firstImage, L"SnippingTool", defaults);
|
||||
history.pin(history.entries().front().id);
|
||||
history.add(secondImage, L"SnippingTool", defaults);
|
||||
require(history.entries().size() == 1 && history.entries().front().pinned,
|
||||
"Snipping Tool republish retains one pinned history entry");
|
||||
history.add(changedImage, L"SnippingTool", defaults);
|
||||
require(history.entries().size() == 2, "a new screenshot is retained");
|
||||
}
|
||||
ks::Settings settings;
|
||||
settings.elevatedAutostart = true;
|
||||
settings.cycle = {L"en", L"uk"};
|
||||
settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}};
|
||||
ks::saveSettings(temp / L"settings.json", settings);
|
||||
auto restored = ks::loadSettings(temp / L"settings.json", {});
|
||||
require(restored.snippets[0].text == settings.snippets[0].text, "settings snippet roundtrip");
|
||||
require(restored.cycle == settings.cycle, "layout order roundtrip");
|
||||
require(restored.elevatedAutostart, "elevated autostart setting roundtrip");
|
||||
require(!ks::loadSettings(temp / L"defaults.json", {}).elevatedAutostart,
|
||||
"elevated autostart is opt-in for existing and default settings");
|
||||
auto invalid = settings;
|
||||
invalid.undo = invalid.toggle;
|
||||
rejects([&] { ks::validateSettings(invalid); }, "duplicate shortcut rejected");
|
||||
invalid = settings;
|
||||
invalid.snippets[0].text = L"{cursor}{cursor}";
|
||||
rejects([&] { ks::validateSettings(invalid); }, "invalid snippet rejected");
|
||||
{
|
||||
ks::History history(temp / L"history");
|
||||
history.add(clip, L"Test", settings);
|
||||
history.add(clip, L"Test", settings);
|
||||
require(history.entries().size() == 1, "history consecutive deduplication");
|
||||
auto id = history.entries()[0].id;
|
||||
history.pin(id);
|
||||
require(history.entries()[0].pinned, "pin persisted in memory");
|
||||
require(history.search(L"line 2").size() == 1, "full text search");
|
||||
require(history.load(id).text() == clip.text(), "history payload roundtrip");
|
||||
}
|
||||
{
|
||||
ks::History history(temp / L"history");
|
||||
require(history.entries().size() == 1 && history.entries()[0].pinned, "history index reload");
|
||||
rejects([&] { history.load(L"../other"); }, "history path traversal rejected");
|
||||
history.clear();
|
||||
require(history.entries().empty(), "clear history");
|
||||
}
|
||||
// Test-owned directory only; never touch the user's clipboard or stored history.
|
||||
std::filesystem::remove_all(temp);
|
||||
CoUninitialize();
|
||||
std::cout << "Passed " << passed << " native regression checks\n";
|
||||
return 0;
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
std::cerr << "FAILED: " << e.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user