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