Files
hrynco-kswitcher/src/storage.cpp
T

587 lines
24 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>
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<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.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");
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;
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<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.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("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"))
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},
{"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();
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);
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)
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), e.value("ocrState", e.value("image", false) ? 1 : 0),
str(e, "ocrProfile", L"windows")};
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}, {"ocrState", e.ocrState}, {"ocrProfile", utf8(e.ocrProfile)}});
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);
}
}
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,
image ? 1 : 0, ocrProfile(s)});
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);
}
}
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) 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;
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