#include "storage.h" #include "json.hpp" #include "ocr.h" #include #include #include namespace ks { using Json = nlohmann::json; 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) { 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.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; for (const auto &snippet : s.snippets) { 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 (!seen.insert(lower(snippet.trigger)).second) throw std::runtime_error("Duplicate snippet trigger"); 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.autostart = j.value("startWithSystem", false); s.elevatedAutostart = j.value("autostartAsAdministrator", false); s.awake = j.value("preventScreenLock", false); s.autoCorrect = j.value("autoCorrectLayout", false); 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); 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("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")) s.snippets.push_back({str(sn, "trigger"), str(sn, "text")}); 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}, {"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(); for (auto &id : s.cycle) j["layouts"].push_back(utf8(id)); j["autoCorrectLayout"] = s.autoCorrect; j["autoCorrectMaxRank"] = s.autoMaxRank; j["autoCorrectMinLength"] = s.autoMinLength; j["autoCorrectPrefixChars"] = utf8(s.autoPrefixChars); j["autoCorrectSuffixChars"] = utf8(s.autoSuffixChars); 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) j["snippets"].push_back({{"trigger", utf8(sn.trigger)}, {"text", utf8(sn.text)}}); 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; } 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_); auto index = folder_ / L"index.dat"; if (!std::filesystem::exists(index)) 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)}; if (validId(item.id) && std::filesystem::exists(folder_ / (item.id + L".dat"))) entries_.push_back(std::move(item)); } } 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)}, {"bytes", e.bytes}, {"at", e.at}, {"pinned", e.pinned}, {"image", e.image}, {"files", e.files}}); 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); entries_.erase(entries_.begin() + i); } } bool sameClipboardImage(const Clip &a, const Clip &b) { // OLE DataObject / Ole Private Data change when Snipping Tool republishes // the same capture. They are transport metadata, not image identity. auto image = [](const Clip &c) -> const Format * { 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; }; const auto *left = image(a), *right = image(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) { if (clip.formats.empty() || !clip.size()) return; auto data = encodeClip(clip); if (data.size() > uint64_t(s.maxImageMiB) * 1024 * 1024) return; std::lock_guard lock(mutex_); // Compare only the newest record; never keep image payloads in the resident index. if (!entries_.empty()) { try { auto previous = load(entries_.front().id); if (encodeClip(previous) == data || sameClipboardImage(previous, clip)) 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(); bool image = false, files = false; for (auto &f : clip.formats) { image |= f.id == CF_DIB || f.id == CF_DIBV5 || f.name == L"PNG"; files |= f.id == CF_HDROP; } 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}); 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); } 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::vector History::search(const std::wstring &query) const { auto items = entries(); 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; try { return lower(load(e.id).text()).find(q) == q.npos; } catch (...) { return true; } }); return items; } } // namespace ks