Implement native Windows KSwitcher with configurable hotkeys and clipboard history
This commit is contained in:
+427
@@ -0,0 +1,427 @@
|
||||
#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};
|
||||
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.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)
|
||||
{
|
||||
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 (!std::filesystem::exists(path))
|
||||
return s;
|
||||
auto j = parse(readFile(path, 4 * 1024 * 1024));
|
||||
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.history = j.value("historyEnabled", true);
|
||||
s.autostart = j.value("startWithSystem", false);
|
||||
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
|
||||
s.awake = j.value("preventScreenLock", false);
|
||||
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);
|
||||
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)
|
||||
{
|
||||
validateSettings(s);
|
||||
Json j = {{"format", "kswitcher-settings"},
|
||||
{"schemaVersion", 1},
|
||||
{"convertLastWord", s.recent},
|
||||
{"switchSystemLayout", s.switchLayout},
|
||||
{"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)}};
|
||||
j["layouts"] = Json::array();
|
||||
for (auto &id : s.cycle)
|
||||
j["layouts"].push_back(utf8(id));
|
||||
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));
|
||||
atomicWrite(path, 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
|
||||
Reference in New Issue
Block a user