Implement native Windows KSwitcher with configurable hotkeys and clipboard history

This commit is contained in:
2026-10-27 22:14:06 +02:00
commit f683e4178c
37 changed files with 31288 additions and 0 deletions
+427
View File
@@ -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