492 lines
18 KiB
C++
492 lines
18 KiB
C++
#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)
|
||
{
|
||
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.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.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<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},
|
||
{"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<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);
|
||
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<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;
|
||
try
|
||
{
|
||
return lower(load(e.id).text()).find(q) == q.npos;
|
||
}
|
||
catch (...)
|
||
{
|
||
return true;
|
||
}
|
||
});
|
||
return items;
|
||
}
|
||
} // namespace ks
|