Release v0.6.39: isolate history UI and image clipboard memory

This commit is contained in:
2026-11-04 19:40:54 +02:00
parent fa2f6d2ce4
commit 00c9cb0ee4
22 changed files with 747 additions and 49 deletions
+108
View File
@@ -0,0 +1,108 @@
// Run with the resident stopped. The suite restores the original clipboard.
#include "storage.h"
#include "ocr.h"
#include <psapi.h>
#include <iostream>
#include <algorithm>
static void require(bool ok, const char *name) {
if (!ok) throw std::runtime_error(name);
std::cout << "PASS " << name << '\n';
}
static HWND owner() {
return CreateWindowExW(0,L"STATIC",L"Snapshot test",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
}
static void child(const std::wstring &argument) {
auto exe=ks::executable(), command=L"\""+exe+L"\" "+argument;
STARTUPINFOW startup{sizeof(startup)}; startup.dwFlags=STARTF_USESHOWWINDOW; startup.wShowWindow=SW_HIDE;
PROCESS_INFORMATION p{};
if(!CreateProcessW(exe.c_str(),command.data(),nullptr,nullptr,FALSE,CREATE_NO_WINDOW,nullptr,nullptr,&startup,&p))
throw std::runtime_error("start fixture process");
CloseHandle(p.hThread);
auto wait=WaitForSingleObject(p.hProcess,30000); DWORD code=1;
if(wait!=WAIT_OBJECT_0) {TerminateProcess(p.hProcess,1);WaitForSingleObject(p.hProcess,INFINITE);}
GetExitCodeProcess(p.hProcess,&code); CloseHandle(p.hProcess);
if(code) throw std::runtime_error("fixture process failed");
}
static size_t resident() {
ks::Bytes b(1024*1024); auto ws=reinterpret_cast<PSAPI_WORKING_SET_INFORMATION*>(b.data());
if(!QueryWorkingSet(GetCurrentProcess(),ws,DWORD(b.size()))) throw std::runtime_error("working set query");
size_t pages=0; for(size_t i=0;i<ws->NumberOfEntries;++i) if(!ws->WorkingSetInfo[i].Shared) ++pages;
return pages*4096;
}
static void verifyImage(HWND w) {
auto c=ks::readClipboard(w,64*1024*1024);
for(auto id:{CF_DIB,CF_DIBV5}) {
auto it=std::find_if(c.formats.begin(),c.formats.end(),[&](const auto &f){return f.id==UINT(id);});
require(it!=c.formats.end(),"restores image representation");
auto pixels=ks::decodeImage(ks::Clip{{*it}});
require(pixels.width==1566 && pixels.height==1068 && pixels.bgra.front()==77,"preserves image pixels");
}
require(c.text()==L"Snapshot mixed text","preserves accompanying text");
}
int main(int argc,char**argv) {
CoInitializeEx(nullptr,COINIT_MULTITHREADED);
HWND w=owner(); int result=0; ks::Clip original; bool saved=false;
try {
std::string mode=argc>1?argv[1]:"suite";
if(mode=="publish") {
ks::Pixels p{1566,1068,ks::Bytes(1566*1068*4,255)}; p.bgra[0]=77;
auto dib=ks::imageDib(p); auto v5=dib;
BITMAPV5HEADER h{}; h.bV5Size=sizeof(h); h.bV5Width=p.width; h.bV5Height=-p.height;
h.bV5Planes=1; h.bV5BitCount=32; h.bV5Compression=BI_BITFIELDS;
h.bV5RedMask=0xff0000; h.bV5GreenMask=0xff00; h.bV5BlueMask=0xff; h.bV5AlphaMask=0xff000000;
v5.id=CF_DIBV5; v5.data.resize(sizeof(h)+p.bgra.size());
memcpy(v5.data.data(),&h,sizeof(h)); memcpy(v5.data.data()+sizeof(h),p.bgra.data(),p.bgra.size());
auto clip=ks::textClip(L"Snapshot mixed text"); clip.formats.push_back(dib); clip.formats.push_back(v5);
ks::writeClipboard(w,clip);
} else if(mode=="old" || mode=="new") {
child(L"publish"); auto baseline=resident();
if(mode=="old") {
auto clip=ks::readClipboard(w,64*1024*1024);
ks::writeClipboard(w,ks::textClip(L"temporary")); ks::writeClipboard(w,clip);
} else {
ks::ClipboardSnapshot snapshot; snapshot.capture(w,GetClipboardSequenceNumber());
auto seq=ks::writeClipboard(w,ks::textClip(L"temporary"));
require(snapshot.restore(w,seq)==GetClipboardSequenceNumber(),"helper returns current sequence");
}
auto after=resident();
auto report="baseline="+std::to_string(baseline)+" after="+std::to_string(after)+" delta="+std::to_string(int64_t(after)-int64_t(baseline))+"\n";
auto path=std::filesystem::path(ks::executable()).parent_path()/(L"snapshot-memory-"+ks::wide(mode)+L".txt");
ks::atomicWrite(path,ks::Bytes(report.begin(),report.end()));
verifyImage(w); // Only after measuring: verification intentionally reads images.
} else {
original=ks::readClipboard(w,128*1024*1024); saved=true;
child(L"old"); child(L"new"); child(L"publish");
auto seq=GetClipboardSequenceNumber();
auto selected=ks::readClipboard(w,32*1024*1024,ks::ClipboardRead::Selection);
require(selected.formats.size()==1 && selected.text()==L"Snapshot mixed text","selection read excludes image data");
bool rejected=false;
try {ks::readClipboard(w,32*1024*1024,ks::ClipboardRead::WithoutImages,seq);} catch(...) {rejected=true;}
require(rejected,"resident rejects images before reading their data");
{
ks::ClipboardSnapshot snapshot; snapshot.capture(w,seq);
auto stale=ks::writeClipboard(w,ks::textClip(L"temporary"));
ks::writeClipboard(w,ks::textClip(L"external copy")); rejected=false;
try {snapshot.restore(w,stale);} catch(...) {rejected=true;}
require(rejected && ks::readClipboard(w).text()==L"external copy","stale image restore preserves external copy");
}
{
ks::ClipboardSnapshot snapshot; snapshot.capture(w,GetClipboardSequenceNumber());
auto temporary=ks::writeClipboard(w,ks::textClip(L"temporary"));
snapshot.restore(w,temporary);
require(ks::readClipboard(w).text()==L"external copy","text snapshot fast path restores clipboard");
}
{
ks::Clip empty; auto seqEmpty=ks::writeClipboard(w,empty);
ks::ClipboardSnapshot snapshot; snapshot.capture(w,seqEmpty);
snapshot.restore(w,ks::writeClipboard(w,ks::textClip(L"temporary")));
require(ks::readClipboard(w).formats.empty(),"empty clipboard restored");
}
auto marked=ks::textClip(L"line"); marked.formats.push_back({0,L"MSDEVLineSelect",ks::Bytes{1}});
ks::writeClipboard(w,marked);
require(ks::readClipboard(w,1024,ks::ClipboardRead::Selection).formats.size()==2,"selection reader preserves editor guard metadata");
std::cout<<"PASS clipboard snapshot suite\n";
}
} catch(const std::exception &e) {std::cerr<<"FAIL "<<e.what()<<'\n';result=1;}
if(saved) try {ks::writeClipboard(w,original);} catch(...) {result=1;}
DestroyWindow(w); CoUninitialize(); return result;
}
+169
View File
@@ -0,0 +1,169 @@
// Run from an isolated fixture folder containing KSwitcher.exe and Screenshot.exe,
// with the normal resident stopped. Never points at the installed user's data.
#include "storage.h"
#include "ocr.h"
#include "json.hpp"
#include <commctrl.h>
#include <psapi.h>
#include <iostream>
static HWND editor{};
static bool pastedImage{};
static LRESULT CALLBACK targetProc(HWND w,UINT message,WPARAM wp,LPARAM lp) {
if(message==WM_SETFOCUS && editor) {SetFocus(editor);return 0;}
return DefWindowProcW(w,message,wp,lp);
}
static void require(bool ok,const char *what) {if(!ok)throw std::runtime_error(what);std::cout<<"PASS "<<what<<'\n';}
static void pump() {MSG m;while(PeekMessageW(&m,nullptr,0,0,PM_REMOVE)){TranslateMessage(&m);DispatchMessageW(&m);}}
template<class F> static void until(F condition,const char *what,DWORD timeout=10000) {
auto end=GetTickCount64()+timeout;
while(!condition()){if(GetTickCount64()>=end)throw std::runtime_error(what);pump();Sleep(10);}
}
static LRESULT send(HWND w,UINT message,WPARAM wp=0,LPARAM lp=0) {
DWORD_PTR result{};
if(!SendMessageTimeoutW(w,message,wp,lp,SMTO_ABORTIFHUNG,5000,&result))throw std::runtime_error("window request timed out");
return LRESULT(result);
}
static void command(HWND resident,int cmd) {
COPYDATASTRUCT data{1,sizeof(cmd),&cmd};send(resident,WM_COPYDATA,0,LPARAM(&data));
}
static size_t memory(HANDLE process) {
ks::Bytes bytes(1024*1024);auto ws=reinterpret_cast<PSAPI_WORKING_SET_INFORMATION*>(bytes.data());
if(!QueryWorkingSet(process,ws,DWORD(bytes.size())))throw std::runtime_error("memory query failed");
size_t pages=0;for(size_t i=0;i<ws->NumberOfEntries;++i)if(!ws->WorkingSetInfo[i].Shared)++pages;
return pages*4096;
}
static std::wstring editorText() {wchar_t text[256]{};GetWindowTextW(editor,text,256);return text;}
static LRESULT CALLBACK editProc(HWND w,UINT message,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR) {
if(message==WM_PASTE && IsClipboardFormatAvailable(CF_DIB)) {pastedImage=true;return 0;}
return DefSubclassProc(w,message,wp,lp);
}
static ks::Clip excluded(ks::Clip clip) {
clip.formats.push_back({0,L"ExcludeClipboardContentFromMonitorProcessing",ks::Bytes{0}});return clip;
}
int main() {
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
PROCESS_INFORMATION process{};HWND resident{},target{};ks::Clip saved;bool captured=false;int result=0;
try {
auto folder=std::filesystem::path(ks::executable()).parent_path();
require(folder.filename().wstring().starts_with(L"history-host-fixture-"),"isolated fixture directory");
require(!FindWindowW(L"KSwitcher.Main",nullptr),"normal resident is stopped");
WNDCLASSW targetClass{};targetClass.lpfnWndProc=targetProc;targetClass.hInstance=GetModuleHandleW(nullptr);
targetClass.lpszClassName=L"KSwitcher.HistoryTestDestination";RegisterClassW(&targetClass);
target=CreateWindowExW(0,targetClass.lpszClassName,L"KSwitcher history test destination",WS_OVERLAPPEDWINDOW|WS_VISIBLE,
50,50,500,250,nullptr,nullptr,nullptr,nullptr);
editor=CreateWindowExW(0,L"EDIT",L"",WS_CHILD|WS_VISIBLE|ES_MULTILINE,10,10,450,180,target,nullptr,nullptr,nullptr);
SetWindowSubclass(editor,editProc,1,0);
saved=ks::readClipboard(target,128*1024*1024);captured=true;
ks::writeClipboard(target,excluded(ks::textClip(L"fixture clipboard")));
auto settings=ks::loadSettings(folder/L"missing.json",ks::readLayouts());
settings.autoCorrect=false;settings.writeLog=true;settings.history=true;settings.maxItems=500;settings.maxMiB=100;
std::filesystem::create_directories(folder/L"data");
ks::saveSettings(folder/L"data/settings.json",settings);
ks::History history(folder/L"data/history");
history.clear(); // This executable only runs in its dedicated fixture directory.
ks::Pixels pixels{1566,1068,ks::Bytes(1566*1068*4,255)};
auto image=ks::Clip{{ks::imageDib(pixels)}};
history.add(image,L"test",settings);
auto imageId=history.entries().front().id;
// Seed once rather than rewriting the encrypted index 500 times. This
// also avoids measuring fixture creation in the resident under test.
auto indexPath=folder/L"data/history/index.dat";
auto rows=nlohmann::json::parse(ks::unprotect(ks::readFile(indexPath,1024*1024)));
for(int i=0;i<499;++i) {
auto id=ks::lower(ks::newSnippetId().substr(1,36)),text=L"History fixture "+std::to_wstring(i);
auto bytes=ks::protect(ks::encodeClip(ks::textClip(text)));
ks::atomicWrite(folder/L"data/history"/(id+L".dat"),bytes);
rows.insert(rows.begin(),nlohmann::json{{"id",ks::utf8(id)},{"preview",ks::utf8(text)},
{"app","test"},{"bytes",bytes.size()},{"at",i+1},{"pinned",false},{"image",false},{"files",false}});
}
auto encoded=rows.dump();ks::atomicWrite(indexPath,ks::protect(ks::Bytes(encoded.begin(),encoded.end())));
history.reload();
auto textId=history.entries().front().id;
auto exe=folder/L"KSwitcher.exe";auto line=L"\""+exe.wstring()+L"\"";
STARTUPINFOW startup{sizeof(startup)};startup.dwFlags=STARTF_USESHOWWINDOW;startup.wShowWindow=SW_HIDE;
require(CreateProcessW(exe.c_str(),line.data(),nullptr,nullptr,FALSE,CREATE_NO_WINDOW,nullptr,folder.c_str(),&startup,&process),"start isolated resident");
CloseHandle(process.hThread);process.hThread=nullptr;
until([&]{resident=FindWindowW(L"KSwitcher.Main",nullptr);return resident!=nullptr;},"resident startup");
auto base=memory(process.hProcess);
std::string forged="{\"action\":\"clear\",\"id\":\"\",\"plain\":false}";
COPYDATASTRUCT forgedRequest{0x4b530200,DWORD(forged.size()),forged.data()};
require(send(resident,WM_COPYDATA,WPARAM(target),LPARAM(&forgedRequest))==0,
"history rejects requests from an untrusted process");
auto open=[&] {
require(ks::activateWindow(target),"destination foreground");SetFocus(editor);command(resident,5);
HWND window{};
until([&]{window=FindWindowW(L"KSwitcher.History",nullptr);return window&&IsWindowVisible(window);},"history startup");
until([&]{return send(GetDlgItem(window,102),LB_GETCOUNT)>0;},"history rows");
DWORD pid{};GetWindowThreadProcessId(window,&pid);
require(pid!=process.dwProcessId,"history belongs to a separate process");
return window;
};
auto close=[&](HWND window) {
DWORD pid{};GetWindowThreadProcessId(window,&pid);HANDLE child=OpenProcess(SYNCHRONIZE,FALSE,pid);
PostMessageW(window,WM_KEYDOWN,VK_ESCAPE,0); // Escape also works with focus on the window itself.
until([&]{return WaitForSingleObject(child,0)==WAIT_OBJECT_0;},"history helper exits after Escape");
CloseHandle(child);
require(!FindWindowW(L"KSwitcher.History",nullptr),"Escape destroys history window");
};
for(int i=0;i<3;++i){auto w=open();until([&]{return send(GetDlgItem(w,102),LB_GETCOUNT)==500;},"all history rows loaded");require(true,"all history entries visible");close(w);}
auto idle=memory(process.hProcess);
std::cout<<"MEMORY resident_before="<<base<<" after_three_closes="<<idle<<'\n';
require(idle<base+2*1024*1024,"repeated history does not retain UI heap in resident");
auto w=open();send(w,WM_COMMAND,112);
until([&]{history.reload();return history.entries().front().pinned;},"resident pin mutation");
require(history.entries().front().id==textId,"pin preserves history identity");
send(GetDlgItem(w,101),WM_SETTEXT,0,LPARAM(L"History fixture 498"));
until([&]{return send(GetDlgItem(w,102),LB_GETCOUNT)==1;},"history search");
close(w);w=open();
wchar_t query[100]{};send(GetDlgItem(w,101),WM_GETTEXT,100,LPARAM(query));
require(std::wstring(query)==L"History fixture 498","search survives helper restart");
send(w,WM_COMMAND,114);
HWND entry{};until([&]{entry=FindWindowW(L"KSwitcher.Entry",nullptr);return entry!=nullptr;},"full item opens");
DWORD childPid{};GetWindowThreadProcessId(w,&childPid);HANDLE child=OpenProcess(SYNCHRONIZE,FALSE,childPid);
send(GetDlgItem(w,101),WM_KEYDOWN,VK_ESCAPE);
require(WaitForSingleObject(child,0)==WAIT_TIMEOUT && IsWindow(entry),"full item remains usable after closing history");
send(entry,WM_CLOSE);
until([&]{return WaitForSingleObject(child,0)==WAIT_OBJECT_0;},"helper exits after last full item closes");CloseHandle(child);
w=open();send(GetDlgItem(w,101),WM_SETTEXT,0,LPARAM(L""));
until([&]{return send(GetDlgItem(w,102),LB_GETCOUNT)==500;},"clear search");close(w);
ks::writeClipboard(target,excluded(image));
w=open();send(w,WM_COMMAND,111);
until([&]{return editorText()==L"History fixture 498";},"paste plain text into native editor");
until([&]{return IsClipboardFormatAvailable(CF_DIB)&&!FindWindowW(L"KSwitcher.HistoryHost",nullptr);},"image clipboard restored after paste");
require(ks::decodeImage(ks::readClipboard(target,64*1024*1024)).width==pixels.width,"image clipboard survives history paste");
auto afterPaste=memory(process.hProcess);
std::cout<<"MEMORY after_paste_with_image_backup="<<afterPaste<<'\n';
require(afterPaste<base+2*1024*1024,"image clipboard backup stays outside resident");
ks::writeClipboard(target,excluded(ks::textClip(L"keep clipboard")));
w=open();send(GetDlgItem(w,101),WM_SETTEXT,0,LPARAM(L"Image"));
until([&]{return send(GetDlgItem(w,102),LB_GETCOUNT)==1;},"image search");
pastedImage=false;send(w,WM_COMMAND,110);
until([&]{return pastedImage;},"image paste reaches destination");
until([&]{return ks::readClipboard(target).text()==L"keep clipboard";},"clipboard restored after image paste");
std::cout<<"MEMORY after_image_paste="<<memory(process.hProcess)<<'\n';
require(memory(process.hProcess)<base+2*1024*1024,"image paste does not retain full bitmap in resident");
until([&]{return !FindWindowW(L"KSwitcher.HistoryHost",nullptr);},"image paste helper exits");
w=open();send(w,WM_COMMAND,113);
until([&]{history.reload();return history.entries().size()==499;},"resident removes selected image");
GetWindowThreadProcessId(w,&childPid);child=OpenProcess(SYNCHRONIZE,FALSE,childPid);
// Startup update checks temporarily reject exit; retry after that
// operation finishes instead of treating its guard as a host leak.
ULONGLONG nextExit{};
until([&]{
if(WaitForSingleObject(process.hProcess,0)==WAIT_OBJECT_0) return true;
if(GetTickCount64()>=nextExit) {
command(resident,99);nextExit=GetTickCount64()+500;
}
return false;
},"resident accepts exit after startup update check",30000);
until([&]{return WaitForSingleObject(child,0)==WAIT_OBJECT_0;},"resident shutdown closes history helper");
CloseHandle(child);resident=nullptr;
std::cout<<"PASS isolated history host suite\n";
} catch(const std::exception &e){std::cerr<<"FAIL "<<e.what()<<" editor="<<ks::utf8(editorText())<<" foreground="<<(GetForegroundWindow()==target)<<'\n';result=1;}
if(resident)try{command(resident,99);}catch(...){}
if(process.hProcess){if(WaitForSingleObject(process.hProcess,10000)!=WAIT_OBJECT_0){TerminateProcess(process.hProcess,1);WaitForSingleObject(process.hProcess,INFINITE);}CloseHandle(process.hProcess);}
if(captured)try{ks::writeClipboard(target,saved);}catch(...){result=1;}
if(target) DestroyWindow(target);
CoUninitialize();
return result;
}
+16
View File
@@ -29,6 +29,12 @@ int main() {
"helper persists fingerprints and resident reloads them");
require(entries.back().id==pinned && entries.back().pinned,"helper preserves existing pinned entry");
auto restored=history.load(entries.front().id);
auto thumbnail=history.thumbnail(entries.front().id);
require(thumbnail.width==pixels.width && thumbnail.height==pixels.height && thumbnail.bgra==pixels.bgra,
"helper thumbnail preserves small image pixels");
bool thumbnailCancelled=false;
try {history.thumbnail(entries.front().id,[]{return true;});} catch(...) {thumbnailCancelled=true;}
require(thumbnailCancelled,"closed history cancels thumbnail work");
auto has=[&](const ks::Format &expected) {
return std::any_of(restored.formats.begin(),restored.formats.end(),[&](const ks::Format &actual) {
return (expected.name.empty()?actual.id==expected.id:actual.name==expected.name) && actual.data==expected.data;
@@ -55,6 +61,16 @@ int main() {
}
require(rejected && history.entries().size()==2,"oversized image is rejected without changing index");
require(GetClipboardSequenceNumber()==sequence,"rejection leaves clipboard unchanged");
settings.maxImageMiB=20;
history.add(ks::Clip{{ks::imageDib(pixels)}},L"thumbnail fixture",settings);
auto largeId=history.entries().front().id;
thumbnail=history.thumbnail(largeId);
require(thumbnail.width==208 && thumbnail.height==104 && thumbnail.bgra.size()==208*104*4,
"large image returns only bounded thumbnail pixels");
history.remove(largeId);
bool missingRejected=false;
try {history.thumbnail(largeId);} catch(...) {missingRejected=true;}
require(missingRejected,"deleted image cannot be previewed or recreated");
for(const auto &file:std::filesystem::directory_iterator(folder))
require(file.path().extension()!=L".request","encrypted capture request is cleaned up");
} catch(const std::exception &e) {std::cerr << "FAIL " << e.what() << '\n';result=1;}
+4
View File
@@ -309,6 +309,8 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
app->settings.autoCorrect = true;
app->settings.switchLayout = true;
app->settings.autoLanguages = {L"en", L"ru"};
// Direct fixture mutations bypass applySettings' dictionary preload.
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
app->settings.cycle = {app->layouts[en].id, app->layouts[ru].id};
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"ghbdtn ", false);
@@ -682,6 +684,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
app->settings.autoCorrect = true;
app->settings.switchLayout = true;
app->settings.autoLanguages = {L"en", L"ru"};
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
app->settings.cycle = {app->layouts[en].id, app->layouts[ru].id};
app->autoCorrector = ks::AutoCorrector{};
ActivateKeyboardLayout(app->layouts[en].handle, 0);
@@ -731,6 +734,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
});
require(uk != app->layouts.end(), "Ukrainian layout available for punctuation-key regression");
app->settings.autoLanguages = {L"en", L"uk"};
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
app->settings.cycle = {app->layouts[en].id, uk->id};
app->settings.autoWords[L"uk"] = {L"робить"};
app->autoCorrector = ks::AutoCorrector{};