Files
hrynco-kswitcher/src/storage.cpp
T

1040 lines
47 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "storage.h"
#include "json.hpp"
#include "ocr.h"
#include <algorithm>
#include <set>
#include <sstream>
#include <wincrypt.h>
namespace ks
{
using Json = nlohmann::json;
std::wstring snippetId(const Snippet &snippet)
{
if (!snippet.id.empty()) return snippet.id;
// Deterministic migration of pre-ID profiles on every existing device.
std::wstring id = L"legacy-";
const wchar_t *hex = L"0123456789abcdef";
for (unsigned char c : utf8(lower(snippet.trigger))) { id += hex[c >> 4]; id += hex[c & 15]; }
return id;
}
std::wstring newSnippetId()
{
GUID guid{}; wchar_t text[40]{};
if (FAILED(CoCreateGuid(&guid))) throw std::runtime_error("Cannot create snippet ID.");
StringFromGUID2(guid, text, 40);
return text;
}
namespace
{
Bytes jsonBytes(const Json &j)
{
auto s = j.dump(2);
return Bytes(s.begin(), s.end());
}
Json parse(const Bytes &bytes)
{
return Json::parse(bytes.begin(), bytes.end());
}
std::wstring str(const Json &j, const char *key, const std::wstring &fallback = L"")
{
return j.contains(key) ? wide(j.at(key).get<std::string>()) : fallback;
}
void put32(Bytes &b, uint32_t n)
{
for (int i = 0; i < 4; ++i)
b.push_back((unsigned char)(n >> (8 * i)));
}
uint32_t get32(const Bytes &b, size_t &p)
{
if (b.size() - p < 4)
throw std::runtime_error("Truncated clipboard record");
uint32_t n = 0;
for (int i = 0; i < 4; ++i)
n |= uint32_t(b[p++]) << (8 * i);
return n;
}
bool validId(const std::wstring &id)
{
return !id.empty() && id.size() < 80 && id.find_first_not_of(L"0123456789abcdef-") == id.npos;
}
} // namespace
void validateSettings(const Settings &s)
{
if (s.chatCycles.size() > 1000) throw std::runtime_error("Too many chat language profiles");
for (const auto &[key, cycle] : s.chatCycles)
{
auto separator = key.find(L'\n');
if (separator == key.npos || separator + 1 == key.size() || key.size() > 1024 ||
key.find(L'\n', separator + 1) != key.npos || key.find(L'\r') != key.npos ||
(key.substr(0, separator) != L"telegram" && key.substr(0, separator) != L"ms-teams" && key.substr(0, separator) != L"viber" && key.substr(0, separator) != L"telegram-web"))
throw std::runtime_error("Invalid chat language profile");
if (cycle.empty() || cycle.size() > 64) throw std::runtime_error("Select at least one chat language");
std::vector<std::wstring> seen;
for (const auto &id : cycle)
{
if (id.size() != 12 || !id.starts_with(L"win:") ||
id.find_first_not_of(L"0123456789abcdefABCDEF", 4) != id.npos ||
std::find(seen.begin(), seen.end(), id) != seen.end())
throw std::runtime_error("Invalid or duplicate chat layout");
seen.push_back(id);
}
}
if (s.tesseractPath.size() > 32700 || s.tesseractPath.find_first_of(L"\r\n\"") != s.tesseractPath.npos)
throw std::runtime_error("Invalid Tesseract executable path");
if (s.tesseractLanguages.empty() || s.tesseractLanguages.size() > 128 ||
s.tesseractLanguages.front() == L'+' || s.tesseractLanguages.back() == L'+' ||
s.tesseractLanguages.find(L"++") != s.tesseractLanguages.npos ||
s.tesseractLanguages.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+") != s.tesseractLanguages.npos)
throw std::runtime_error("Use Tesseract language codes separated by +, for example ukr+eng+rus");
std::vector<Hotkey> used;
std::vector<std::wstring> keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard, s.screenshot};
keys.insert(keys.end(), s.direct.begin(), s.direct.end());
for (auto &text : keys)
{
auto key = Hotkey::parse(text);
if (!key)
throw std::runtime_error("Invalid shortcut; use e.g. Ctrl+Alt+Shift+Space");
if (!key->key)
continue;
if ((key->key == VK_DELETE &&
(key->modifiers & (MOD_CONTROL | MOD_ALT)) == (MOD_CONTROL | MOD_ALT)) ||
(key->key == 'L' && key->modifiers == MOD_WIN))
throw std::runtime_error("Windows protects Ctrl+Alt+Delete and Win+L; choose another shortcut");
if (std::find(used.begin(), used.end(), *key) != used.end())
throw std::runtime_error("Keyboard shortcuts must be unique");
used.push_back(*key);
}
if (s.autoMinLength < 1 || s.autoMinLength > 32)
throw std::runtime_error("Minimum word length must be between 1 and 32 letters");
if (s.autoMaxRank < 0)
throw std::runtime_error("Frequency rank limit must be zero or a positive whole number");
for (const auto &characters : {s.autoPrefixChars, s.autoSuffixChars})
{
if (characters.size() > 32)
throw std::runtime_error("Use at most 32 punctuation characters in each delimiter field");
for (wchar_t c : characters)
if (letter(c) || iswalnum(c) || iswspace(c) || c < 32 || (c >= 0xd800 && c <= 0xdfff))
throw std::runtime_error(
"Word delimiters must be punctuation, without letters, digits or spaces");
}
if (s.autoCorrect && s.autoLanguages.size() < 2)
throw std::runtime_error("Select at least two languages for automatic correction");
if (std::set<std::wstring>(s.autoLanguages.begin(), s.autoLanguages.end()).size() !=
s.autoLanguages.size())
throw std::runtime_error("Dictionary languages must be unique");
for (auto &language : s.autoLanguages)
if (language != L"en" && language != L"uk" && language != L"ru")
throw std::runtime_error("Unsupported auto-correction language");
if (s.autoNever.size() > 10000 || s.autoExcluded.size() > 1000)
throw std::runtime_error("Too many auto-correction exceptions");
if (s.autoReplacements.size() > 10000)
throw std::runtime_error("Too many automatic replacements");
for (const auto &[from, to] : s.autoReplacements)
{
bool valid = false;
for (auto alphabet : {L"abcdefghijklmnopqrstuvwxyz", L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя",
L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя"})
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
from == lower(from) && to == lower(to) && from != to &&
from.find_first_not_of(alphabet) == from.npos &&
to.find_first_not_of(std::wstring(alphabet) + L" ") == to.npos &&
to.front() != L' ' && to.back() != L' ' && to.find(L" ") == to.npos;
if (!valid)
throw std::runtime_error("Use a lowercase typo of 1-32 letters and a different correction of 1-32 characters in the same language. Single spaces between correction words are allowed.");
}
for (const auto &[language, words] : s.autoWords)
{
std::wstring alphabet = language == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
language == L"uk" ? L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя" :
language == L"ru" ? L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя" : L"";
if (alphabet.empty() || words.size() > 10000)
throw std::runtime_error("Invalid personal dictionary language or too many words");
for (const auto &word : words)
if (word.empty() || word.size() > 32 || lower(word).find_first_not_of(alphabet) != word.npos)
throw std::runtime_error("Personal words must contain 1-32 letters of their selected language");
}
if (s.maxItems < 10 || s.maxItems > 10000 || s.maxMiB < 1 || s.maxMiB > 2048 || s.maxImageMiB < 1 ||
s.maxImageMiB > 64)
throw std::runtime_error("History limits: 10–10000 items, 1–2048 MiB total, 1–64 MiB per image");
std::set<std::wstring> seen;
std::set<std::wstring> ids;
for (const auto &id : s.snippetTombstones)
if (id.empty() || id.size() > 600 || !ids.insert(id).second)
throw std::runtime_error("Invalid snippet deletion record.");
for (const auto &snippet : s.snippets)
{
auto id = snippetId(snippet);
if (id.size() > 600 || !ids.insert(id).second) throw std::runtime_error("Duplicate snippet ID or deleted snippet.");
if (snippet.trigger.size() < 2 || snippet.trigger.size() > 64 ||
snippet.trigger.find_first_of(L"\r\n\t ") != snippet.trigger.npos)
throw std::runtime_error("Snippet triggers must contain 2–64 characters without whitespace");
if (snippet.apps.size() > 64) throw std::runtime_error("Too many snippet applications");
std::vector<std::wstring> scopes;
for (const auto &app : snippet.apps)
{
auto normalized = snippetAppName(app);
if (normalized.empty() || normalized.size() > 250 || normalized.find_first_of(L"\\/:*?\"<>|,\r\n\t") != normalized.npos)
throw std::runtime_error("Use application names such as chrome.exe, not paths or wildcards");
scopes.push_back(normalized);
}
if (scopes.empty()) scopes.push_back(L"");
for (const auto &scope : scopes)
if (!seen.insert(lower(snippet.trigger) + L"\n" + scope).second)
throw std::runtime_error("Duplicate snippet trigger for the same application or global scope");
auto t = parseTemplate(snippet.text);
if (t.text.empty() || t.cursors > 1)
throw std::runtime_error("Each snippet needs text and at most one {cursor} marker");
}
}
Settings loadSettings(const std::filesystem::path &path, const std::vector<Layout> &layouts)
{
return decodeSettings(std::filesystem::exists(path) ? readFile(path, 4 * 1024 * 1024) : Bytes{}, layouts);
}
Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
{
Settings s;
for (size_t i = 0; i < layouts.size(); ++i)
{
if (layouts[i].convertible)
s.cycle.push_back(layouts[i].id);
s.direct.push_back(i < 3 ? L"Ctrl+Alt+Shift+" + std::to_wstring(i + 1) : L"");
s.directIds.push_back(layouts[i].id);
}
if (bytes.empty())
return s;
auto j = parse(bytes);
auto format = j.value("format", std::string());
if (format != "kswitcher-settings" && format != "typlo-settings")
throw std::runtime_error("Not a KSwitcher or Typlo settings file");
s.recent = j.value("convertLastWord", true);
s.switchLayout = j.value("switchSystemLayout", true);
s.writeLog = j.value("writeLog", true);
s.history = j.value("historyEnabled", true);
s.useTesseract = j.value("useTesseract", false);
s.tesseractPath = str(j, "tesseractPath");
s.tesseractLanguages = str(j, "tesseractLanguages", s.tesseractLanguages);
s.autostart = j.value("startWithSystem", false);
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
s.awake = j.value("preventScreenLock", false);
s.autoCorrect = j.value("autoCorrectLayout", false);
s.autoReplace = j.value("autoCorrectReplace", false);
s.autoTypos = j.value("autoCorrectTypos", false);
if (j.contains("autoCorrectReplacements"))
for (auto &[from, to] : j.at("autoCorrectReplacements").items())
{
auto key = lower(wide(from));
if (!s.autoReplacements.emplace(key, lower(wide(to.get<std::string>()))).second)
throw std::runtime_error("Duplicate automatic replacement");
}
s.autoMaxRank = j.value("autoCorrectMaxRank", 0);
s.autoMinLength = j.value("autoCorrectMinLength", 3);
s.autoPrefixChars = str(j, "autoCorrectPrefixChars", s.autoPrefixChars);
s.autoSuffixChars = str(j, "autoCorrectSuffixChars", s.autoSuffixChars);
if (j.contains("autoCorrectWords"))
for (auto &[language, words] : j.at("autoCorrectWords").items())
for (auto &word : words)
s.autoWords[wide(language)].push_back(lower(wide(word.get<std::string>())));
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
{"autoCorrectNever", &s.autoNever},
{"autoCorrectExcludedApps", &s.autoExcluded}})
if (j.contains(pair.first))
{
pair.second->clear();
for (auto &item : j.at(pair.first))
pair.second->push_back(wide(item.get<std::string>()));
}
s.maxItems = std::clamp(j.value("maxItems", 500), 10, 10000);
s.maxMiB = std::clamp(j.value("maxTotalMegabytes", 200), 1, 2048);
s.maxImageMiB = std::clamp(j.value("maxImageMegabytes", 20), 1, 64);
s.toggle = str(j, "toggleHotkey", s.toggle);
s.casing = str(j, "caseHotkey", s.casing);
s.translit = str(j, "translitHotkey", s.translit);
s.undo = str(j, "undoHotkey", s.undo);
s.clipboard = str(j, "clipboardHotkey", s.clipboard);
s.screenshot = str(j, "screenshotHotkey", s.screenshot);
s.screenshotAutoOpenEditor = j.value("screenshotAutoOpenEditor", false);
if (j.contains("layouts"))
{
s.cycle.clear();
for (auto &id : j.at("layouts"))
{
auto w = wide(id.get<std::string>());
if (std::find(s.cycle.begin(), s.cycle.end(), w) == s.cycle.end())
s.cycle.push_back(w);
}
}
if (j.contains("chatCycles"))
{
if (!j.at("chatCycles").is_object()) throw std::runtime_error("Invalid chat profiles object");
for (const auto &[key, cycle] : j.at("chatCycles").items())
{
if (!cycle.is_array() || cycle.empty()) throw std::runtime_error("Empty chat language profile");
for (const auto &id : cycle)
s.chatCycles[wide(key)].push_back(wide(id.get<std::string>()));
}
}
if (j.contains("layoutHotkeys"))
{
std::fill(s.direct.begin(), s.direct.end(), L"");
for (auto &key : j.at("layoutHotkeys"))
for (size_t i = 0; i < layouts.size(); ++i)
if (layouts[i].id == str(key, "layoutId"))
s.direct[i] = str(key, "hotkey");
}
if (j.contains("excludedApps"))
{
s.excluded.clear();
for (auto &name : j.at("excludedApps"))
s.excluded.push_back(wide(name.get<std::string>()));
}
if (j.contains("snippets"))
for (auto &sn : j.at("snippets"))
{
Snippet item{str(sn, "trigger"), str(sn, "text"), str(sn, "id")};
if (sn.contains("apps"))
{
if (!sn.at("apps").is_array()) throw std::runtime_error("Snippet apps must be a list");
for (const auto &app : sn.at("apps")) item.apps.push_back(snippetAppName(wide(app.get<std::string>())));
}
if (item.id.empty() && !item.apps.empty()) item.id = newSnippetId();
item.id = snippetId(item);
s.snippets.push_back(std::move(item));
}
if (j.contains("snippetTombstones"))
for (const auto &id : j.at("snippetTombstones")) s.snippetTombstones.push_back(wide(id.get<std::string>()));
validateSettings(s);
return s;
}
void saveSettings(const std::filesystem::path &path, const Settings &s)
{
atomicWrite(path, encodeSettings(s));
}
Bytes encodeSettings(const Settings &s)
{
validateSettings(s);
Json j = {{"format", "kswitcher-settings"},
{"schemaVersion", 1},
{"convertLastWord", s.recent},
{"switchSystemLayout", s.switchLayout},
{"writeLog", s.writeLog},
{"autostartAsAdministrator", s.elevatedAutostart},
{"historyEnabled", s.history},
{"useTesseract", s.useTesseract},
{"tesseractPath", utf8(s.tesseractPath)},
{"tesseractLanguages", utf8(s.tesseractLanguages)},
{"startWithSystem", s.autostart},
{"preventScreenLock", s.awake},
{"maxItems", s.maxItems},
{"maxTotalMegabytes", s.maxMiB},
{"maxImageMegabytes", s.maxImageMiB},
{"toggleHotkey", utf8(s.toggle)},
{"caseHotkey", utf8(s.casing)},
{"translitHotkey", utf8(s.translit)},
{"undoHotkey", utf8(s.undo)},
{"clipboardHotkey", utf8(s.clipboard)},
{"screenshotHotkey", utf8(s.screenshot)},
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
j["layouts"] = Json::array();
j["chatCycles"] = Json::object();
for (const auto &[key, cycle] : s.chatCycles)
for (const auto &id : cycle) j["chatCycles"][utf8(key)].push_back(utf8(id));
for (auto &id : s.cycle)
j["layouts"].push_back(utf8(id));
j["autoCorrectLayout"] = s.autoCorrect;
j["autoCorrectReplace"] = s.autoReplace;
j["autoCorrectTypos"] = s.autoTypos;
j["autoCorrectReplacements"] = Json::object();
for (const auto &[from, to] : s.autoReplacements)
j["autoCorrectReplacements"][utf8(from)] = utf8(to);
j["autoCorrectMaxRank"] = s.autoMaxRank;
j["autoCorrectMinLength"] = s.autoMinLength;
j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars);
j["autoCorrectSuffixChars"] = utf8(s.autoSuffixChars);
j["autoCorrectWords"] = Json::object();
for (const auto &[language, words] : s.autoWords)
{
auto &items = j["autoCorrectWords"][utf8(language)] = Json::array();
for (const auto &word : words)
items.push_back(utf8(lower(word)));
}
for (auto pair : {std::pair{"autoCorrectLanguages", &s.autoLanguages},
{"autoCorrectNever", &s.autoNever},
{"autoCorrectExcludedApps", &s.autoExcluded}})
{
j[pair.first] = Json::array();
for (auto &item : *pair.second)
j[pair.first].push_back(utf8(item));
}
j["layoutHotkeys"] = Json::array();
for (size_t i = 0; i < std::min(s.directIds.size(), s.direct.size()); ++i)
j["layoutHotkeys"].push_back({{"layoutId", utf8(s.directIds[i])}, {"hotkey", utf8(s.direct[i])}});
j["snippets"] = Json::array();
for (auto &sn : s.snippets)
{
Json item{{"trigger", utf8(sn.trigger)}, {"text", utf8(sn.text)}, {"id", utf8(snippetId(sn))}};
if (!sn.apps.empty())
{
item["apps"] = Json::array();
for (const auto &app : sn.apps) item["apps"].push_back(utf8(snippetAppName(app)));
}
j["snippets"].push_back(std::move(item));
}
j["snippetTombstones"] = Json::array();
for (const auto &id : s.snippetTombstones) j["snippetTombstones"].push_back(utf8(id));
j["excludedApps"] = Json::array();
for (auto &name : s.excluded)
j["excludedApps"].push_back(utf8(name));
return jsonBytes(j);
}
Bytes encodeClip(const Clip &clip)
{
Bytes b;
put32(b, 0x3143534b);
put32(b, (uint32_t)clip.formats.size());
for (const auto &f : clip.formats)
{
auto name = utf8(f.name);
put32(b, f.id);
put32(b, (uint32_t)name.size());
put32(b, (uint32_t)f.data.size());
b.insert(b.end(), name.begin(), name.end());
b.insert(b.end(), f.data.begin(), f.data.end());
}
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;
if (get32(b, p) != 0x3143534b)
throw std::runtime_error("Unknown clipboard record");
auto count = get32(b, p);
if (count > 1024)
throw std::runtime_error("Too many clipboard formats");
Clip clip;
for (uint32_t i = 0; i < count; ++i)
{
Format f;
f.id = get32(b, p);
auto name = get32(b, p), size = get32(b, p);
if (name > 4096 || name > b.size() - p)
throw std::runtime_error("Invalid clipboard format name");
f.name = wide(std::string((const char *)b.data() + p, name));
p += name;
if (size > b.size() - p)
throw std::runtime_error("Truncated clipboard data");
f.data.assign(b.begin() + p, b.begin() + p + size);
p += size;
clip.formats.push_back(std::move(f));
}
if (p != b.size())
throw std::runtime_error("Trailing clipboard data");
return clip;
}
History::History(const std::filesystem::path &folder) : folder_(folder)
{
std::filesystem::create_directories(folder_);
loadIndex();
}
void History::loadIndex()
{
std::vector<Entry> loaded;
auto index = folder_ / L"index.dat";
if (!std::filesystem::exists(index))
{
entries_.clear();
return;
}
auto j = parse(unprotect(readFile(index, 16 * 1024 * 1024)));
if (!j.is_array() || j.size() > 10000)
throw std::runtime_error("Invalid history index");
for (auto &e : j)
{
Entry item{str(e, "id"),
str(e, "preview"),
str(e, "app"),
e.value("bytes", uint64_t{}),
e.value("at", uint64_t{}),
e.value("pinned", false),
e.value("image", false),
e.value("files", false), e.value("ocrState", e.value("image", false) ? 1 : 0),
str(e, "ocrProfile", L"windows")};
item.computer=e.value("computer",std::string{});item.computerName=str(e,"computerName");
if (e.value("fingerprintVersion", 0) == 1) {
item.contentHash = e.value("contentHash", std::string{});
item.imageHash = e.value("imageHash", std::string{});
item.pixelHash = e.value("pixelHash", std::string{});
}
if (validId(item.id) && std::filesystem::exists(folder_ / (item.id + L".dat")))
loaded.push_back(std::move(item));
}
entries_ = std::move(loaded);
}
void History::reload() { std::lock_guard lock(mutex_); loadIndex(); }
void ClipboardSnapshot::cleanup() noexcept
{
if (request_.empty()) return;
std::error_code ignored;
for (const auto *suffix : {L"", L".snapshot", L".result"})
std::filesystem::remove(std::filesystem::path(request_.wstring() + suffix), ignored);
request_.clear();
}
ClipboardSnapshot::~ClipboardSnapshot() { cleanup(); }
DWORD ClipboardSnapshot::run(bool restore, DWORD sequence)
{
atomicWrite(request_, protect(jsonBytes(Json{{"schema", 1}, {"restore", restore}, {"sequence", sequence}})));
auto helper = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Screenshot.exe";
auto command = L"\"" + helper.wstring() + L"\" --clipboard-snapshot \"" + request_.wstring() + L"\"";
STARTUPINFOW startup{sizeof(startup)};
startup.dwFlags = STARTF_USESHOWWINDOW; startup.wShowWindow = SW_HIDE;
PROCESS_INFORMATION process{};
if (!CreateProcessW(helper.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
nullptr, helper.parent_path().c_str(), &startup, &process))
throw std::runtime_error("Cannot start clipboard snapshot helper");
CloseHandle(process.hThread);
auto wait = WaitForSingleObject(process.hProcess, 10000);
DWORD result = ERROR_GEN_FAILURE;
if (wait != WAIT_OBJECT_0)
{
TerminateProcess(process.hProcess, ERROR_TIMEOUT);
WaitForSingleObject(process.hProcess, INFINITE);
}
GetExitCodeProcess(process.hProcess, &result);
CloseHandle(process.hProcess);
if (wait != WAIT_OBJECT_0 || result) throw std::runtime_error("Clipboard snapshot helper failed");
return parse(unprotect(readFile(request_.wstring() + L".result", 4096))).at("sequence").get<DWORD>();
}
void ClipboardSnapshot::capture(HWND window, DWORD sequence)
{
cleanup();
clip_ = {};
if (!IsClipboardFormatAvailable(CF_DIB) && !IsClipboardFormatAvailable(CF_DIBV5) &&
!IsClipboardFormatAvailable(CF_BITMAP) && !IsClipboardFormatAvailable(RegisterClipboardFormatW(L"PNG")))
{
clip_ = readClipboard(window, 64 * 1024 * 1024, ClipboardRead::WithoutImages, sequence);
return;
}
request_ = std::filesystem::temp_directory_path() / (L"ks-clipboard-" + newSnippetId() + L".request");
try { run(false, sequence); }
catch (...) { cleanup(); throw; }
}
DWORD ClipboardSnapshot::restore(HWND window, DWORD sequence)
{
return request_.empty() ? writeClipboardGuarded(window, clip_, sequence) : run(true, sequence);
}
int runClipboardSnapshot(const std::filesystem::path &request)
{
try
{
if (!request.is_absolute() || !request.filename().wstring().starts_with(L"ks-clipboard-") ||
request.extension() != L".request") return 1;
auto job = parse(unprotect(readFile(request, 4096)));
if (job.at("schema") != 1) return 1;
auto sequence = job.at("sequence").get<DWORD>();
const auto snapshot = request.wstring() + L".snapshot";
if (job.at("restore").get<bool>())
{
auto clip = decodeClip(unprotect(readFile(snapshot, 70 * 1024 * 1024)));
HWND window = CreateWindowExW(0, L"STATIC", L"", 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, nullptr, nullptr);
if (!window) return 1;
struct WindowCleanup { HWND window; ~WindowCleanup() { DestroyWindow(window); } } cleanup{window};
sequence = writeClipboardGuarded(window, clip, sequence);
}
else
{
auto clip = readClipboard(nullptr, 64 * 1024 * 1024, ClipboardRead::All, sequence);
if (sequence != GetClipboardSequenceNumber()) return 2;
atomicWrite(snapshot, protect(encodeClip(clip)));
}
atomicWrite(request.wstring() + L".result", protect(jsonBytes(Json{{"sequence", sequence}})));
return 0;
}
catch (...) { return 1; }
}
bool History::captureImage(DWORD sequence, const std::wstring &app, const Settings &settings)
{
// Serialize helper index changes with all resident history mutations. Only
// metadata is reloaded: GetClipboardData's image allocations die with the child.
std::lock_guard lock(mutex_);
if (sequence != GetClipboardSequenceNumber()) return false;
auto request = std::filesystem::absolute(folder_) / (L"capture-" + newSnippetId() + L".request");
struct Cleanup {
std::filesystem::path path;
~Cleanup() { std::error_code ignored; std::filesystem::remove(path, ignored); }
} cleanup{request};
Json job{{"schema", 1}, {"sequence", sequence}, {"app", utf8(app)},
{"settings", Json::parse(encodeSettings(settings))}};
atomicWrite(request, protect(jsonBytes(job)));
auto helper = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Screenshot.exe";
auto command = L"\"" + helper.wstring() + L"\" --capture-history \"" + request.wstring() + L"\"";
STARTUPINFOW startup{sizeof(startup)};
startup.dwFlags = STARTF_USESHOWWINDOW; startup.wShowWindow = SW_HIDE;
PROCESS_INFORMATION process{};
if (!CreateProcessW(helper.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
nullptr, helper.parent_path().c_str(), &startup, &process))
throw std::runtime_error(utf8(lastError(L"Cannot start image history helper")));
CloseHandle(process.hThread);
auto wait = WaitForSingleObject(process.hProcess, 30000);
DWORD result = ERROR_GEN_FAILURE;
if (wait != WAIT_OBJECT_0)
{
// Never release the index lock while a timed-out child can still write it.
TerminateProcess(process.hProcess, ERROR_TIMEOUT);
WaitForSingleObject(process.hProcess, INFINITE);
}
GetExitCodeProcess(process.hProcess, &result);
CloseHandle(process.hProcess);
loadIndex(); // Also reconcile any completed atomic save before a child failure.
if (wait != WAIT_OBJECT_0) throw std::runtime_error("Image history helper timed out");
if (result == 2) return false;
if (result == 3) throw std::runtime_error("Clipboard exceeds the configured size limit");
if (result) throw std::runtime_error("Image history helper failed: " + std::to_string(result));
return true;
}
int runImageHistoryCapture(const std::filesystem::path &request)
{
try
{
if (!request.is_absolute() || !request.filename().wstring().starts_with(L"capture-") ||
request.extension() != L".request") return 1;
auto job = parse(unprotect(readFile(request, 4 * 1024 * 1024)));
if (job.at("schema") != 1) return 1;
auto sequence = job.at("sequence").get<DWORD>();
auto settings = decodeSettings(jsonBytes(job.at("settings")), readLayouts());
auto valid = [&] {
return settings.history && sequence == GetClipboardSequenceNumber() &&
!IsClipboardFormatAvailable(RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing")) &&
!IsClipboardFormatAvailable(RegisterClipboardFormatW(L"Clipboard Viewer Ignore"));
};
if (!valid()) return 2;
if (!IsClipboardFormatAvailable(CF_DIB) && !IsClipboardFormatAvailable(CF_DIBV5) &&
!IsClipboardFormatAvailable(CF_BITMAP) &&
!IsClipboardFormatAvailable(RegisterClipboardFormatW(L"PNG"))) return 2;
auto clip = readClipboard(nullptr, size_t(settings.maxImageMiB) * 1024 * 1024);
if (!valid()) return 2;
for (const auto &format : clip.formats)
if (format.name == L"CanIncludeInClipboardHistory" && format.data.size() >= 4 &&
!format.data[0] && !format.data[1] && !format.data[2] && !format.data[3]) return 2;
History history(request.parent_path());
if (!valid()) return 2;
history.add(clip, str(job, "app"), settings);
return 0;
}
catch (const std::exception &e)
{
return std::string(e.what()) == "Clipboard exceeds the configured size limit" ? 3 : 1;
}
}
void History::saveIndex()
{
Json j = Json::array();
for (const auto &e : entries_)
j.push_back({{"id", utf8(e.id)},
{"preview", utf8(e.preview)},
{"app", utf8(e.app)}, {"computer", e.computer}, {"computerName", utf8(e.computerName)},
{"bytes", e.bytes},
{"at", e.at},
{"pinned", e.pinned},
{"image", e.image},
{"files", e.files}, {"ocrState", e.ocrState}, {"ocrProfile", utf8(e.ocrProfile)},
{"fingerprintVersion", 1}, {"contentHash", e.contentHash},
{"imageHash", e.imageHash}, {"pixelHash", e.pixelHash}});
atomicWrite(folder_ / L"index.dat", protect(jsonBytes(j)));
}
std::vector<Entry> History::entries() const
{
std::lock_guard lock(mutex_);
return entries_;
}
Clip History::load(const std::wstring &id) const
{
if (!validId(id))
throw std::runtime_error("Invalid history ID");
return decodeClip(unprotect(readFile(folder_ / (id + L".dat"), 80 * 1024 * 1024)));
}
void History::prune(const Settings &s)
{
uint64_t total = 0;
for (auto &e : entries_)
total += e.bytes;
for (size_t i = entries_.size(); i > 0;)
{
--i;
if (entries_.size() <= (size_t)s.maxItems && total <= uint64_t(s.maxMiB) * 1024 * 1024)
break;
if (entries_[i].pinned)
continue;
total -= entries_[i].bytes;
std::error_code ec;
std::filesystem::remove(folder_ / (entries_[i].id + L".dat"), ec);
std::filesystem::remove(folder_ / (entries_[i].id + L".ocr"), ec);
entries_.erase(entries_.begin() + i);
}
}
static const Format *clipboardImageFormat(const Clip &c)
{
// OLE DataObject / Ole Private Data change when Snipping Tool republishes
// the same capture. They are transport metadata, not image identity.
if (!c.text().empty())
return nullptr;
for (const auto &f : c.formats)
if (f.id == CF_HDROP)
return nullptr;
for (const auto &f : c.formats)
if (f.name == L"PNG" && !f.data.empty())
return &f;
for (UINT id : {UINT(CF_DIBV5), UINT(CF_DIB)})
for (const auto &f : c.formats)
if (f.id == id && !f.data.empty())
return &f;
return nullptr;
}
static std::string clipboardHash(const Bytes &bytes)
{
struct Hash {
HCRYPTPROV provider{}; HCRYPTHASH hash{};
~Hash() { if (hash) CryptDestroyHash(hash); if (provider) CryptReleaseContext(provider, 0); }
} state;
if (!CryptAcquireContextW(&state.provider, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT) ||
!CryptCreateHash(state.provider, CALG_SHA_256, 0, 0, &state.hash) ||
!CryptHashData(state.hash, bytes.data(), static_cast<DWORD>(bytes.size()), 0))
throw std::runtime_error("Cannot hash clipboard data");
BYTE digest[32]{}; DWORD size = sizeof(digest);
if (!CryptGetHashParam(state.hash, HP_HASHVAL, digest, &size, 0) || size != sizeof(digest))
throw std::runtime_error("Cannot read clipboard hash");
static constexpr char hex[] = "0123456789abcdef";
std::string result; result.reserve(64);
for (auto b : digest) { result += hex[b >> 4]; result += hex[b & 15]; }
return result;
}
static void imageFingerprints(Entry &entry, const Clip &clip, bool decodePixels = true)
{
const auto *image = clipboardImageFormat(clip);
if (!image) return;
if (entry.imageHash.empty())
entry.imageHash = std::to_string(image->id) + ":" + utf8(image->name) + ":" + clipboardHash(image->data);
if (!decodePixels) return;
try {
auto pixels = decodeImage(clip);
entry.pixelHash = std::to_string(pixels.width) + "x" + std::to_string(pixels.height) + ":" + clipboardHash(pixels.bgra);
} catch (const std::exception &) {
// Undecodable images can still be compared by their original bytes.
}
}
bool sameClipboardImage(const Clip &a, const Clip &b)
{
const auto *left = clipboardImageFormat(a), *right = clipboardImageFormat(b);
if (!left || !right)
return false;
if (left->id == right->id && left->name == right->name && left->data == right->data)
return true;
try
{
// Different encodings can still represent exactly the same pixels.
// These buffers are temporary; image payloads never stay in the index.
auto x = decodeImage(a), y = decodeImage(b);
return x.width == y.width && x.height == y.height && x.bgra == y.bgra;
}
catch (const std::exception &)
{
return false; // Undecodable data is not evidence of a duplicate.
}
}
void History::add(const Clip &clip, const std::wstring &app, const Settings &s, const std::string &computer, const std::wstring &computerName)
{
if (clip.formats.empty() || !clip.size())
return;
auto data = encodeClip(clip);
if (data.size() > uint64_t(s.maxImageMiB) * 1024 * 1024)
return;
bool image = false, files = false;
for (const auto &f : clip.formats)
{
image |= f.id == CF_DIB || f.id == CF_DIBV5 || f.name == L"PNG";
files |= f.id == CF_HDROP;
}
std::lock_guard lock(mutex_);
Entry fingerprints;
fingerprints.contentHash = clipboardHash(data);
if (!entries_.empty() && entries_.front().computer == computer && entries_.front().contentHash == fingerprints.contentHash)
return;
imageFingerprints(fingerprints, clip, false);
if (!entries_.empty() && entries_.front().computer == computer && !fingerprints.imageHash.empty() && entries_.front().imageHash == fingerprints.imageHash)
return;
imageFingerprints(fingerprints, clip);
// Different payload kinds cannot be duplicates. Consult the index first so
// copying text after a screenshot never loads/decrypts the previous image.
if (!entries_.empty() && entries_.front().computer == computer && entries_.front().image == image && entries_.front().files == files)
{
try
{
auto &previous = entries_.front();
bool migrated = previous.contentHash.empty();
if (migrated) {
auto oldClip = load(previous.id);
previous.contentHash = clipboardHash(encodeClip(oldClip));
imageFingerprints(previous, oldClip);
}
if (previous.contentHash == fingerprints.contentHash ||
(!fingerprints.imageHash.empty() && previous.imageHash == fingerprints.imageHash) ||
(!fingerprints.pixelHash.empty() && previous.pixelHash == fingerprints.pixelHash)) {
if (migrated) saveIndex();
return;
}
}
catch (...)
{
}
}
GUID guid{};
if (FAILED(CoCreateGuid(&guid)))
throw std::runtime_error("Cannot generate history ID");
wchar_t buf[40];
StringFromGUID2(guid, buf, 40);
std::wstring id = lower(std::wstring(buf + 1, 36));
auto encrypted = protect(data);
atomicWrite(folder_ / (id + L".dat"), encrypted);
auto text = clip.text();
if (text.empty())
text = files ? L"[Files]" : image ? L"[Image]" : L"[Clipboard data]";
for (auto &c : text)
if (c == L'\r' || c == L'\n' || c == L'\t')
c = L' ';
if (text.size() > 240)
text.resize(240);
FILETIME now;
GetSystemTimeAsFileTime(&now);
entries_.insert(entries_.begin(),
Entry{id, text, app, encrypted.size(),
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files,
image ? 1 : 0, ocrProfile(s), std::move(fingerprints.contentHash),
std::move(fingerprints.imageHash), std::move(fingerprints.pixelHash), computer, computerName});
prune(s);
saveIndex();
}
void History::remove(const std::wstring &id)
{
std::lock_guard lock(mutex_);
if (!validId(id))
return;
auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; });
if (it == entries_.end())
return;
entries_.erase(it);
saveIndex();
std::error_code ec;
std::filesystem::remove(folder_ / (id + L".dat"), ec);
std::filesystem::remove(folder_ / (id + L".ocr"), ec);
}
void History::pin(const std::wstring &id)
{
std::lock_guard lock(mutex_);
for (auto &e : entries_)
if (e.id == id)
e.pinned = !e.pinned;
saveIndex();
}
void History::clear()
{
std::lock_guard lock(mutex_);
auto old = entries_;
entries_.clear();
saveIndex();
for (auto &e : old)
{
std::error_code ec;
std::filesystem::remove(folder_ / (e.id + L".dat"), ec);
std::filesystem::remove(folder_ / (e.id + L".ocr"), ec);
}
}
static Bytes historyImageJob(const std::filesystem::path &folder, const std::wstring &id,
const Json &job, const std::function<bool()> &cancelled)
{
if (!validId(id)) throw std::runtime_error("Invalid history ID");
if (cancelled && cancelled()) throw std::runtime_error("History OCR cancelled");
auto request = std::filesystem::absolute(folder) / (L"ocr-" + newSnippetId() + L".request");
auto response = request; response += L".result";
struct Cleanup {
std::filesystem::path request, response;
~Cleanup() {
std::error_code ignored;
std::filesystem::remove(request, ignored);
std::filesystem::remove(response, ignored);
}
} cleanup{request, response};
atomicWrite(request, protect(jsonBytes(job)));
auto helper = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Screenshot.exe";
auto command = L"\"" + helper.wstring() + L"\" --history-ocr \"" + request.wstring() + L"\"";
STARTUPINFOW startup{sizeof(startup)};
startup.dwFlags = STARTF_USESHOWWINDOW; startup.wShowWindow = SW_HIDE;
PROCESS_INFORMATION process{};
if (!CreateProcessW(helper.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW,
nullptr, helper.parent_path().c_str(), &startup, &process))
throw std::runtime_error(utf8(lastError(L"Cannot start history OCR helper")));
CloseHandle(process.hThread);
struct Child {
HANDLE handle;
~Child() {
if (WaitForSingleObject(handle, 0) != WAIT_OBJECT_0) {
TerminateProcess(handle, ERROR_CANCELLED);
WaitForSingleObject(handle, INFINITE);
}
CloseHandle(handle);
}
} child{process.hProcess};
auto deadline = GetTickCount64() + 60000;
for (;;) {
auto wait = WaitForSingleObject(child.handle, 100);
if (wait == WAIT_OBJECT_0) break;
if (wait != WAIT_TIMEOUT) throw std::runtime_error("Cannot wait for history OCR helper");
if (cancelled && cancelled()) throw std::runtime_error("History OCR cancelled");
if (GetTickCount64() >= deadline) throw std::runtime_error("History OCR helper timed out");
}
DWORD result{};
if (!GetExitCodeProcess(child.handle, &result) || result)
throw std::runtime_error("History OCR helper failed: " + std::to_string(result));
return unprotect(readFile(response, 8 * 1024 * 1024));
}
std::wstring History::recognizeImage(const std::wstring &id, const Settings &settings,
const std::function<bool()> &cancelled) const
{
Json job{{"schema", 1}, {"id", utf8(id)}, {"tesseract", settings.useTesseract},
{"path", utf8(settings.tesseractPath)}, {"languages", utf8(settings.tesseractLanguages)}};
auto reply = parse(historyImageJob(folder_, id, job, cancelled));
if (reply.at("schema") != 1 || str(reply, "id") != id)
throw std::runtime_error("Invalid history OCR response");
return str(reply, "text");
}
Pixels History::thumbnail(const std::wstring &id, const std::function<bool()> &cancelled) const
{
auto bytes = historyImageJob(folder_, id, Json{{"schema", 1}, {"id", utf8(id)}, {"thumbnail", true}}, cancelled);
size_t at = 0;
auto width = get32(bytes, at), height = get32(bytes, at);
if (!width || !height || width > 208 || height > 128 || bytes.size() - at != width * height * 4)
throw std::runtime_error("Invalid history thumbnail response");
bytes.erase(bytes.begin(), bytes.begin() + at);
return Pixels{width, height, std::move(bytes)};
}
int runHistoryOcr(const std::filesystem::path &request)
{
try {
if (!request.is_absolute() || !request.filename().wstring().starts_with(L"ocr-") ||
request.extension() != L".request") return 1;
auto job = parse(unprotect(readFile(request, 1024 * 1024)));
auto id = str(job, "id");
if (job.at("schema") != 1 || !validId(id)) return 1;
auto clip = decodeClip(unprotect(readFile(request.parent_path() / (id + L".dat"), 80 * 1024 * 1024)));
if (job.value("thumbnail", false)) {
auto pixels = decodeImage(clip, 208, 128);
if (!pixels.width || !pixels.height) return 1;
Bytes reply;
put32(reply, pixels.width); put32(reply, pixels.height);
reply.insert(reply.end(), pixels.bgra.begin(), pixels.bgra.end());
atomicWrite(request.wstring() + L".result", protect(reply));
return 0;
}
Settings settings;
settings.useTesseract = job.at("tesseract").get<bool>();
settings.tesseractPath = str(job, "path");
settings.tesseractLanguages = str(job, "languages");
auto text = utf8(recognizeText(clip, settings));
if (text.size() > 1024 * 1024) return 1;
auto response = request; response += L".result";
atomicWrite(response, protect(jsonBytes(Json{{"schema", 1}, {"id", utf8(id)}, {"text", text}})));
return 0;
} catch (...) { return 1; }
}
bool History::saveOcr(const std::wstring &id, const std::optional<std::wstring> &text, const Settings &settings)
{
std::lock_guard lock(mutex_);
auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; });
// A late OCR completion must never recreate a deleted/pruned image.
auto profile = ocrProfile(settings);
if (it == entries_.end() || !it->image || (it->ocrState == 2 && it->ocrProfile == profile)) return false;
if (text)
{
auto utf = utf8(*text);
if (utf.size() > 1024 * 1024) throw std::runtime_error("OCR text exceeds storage limit");
// A version byte also makes empty recognition results encryptable by DPAPI.
Bytes payload{1};
payload.insert(payload.end(), utf.begin(), utf.end());
auto encrypted = protect(payload);
auto path = folder_ / (id + L".ocr");
std::error_code ec;
auto previousBytes = std::filesystem::file_size(path, ec);
if (ec) previousBytes = 0;
atomicWrite(path, encrypted);
it->bytes = it->bytes - std::min(it->bytes, uint64_t(previousBytes)) + encrypted.size();
it->ocrState = 2;
}
else it->ocrState = 3;
it->ocrProfile = profile;
prune(settings);
saveIndex();
return true;
}
std::optional<std::wstring> History::ocrText(const std::wstring &id, const std::wstring &profile) const
{
{
std::lock_guard lock(mutex_);
auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; });
if (it == entries_.end() || it->ocrState != 2 || (!profile.empty() && it->ocrProfile != profile)) return std::nullopt;
}
auto bytes = unprotect(readFile(folder_ / (id + L".ocr"), 2 * 1024 * 1024));
if (bytes.empty() || bytes.front() != 1) throw std::runtime_error("Invalid OCR text format");
return wide(std::string(bytes.begin() + 1, bytes.end()));
}
std::vector<Entry> History::search(const std::wstring &query, bool pinnedOnly) const
{
auto items = entries();
if (pinnedOnly) std::erase_if(items, [](const Entry &entry) { return !entry.pinned; });
if (query.empty())
return items;
auto q = lower(query);
std::erase_if(items,
[&](auto &e)
{
if (lower(e.preview + L" " + e.app).find(q) != q.npos)
return false;
if (e.ocrState == 2)
{
try
{
auto text = ocrText(e.id);
if (text && lower(*text).find(q) != q.npos) return false;
}
catch (...) {} // Missing/corrupt OCR must not hide the original clip.
}
// Short previews contain the complete text (or a placeholder for
// textless clips). Do not decrypt image payloads just to rule them out.
// Newlines/tabs are flattened in previews, so those queries still
// need the original text. Exactly 240 characters may be truncated.
if (e.preview.size() < 240 && q.find_first_of(L"\r\n\t") == q.npos)
return true;
try
{
return lower(load(e.id).text()).find(q) == q.npos;
}
catch (...)
{
return true;
}
});
return items;
}
} // namespace ks