#include "storage.h" #include "json.hpp" #include "ocr.h" #include #include #include #include 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()) : 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 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 used; std::vector 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(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 seen; std::set 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 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 &layouts) { return decodeSettings(std::filesystem::exists(path) ? readFile(path, 4 * 1024 * 1024) : Bytes{}, layouts); } Settings decodeSettings(const Bytes &bytes, const std::vector &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()))).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()))); 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())); } 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()); 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())); } } 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())); } 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()))); } 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())); 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 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(); } 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(); const auto snapshot = request.wstring() + L".snapshot"; if (job.at("restore").get()) { 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(); 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 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(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 &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 &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 &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(); 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 &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 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 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