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
+1139
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
#pragma once
#include "storage.h"
#include <atomic>
#include <condition_variable>
#include <deque>
#include <memory>
#include <shellapi.h>
#include <thread>
namespace ks
{
inline constexpr UINT TrayMessage = WM_APP + 1, ErrorMessage = WM_APP + 2, HistoryMessage = WM_APP + 3,
SnippetMessage = WM_APP + 4;
inline constexpr int Toggle = 1, Case = 2, Translit = 3, Undo = 4, ShowHistory = 5, Direct = 100;
inline constexpr UINT CaptureHotkeyMessage = WM_APP + 5;
struct LastTransform
{
HWND window{};
std::wstring original, result;
int layout = -1;
ULONGLONG at{};
uint64_t version{};
};
class App
{
public:
HWND window{}, historyWindow{}, settingsWindow{}, snippetsWindow{};
HINSTANCE instance{};
std::filesystem::path data;
std::vector<Layout> layouts;
Settings settings;
std::unique_ptr<History> history;
std::atomic<bool> busy{false}, suspended{false};
std::atomic<DWORD> ownSequence{0};
std::atomic<uint64_t> inputVersion{0};
std::atomic<ULONGLONG> pauseUntil{0};
HWND pasteTarget{};
HWND hotkeyCapture{};
std::mutex stateMutex;
Run run;
std::optional<LastTransform> last;
std::wstring registrationWarning;
bool smoke{};
int startCommand{};
App(HINSTANCE, bool = false, const std::filesystem::path &dataRoot = {});
~App();
int execute();
void dispatch(int);
void transform(int, int snippet = -1);
void pasteEntry(std::wstring, bool plain);
void recognizeEntry(std::wstring);
void captureHistory();
void enqueue(std::function<void()>);
void report(const std::wstring &);
void trayMenu();
void addTray();
bool registerHotkeys(const Settings &, bool rollback = false);
void applySettings(const Settings &);
void reloadLayouts();
bool isHotkey(DWORD, UINT);
void keyboard(const KBDLLHOOKSTRUCT &);
void resetInput();
static App *current;
private:
HHOOK keyboardHook{}, mouseHook{};
std::mutex jobsMutex;
std::condition_variable jobsReady;
std::deque<std::function<void()>> jobs;
std::thread worker;
bool stop{};
UINT taskbarCreated{};
std::vector<std::pair<int, Hotkey>> hookBindings;
HotkeyHook hotkeyHook;
static LRESULT CALLBACK windowProc(HWND, UINT, WPARAM, LPARAM);
static LRESULT CALLBACK keyProc(int, WPARAM, LPARAM);
static LRESULT CALLBACK mouseProc(int, WPARAM, LPARAM);
};
void showHistory(App &);
void refreshHistory(App &);
void showSettings(App &);
void showSnippets(App &);
void showShortcuts(App &);
void showEntry(App &, const std::wstring &);
} // namespace ks
+205
View File
@@ -0,0 +1,205 @@
#include "platform.h"
#include <sddl.h>
#include <shellapi.h>
#include <shlobj.h>
#include <taskschd.h>
namespace ks
{
namespace
{
template <class T> struct Com
{
T *p{};
~Com()
{
if (p)
p->Release();
}
T **out()
{
return &p;
}
T *operator->()
{
return p;
}
};
struct Str
{
BSTR value;
Str(const wchar_t *s) : value(SysAllocString(s))
{
if (!value)
throw std::bad_alloc();
}
~Str()
{
SysFreeString(value);
}
operator BSTR() const
{
return value;
}
};
void check(HRESULT hr)
{
if (FAILED(hr))
throw std::runtime_error("Cannot configure Windows autostart (HRESULT " +
std::to_string((unsigned long)hr) + ")");
}
bool missing(HRESULT hr)
{
return hr == HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND) || hr == HRESULT_FROM_WIN32(ERROR_PATH_NOT_FOUND);
}
void runEntry(bool enabled)
{
HKEY key{};
if (RegCreateKeyExW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", 0, nullptr,
0, KEY_SET_VALUE, nullptr, &key, nullptr) != ERROR_SUCCESS)
throw std::runtime_error("Cannot access autostart settings");
auto command = L"\"" + executable() + L"\"";
auto status = enabled ? RegSetValueExW(key, L"KSwitcher", 0, REG_SZ, (const BYTE *)command.c_str(),
DWORD((command.size() + 1) * sizeof(wchar_t)))
: RegDeleteValueW(key, L"KSwitcher");
RegCloseKey(key);
if (status != ERROR_SUCCESS && status != ERROR_FILE_NOT_FOUND)
throw std::runtime_error("Cannot save autostart settings");
}
} // namespace
std::wstring currentUserSid()
{
HANDLE token{};
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token))
throw std::runtime_error("Cannot identify Windows account");
DWORD size{};
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
Bytes data(size);
BOOL ok = GetTokenInformation(token, TokenUser, data.data(), size, &size);
CloseHandle(token);
if (!ok)
throw std::runtime_error("Cannot identify Windows account");
LPWSTR sid{};
if (!ConvertSidToStringSidW(((TOKEN_USER *)data.data())->User.Sid, &sid))
throw std::runtime_error("Cannot read account SID");
std::wstring result = sid;
LocalFree(sid);
return result;
}
void configureAutostart(int mode)
{
Com<ITaskService> service;
check(CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER, IID_ITaskService,
(void **)service.out()));
VARIANT empty;
VariantInit(&empty);
check(service->Connect(empty, empty, empty, empty));
Com<ITaskFolder> folder;
check(service->GetFolder(Str(L"\\"), folder.out()));
Com<IRegisteredTask> existing;
HRESULT found = folder->GetTask(Str(L"KSwitcher"), existing.out());
if (FAILED(found) && !missing(found))
check(found);
if (mode != 2)
{
if (SUCCEEDED(found))
check(existing->put_Enabled(VARIANT_FALSE));
runEntry(mode == 1);
return;
}
auto sid = currentUserSid();
Str user(sid.c_str());
Com<ITaskDefinition> definition;
check(service->NewTask(0, definition.out()));
Com<IPrincipal> principal;
check(definition->get_Principal(principal.out()));
check(principal->put_UserId(user));
check(principal->put_LogonType(TASK_LOGON_INTERACTIVE_TOKEN));
check(principal->put_RunLevel(TASK_RUNLEVEL_HIGHEST));
Com<ITaskSettings> settings;
check(definition->get_Settings(settings.out()));
check(settings->put_DisallowStartIfOnBatteries(VARIANT_FALSE));
check(settings->put_StopIfGoingOnBatteries(VARIANT_FALSE));
check(settings->put_ExecutionTimeLimit(Str(L"PT0S")));
check(settings->put_MultipleInstances(TASK_INSTANCES_IGNORE_NEW));
Com<ITriggerCollection> triggers;
check(definition->get_Triggers(triggers.out()));
Com<ITrigger> trigger;
check(triggers->Create(TASK_TRIGGER_LOGON, trigger.out()));
Com<ILogonTrigger> logon;
check(trigger->QueryInterface(IID_ILogonTrigger, (void **)logon.out()));
check(logon->put_UserId(user));
Com<IActionCollection> actions;
check(definition->get_Actions(actions.out()));
Com<IAction> action;
check(actions->Create(TASK_ACTION_EXEC, action.out()));
Com<IExecAction> exec;
check(action->QueryInterface(IID_IExecAction, (void **)exec.out()));
auto path = executable();
check(exec->put_Path(Str(path.c_str())));
check(exec->put_WorkingDirectory(Str(std::filesystem::path(path).parent_path().c_str())));
VARIANT account;
VariantInit(&account);
account.vt = VT_BSTR;
account.bstrVal = user;
Com<IRegisteredTask> installed;
check(folder->RegisterTaskDefinition(Str(L"KSwitcher"), definition.p, TASK_CREATE_OR_UPDATE, account,
empty, TASK_LOGON_INTERACTIVE_TOKEN, empty, installed.out()));
runEntry(false);
}
void setAutostart(bool enabled, bool elevated)
{
int mode = enabled ? (elevated ? 2 : 1) : 0;
if (IsUserAnAdmin())
{
configureAutostart(mode);
return;
}
// Ordinary startup needs no UAC unless an elevated task must be changed.
if (mode != 2)
{
Com<ITaskService> service;
HRESULT hr = CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER, IID_ITaskService,
(void **)service.out());
VARIANT empty;
VariantInit(&empty);
Com<ITaskFolder> folder;
Com<IRegisteredTask> task;
if (SUCCEEDED(hr))
hr = service->Connect(empty, empty, empty, empty);
if (SUCCEEDED(hr))
hr = service->GetFolder(Str(L"\\"), folder.out());
if (SUCCEEDED(hr))
hr = folder->GetTask(Str(L"KSwitcher"), task.out());
if (missing(hr))
{
runEntry(mode == 1);
return;
}
check(hr);
}
auto file = executable(),
args = L"--configure-autostart=" + std::to_wstring(mode) + L" " + currentUserSid();
SHELLEXECUTEINFOW request{sizeof(request)};
request.fMask = SEE_MASK_NOCLOSEPROCESS;
request.lpVerb = L"runas";
request.lpFile = file.c_str();
request.lpParameters = args.c_str();
request.nShow = SW_HIDE;
if (!ShellExecuteExW(&request) || !request.hProcess)
throw std::runtime_error("Administrator approval was cancelled or unavailable");
// Continue servicing sent hook messages while UAC/helper runs, without
// dispatching another Settings Save or global command reentrantly.
DWORD wait{};
while ((wait = MsgWaitForMultipleObjects(1, &request.hProcess, FALSE, INFINITE, QS_SENDMESSAGE)) ==
WAIT_OBJECT_0 + 1)
{
MSG message;
PeekMessageW(&message, nullptr, WM_NULL, WM_NULL, PM_NOREMOVE);
}
DWORD code = 1;
GetExitCodeProcess(request.hProcess, &code);
CloseHandle(request.hProcess);
if (wait != WAIT_OBJECT_0 || code != 0)
throw std::runtime_error("Elevated autostart was not changed");
}
} // namespace ks
+467
View File
@@ -0,0 +1,467 @@
#include "core.h"
#include <algorithm>
#include <cwctype>
#include <map>
#include <stdexcept>
namespace ks
{
int HotkeyHook::update(UINT key, bool down, UINT modifiers,
const std::vector<std::pair<int, Hotkey>> &bindings)
{
if (key >= 256)
return 0;
if (pressed[key])
{
if (!down)
pressed[key] = false;
return -1;
}
if (down)
for (const auto &[command, hotkey] : bindings)
if (hotkey.key == key && hotkey.modifiers == modifiers)
{
pressed[key] = true;
return command;
}
return 0;
}
bool letter(wchar_t c)
{
WORD type{};
GetStringTypeW(CT_CTYPE1, &c, 1, &type);
return (type & C1_ALPHA) != 0;
}
std::wstring lower(std::wstring s)
{
if (!s.empty())
CharLowerBuffW(s.data(), (DWORD)s.size());
return s;
}
std::wstring upper(std::wstring s)
{
if (!s.empty())
CharUpperBuffW(s.data(), (DWORD)s.size());
return s;
}
int Layout::code(wchar_t c) const
{
auto p = normal.find(c);
if (p != normal.npos && c)
return (int)p;
p = shifted.find(c);
return p == shifted.npos || !c ? -1 : (int)(p + normal.size());
}
wchar_t Layout::at(int c) const
{
if (c < 0)
return 0;
return c < (int)normal.size() ? normal[c]
: c < (int)(normal.size() + shifted.size()) ? shifted[c - normal.size()]
: 0;
}
bool Layout::hasLetter(wchar_t c) const
{
return letter(c) && code(c) >= 0;
}
std::wstring convert(const std::wstring &text, const Layout &from, const Layout &to, bool force)
{
std::wstring result;
for (size_t i = 0; i < text.size();)
{
if (iswspace(text[i]))
{
result += text[i++];
continue;
}
size_t end = i;
bool source = false, other = false;
while (end < text.size() && !iswspace(text[end]))
{
wchar_t c = text[end++];
if (!letter(c))
continue;
if (from.hasLetter(c) && !to.hasLetter(c))
source = true;
else
other = true;
}
for (; i < end; ++i)
{
wchar_t mapped = (!force && !source && other) ? 0 : to.at(from.code(text[i]));
result += mapped ? mapped : text[i];
}
}
return result;
}
int detect(const std::wstring &text, const std::vector<Layout> &layouts, int preferred)
{
int best = -1, score = 0;
auto check = [&](int i)
{
if (i < 0 || i >= (int)layouts.size() || !layouts[i].convertible)
return;
int n =
(int)std::count_if(text.begin(), text.end(), [&](wchar_t c) { return layouts[i].hasLetter(c); });
if (n > score)
{
score = n;
best = i;
}
};
check(preferred);
for (int i = 0; i < (int)layouts.size(); ++i)
check(i);
return best;
}
std::wstring convertTo(const std::wstring &text, const std::vector<Layout> &layouts, int target, int hint)
{
if (target < 0 || target >= (int)layouts.size() || !layouts[target].convertible)
return text;
const auto &to = layouts[target];
if (hint >= 0 && hint < (int)layouts.size() && layouts[hint].convertible &&
std::all_of(
text.begin(), text.end(), [&](wchar_t c) { return !letter(c) || layouts[hint].code(c) >= 0; }))
return hint == target ? text : convert(text, layouts[hint], to, true);
int best = -1, score = 0;
for (int i = 0; i < (int)layouts.size(); ++i)
if (i != target && layouts[i].convertible)
{
int n = (int)std::count_if(text.begin(), text.end(), [&](wchar_t c)
{ return layouts[i].hasLetter(c) && !to.hasLetter(c); });
if (n > score)
{
score = n;
best = i;
}
}
if (best < 0 && detect(text, layouts) < 0)
{
best = hint;
if (best < 0)
for (int i = 0; i < (int)layouts.size(); ++i)
if (i != target && layouts[i].convertible)
{
best = i;
break;
}
}
return best < 0 ? text : convert(text, layouts[best], to);
}
std::wstring cycleCase(const std::wstring &text)
{
if (text == upper(text))
return lower(text);
if (text != lower(text))
return upper(text);
auto out = lower(text);
bool start = true;
for (auto &c : out)
{
if (letter(c))
{
if (start)
c = upper(std::wstring(1, c))[0];
start = false;
}
else
start = !iswdigit(c) && c != L'\'' && c != L'’';
}
return out;
}
std::wstring transliterate(const std::wstring &text)
{
static const std::wstring ru = L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя";
static const std::vector<std::wstring> lat = {L"a", L"b", L"v", L"g", L"d", L"e", L"yo", L"zh", L"z",
L"i", L"y", L"k", L"l", L"m", L"n", L"o", L"p", L"r",
L"s", L"t", L"u", L"f", L"kh", L"ts", L"ch", L"sh", L"shch",
L"", L"y", L"", L"e", L"yu", L"ya"};
bool cyr = std::any_of(text.begin(), text.end(), [](wchar_t c) { return c >= 0x400 && c <= 0x4ff; });
std::wstring out, lo = lower(text);
if (cyr)
{
bool uk = lo.find_first_of(L"ґіїє") != lo.npos;
const std::map<wchar_t, std::wstring> overrides = {{L'г', L"h"}, {L'ґ', L"g"}, {L'и', L"y"},
{L'і', L"i"}, {L'ї', L"yi"}, {L'є', L"ye"},
{L'\'', L""}, {L'’', L""}};
for (size_t i = 0; i < text.size(); ++i)
{
std::wstring m;
auto p = ru.find(lo[i]);
auto u = overrides.find(lo[i]);
if (uk && u != overrides.end())
{
m = u->second;
if (i && letter(text[i - 1]))
{
if (lo[i] == L'ї')
m = L"i";
if (lo[i] == L'є')
m = L"ie";
}
}
else if (p != ru.npos)
m = lat[p];
else
{
out += text[i];
continue;
}
if (text[i] != lo[i] && !m.empty())
{
bool all =
(i + 1 < text.size() && text[i + 1] != lo[i + 1]) || (i > 0 && text[i - 1] != lo[i - 1]);
if (all)
m = upper(m);
else
m[0] = upper(m.substr(0, 1))[0];
}
out += m;
}
}
else
{
static const std::vector<std::pair<std::wstring, std::wstring>> reverse = {
{L"shch", L"щ"}, {L"zh", L"ж"}, {L"kh", L"х"}, {L"ts", L"ц"}, {L"ch", L"ч"}, {L"sh", L"ш"},
{L"yo", L"ё"}, {L"yu", L"ю"}, {L"ya", L"я"}, {L"ye", L"е"}, {L"a", L"а"}, {L"b", L"б"},
{L"v", L"в"}, {L"w", L"в"}, {L"g", L"г"}, {L"d", L"д"}, {L"e", L"е"}, {L"z", L"з"},
{L"i", L"и"}, {L"y", L"й"}, {L"k", L"к"}, {L"q", L"к"}, {L"l", L"л"}, {L"m", L"м"},
{L"n", L"н"}, {L"o", L"о"}, {L"p", L"п"}, {L"r", L"р"}, {L"s", L"с"}, {L"t", L"т"},
{L"u", L"у"}, {L"f", L"ф"}, {L"h", L"х"}, {L"c", L"к"}, {L"x", L"кс"}, {L"j", L"дж"}};
for (size_t i = 0; i < text.size();)
{
bool found = false;
for (const auto &[a, b] : reverse)
if (lo.compare(i, a.size(), a) == 0)
{
auto m = b;
if (text[i] != lo[i])
{
bool all = a.size() > 1 ? text[i + a.size() - 1] != lo[i + a.size() - 1]
: i + 1 < text.size() && text[i + 1] != lo[i + 1];
if (all)
m = upper(m);
else
m[0] = upper(m.substr(0, 1))[0];
}
out += m;
i += a.size();
found = true;
break;
}
if (!found)
out += text[i++];
}
}
return out;
}
Template parseTemplate(const std::wstring &text)
{
Template t;
size_t cursor = std::wstring::npos;
for (size_t i = 0; i < text.size();)
{
if (text.compare(i, 10, L"{{cursor}}\0", 10) == 0)
{
t.text += L"{cursor}";
i += 10;
}
else if (text.compare(i, 8, L"{cursor}") == 0)
{
cursor = t.text.size();
++t.cursors;
i += 8;
}
else
t.text += text[i++];
}
if (cursor != text.npos)
t.suffix = t.text.substr(cursor);
return t;
}
namespace
{
const std::vector<std::pair<UINT, std::wstring>> &namedKeys()
{
static const std::vector<std::pair<UINT, std::wstring>> names = {
{VK_SPACE, L"Space"}, {VK_TAB, L"Tab"},
{VK_RETURN, L"Enter"}, {VK_ESCAPE, L"Escape"},
{VK_BACK, L"Backspace"}, {VK_DELETE, L"Delete"},
{VK_INSERT, L"Insert"}, {VK_PAUSE, L"Pause"},
{VK_SCROLL, L"ScrollLock"}, {VK_HOME, L"Home"},
{VK_END, L"End"}, {VK_PRIOR, L"PageUp"},
{VK_NEXT, L"PageDown"}, {VK_LEFT, L"Left"},
{VK_RIGHT, L"Right"}, {VK_UP, L"Up"},
{VK_DOWN, L"Down"}, {VK_SNAPSHOT, L"PrintScreen"},
{VK_OEM_1, L"Semicolon"}, {VK_OEM_2, L"Slash"},
{VK_OEM_3, L"Backtick"}, {VK_OEM_4, L"LeftBracket"},
{VK_OEM_5, L"Backslash"}, {VK_OEM_6, L"RightBracket"},
{VK_OEM_7, L"Quote"}, {VK_OEM_PLUS, L"Equal"},
{VK_OEM_MINUS, L"Minus"}, {VK_OEM_COMMA, L"Comma"},
{VK_OEM_PERIOD, L"Period"}, {VK_MULTIPLY, L"Multiply"},
{VK_ADD, L"Add"}, {VK_SUBTRACT, L"Subtract"},
{VK_DIVIDE, L"Divide"}, {VK_DECIMAL, L"Decimal"}};
return names;
}
} // namespace
std::wstring Hotkey::format() const
{
if (!key)
return {};
std::wstring result;
if (modifiers & MOD_CONTROL)
result += L"Ctrl+";
if (modifiers & MOD_ALT)
result += L"Alt+";
if (modifiers & MOD_SHIFT)
result += L"Shift+";
if (modifiers & MOD_WIN)
result += L"Win+";
if ((key >= L'A' && key <= L'Z') || (key >= L'0' && key <= L'9'))
return result + wchar_t(key);
if (key >= VK_F1 && key <= VK_F24)
return result + L"F" + std::to_wstring(key - VK_F1 + 1);
if (key >= VK_NUMPAD0 && key <= VK_NUMPAD9)
return result + L"Num" + std::to_wstring(key - VK_NUMPAD0);
for (auto &[vk, name] : namedKeys())
if (key == vk)
return result + name;
return {};
}
std::optional<Hotkey> Hotkey::parse(std::wstring s)
{
Hotkey h;
if (s.empty())
return h;
s = lower(s);
size_t pos = 0;
while (true)
{
size_t end = s.find(L'+', pos);
auto token = s.substr(pos, end == s.npos ? s.npos : end - pos);
if (token == L"ctrl")
h.modifiers |= MOD_CONTROL;
else if (token == L"alt")
h.modifiers |= MOD_ALT;
else if (token == L"shift")
h.modifiers |= MOD_SHIFT;
else if (token == L"win")
h.modifiers |= MOD_WIN;
else
{
if (h.key)
return {};
if (token.size() == 1 && ((token[0] >= L'a' && token[0] <= L'z') || iswdigit(token[0])))
h.key = upper(token)[0];
else if (token.size() == 4 && token.substr(0, 3) == L"num" && token[3] >= L'0' &&
token[3] <= L'9')
h.key = VK_NUMPAD0 + token[3] - L'0';
else if (token.size() >= 2 && token[0] == L'f')
{
wchar_t *tail{};
long n = wcstol(token.c_str() + 1, &tail, 10);
if (*tail || n < 1 || n > 24)
return {};
h.key = VK_F1 + (UINT)n - 1;
}
else
{
for (auto &[key, name] : namedKeys())
if (lower(name) == token)
{
h.key = key;
break;
}
if (!h.key)
return {};
}
}
if (end == s.npos)
break;
pos = end + 1;
}
return h.key ? std::optional<Hotkey>(h) : std::nullopt;
}
std::string utf8(const std::wstring &s)
{
if (s.empty())
return {};
int n = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, s.data(), (int)s.size(), nullptr, 0, nullptr,
nullptr);
if (!n)
throw std::runtime_error("Invalid Unicode");
std::string out(n, 0);
WideCharToMultiByte(CP_UTF8, 0, s.data(), (int)s.size(), out.data(), n, nullptr, nullptr);
return out;
}
std::wstring wide(const std::string &s)
{
if (s.empty())
return {};
int n = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, s.data(), (int)s.size(), nullptr, 0);
if (!n)
throw std::runtime_error("Invalid UTF-8");
std::wstring out(n, 0);
MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), out.data(), n);
return out;
}
void Run::reset()
{
keys.clear();
window = nullptr;
layout = nullptr;
overflow = false;
}
void Run::record(wchar_t c, HWND w, HKL l, ULONGLONG now)
{
if (!w || c < 32)
{
reset();
return;
}
if (window != w || layout != l || now - at >= 2000 ||
(!keys.empty() && iswspace(keys.back()) && !iswspace(c)))
reset();
window = w;
layout = l;
at = now;
if (overflow)
{
if (iswspace(c))
reset();
return;
}
if (keys.empty() && iswspace(c))
return;
if (keys.size() >= 512)
{
keys.clear();
overflow = true;
return;
}
keys += c;
}
void Run::backspace(HWND w, ULONGLONG now)
{
if (w != window || now - at >= 2000 || overflow)
reset();
else if (!keys.empty())
keys.pop_back();
at = now;
}
int Run::match(const std::wstring &text, const std::vector<Layout> &layouts, const Layout &physical) const
{
if (text.size() != keys.size() || overflow)
return -1;
for (int i = 0; i < (int)layouts.size(); ++i)
if (layouts[i].convertible)
{
std::wstring expected;
for (wchar_t c : keys)
expected += c == L' ' ? L' ' : layouts[i].at(physical.code(c));
if (lower(expected) == lower(text))
return i;
}
return -1;
}
} // namespace ks
+77
View File
@@ -0,0 +1,77 @@
#pragma once
#include <cstdint>
#include <functional>
#include <optional>
#include <string>
#include <vector>
#include <windows.h>
namespace ks
{
struct Layout
{
HKL handle{};
std::wstring id, name, normal, shifted;
bool convertible = true;
int code(wchar_t ch) const;
wchar_t at(int code) const;
bool hasLetter(wchar_t ch) const;
};
bool letter(wchar_t ch);
std::wstring lower(std::wstring text);
std::wstring upper(std::wstring text);
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
int detect(const std::wstring &, const std::vector<Layout> &, int preferred = -1);
std::wstring convertTo(const std::wstring &, const std::vector<Layout> &, int target, int hint);
std::wstring cycleCase(const std::wstring &);
std::wstring transliterate(const std::wstring &);
struct Snippet
{
std::wstring trigger, text;
};
struct Template
{
std::wstring text, suffix;
int cursors = 0;
};
Template parseTemplate(const std::wstring &);
struct Hotkey
{
UINT modifiers{}, key{};
bool operator==(const Hotkey &) const = default;
static std::optional<Hotkey> parse(std::wstring);
std::wstring format() const;
};
struct Settings
{
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
bool elevatedAutostart = false;
int maxItems = 500, maxMiB = 200, maxImageMiB = 20;
std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C",
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Ctrl+Shift+V";
std::vector<std::wstring> cycle, direct, directIds;
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
std::vector<Snippet> snippets;
};
// Consume repeats and key-up for intercepted chords, even if settings change.
struct HotkeyHook
{
bool pressed[256]{};
// 0: pass through, -1: consume only, positive: dispatch that action once.
int update(UINT key, bool down, UINT modifiers, const std::vector<std::pair<int, Hotkey>> &bindings);
};
std::string utf8(const std::wstring &);
std::wstring wide(const std::string &);
struct Run
{
HWND window{};
HKL layout{};
ULONGLONG at{};
std::wstring keys;
bool overflow{};
void reset();
void record(wchar_t, HWND, HKL, ULONGLONG);
void backspace(HWND, ULONGLONG);
int match(const std::wstring &, const std::vector<Layout> &, const Layout &physical) const;
};
} // namespace ks
+86
View File
@@ -0,0 +1,86 @@
#include "app.h"
#include <shellapi.h>
#include <shlobj.h>
int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int)
{
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
int argc{};
auto argv = CommandLineToArgvW(GetCommandLineW(), &argc);
std::wstring arg = argc > 1 ? argv[1] : L"";
if (arg.starts_with(L"--configure-autostart="))
{
int result = 1;
try
{
if (argc != 3 || ks::currentUserSid() != argv[2] || !IsUserAnAdmin())
throw std::runtime_error("Administrator approval for the same Windows account is required");
if (arg != L"--configure-autostart=0" && arg != L"--configure-autostart=1" &&
arg != L"--configure-autostart=2")
throw std::runtime_error("Invalid autostart mode");
ks::configureAutostart(arg.back() - L'0');
result = 0;
}
catch (const std::exception &e)
{
MessageBoxW(nullptr, ks::wide(e.what()).c_str(), L"KSwitcher autostart", MB_OK | MB_ICONERROR);
}
LocalFree(argv);
CoUninitialize();
return result;
}
HANDLE mutex = CreateMutexW(nullptr, TRUE, L"Local\\KSwitcher.Native.Singleton");
bool existing = GetLastError() == ERROR_ALREADY_EXISTS;
int cmd = ks::ShowHistory;
if (arg == L"--convert")
cmd = ks::Toggle;
else if (arg == L"--case")
cmd = ks::Case;
else if (arg == L"--transliterate")
cmd = ks::Translit;
else if (arg == L"--settings")
cmd = 20;
else if (arg == L"--snippets")
cmd = 21;
else if (arg == L"--exit")
cmd = 99;
LocalFree(argv);
if (existing)
{
HWND window = FindWindowW(L"KSwitcher.Main", L"KSwitcher");
if (window)
{
COPYDATASTRUCT data{1, sizeof(cmd), &cmd};
DWORD_PTR result{};
SendMessageTimeoutW(window, WM_COPYDATA, 0, (LPARAM)&data, SMTO_ABORTIFHUNG, 1000, &result);
}
CloseHandle(mutex);
CoUninitialize();
return 0;
}
if (arg == L"--exit")
{
CloseHandle(mutex);
CoUninitialize();
return 0;
}
int result = 0;
try
{
ks::App app(instance, arg == L"--smoke-test");
if (!arg.empty() && arg != L"--smoke-test")
app.startCommand = cmd;
result = app.execute();
}
catch (const std::exception &e)
{
MessageBoxW(nullptr, ks::wide(e.what()).c_str(), L"KSwitcher could not start", MB_OK | MB_ICONERROR);
result = 1;
}
if (mutex)
{
ReleaseMutex(mutex);
CloseHandle(mutex);
}
CoUninitialize();
return result;
}
+274
View File
@@ -0,0 +1,274 @@
#include "ocr.h"
#include <memory>
#include <roapi.h>
#include <wincodec.h>
#include <winstring.h>
namespace ks
{
namespace
{
template <class T> struct Com
{
T *p{};
~Com()
{
if (p)
p->Release();
}
T *operator->() const
{
return p;
}
T **out()
{
return &p;
}
Com() = default;
Com(const Com &) = delete;
Com &operator=(const Com &) = delete;
};
void check(HRESULT h)
{
if (FAILED(h))
{
char text[80];
snprintf(text, sizeof(text), "Windows image/OCR service failed (0x%08lx)", (unsigned long)h);
throw std::runtime_error(text);
}
}
struct String
{
HSTRING value{};
explicit String(const wchar_t *s)
{
check(WindowsCreateString(s, (UINT32)wcslen(s), &value));
}
~String()
{
WindowsDeleteString(value);
}
};
GUID guid(const wchar_t *text)
{
GUID result{};
check(IIDFromString(text, &result));
return result;
}
template <class T> void factory(const wchar_t *name, const wchar_t *iid, Com<T> &out)
{
String s(name);
check(RoGetActivationFactory(s.value, guid(iid), (void **)out.out()));
}
} // namespace
// Minimal, strongly typed Windows Runtime ABI declarations. No CLR or C++/WinRT runtime is needed.
// Interface identities/method order follow Microsoft's Windows SDK metadata (Windows.Media.Ocr,
// Windows.Graphics.Imaging, Windows.Storage.Streams, Windows.Foundation).
struct Buffer : IInspectable
{
virtual HRESULT STDMETHODCALLTYPE Capacity(UINT32 *) = 0;
virtual HRESULT STDMETHODCALLTYPE Length(UINT32 *) = 0;
virtual HRESULT STDMETHODCALLTYPE SetLength(UINT32) = 0;
};
struct BufferFactory : IInspectable
{
virtual HRESULT STDMETHODCALLTYPE Create(UINT32, Buffer **) = 0;
};
struct BufferBytes : IUnknown
{
virtual HRESULT STDMETHODCALLTYPE Data(BYTE **) = 0;
};
struct BitmapStatics : IInspectable
{
virtual HRESULT STDMETHODCALLTYPE Copy(IInspectable *, IInspectable **) = 0;
virtual HRESULT STDMETHODCALLTYPE Convert(IInspectable *, INT32, IInspectable **) = 0;
virtual HRESULT STDMETHODCALLTYPE ConvertAlpha(IInspectable *, INT32, INT32, IInspectable **) = 0;
virtual HRESULT STDMETHODCALLTYPE FromBuffer(Buffer *, INT32, INT32, INT32, IInspectable **) = 0;
virtual HRESULT STDMETHODCALLTYPE FromBufferAlpha(Buffer *, INT32, INT32, INT32, INT32,
IInspectable **) = 0;
};
struct OcrResult : IInspectable
{
virtual HRESULT STDMETHODCALLTYPE Lines(IInspectable **) = 0;
virtual HRESULT STDMETHODCALLTYPE Angle(IInspectable **) = 0;
virtual HRESULT STDMETHODCALLTYPE Text(HSTRING *) = 0;
};
struct OcrOperation : IInspectable
{
virtual HRESULT STDMETHODCALLTYPE PutCompleted(IUnknown *) = 0;
virtual HRESULT STDMETHODCALLTYPE GetCompleted(IUnknown **) = 0;
virtual HRESULT STDMETHODCALLTYPE Results(OcrResult **) = 0;
};
struct AsyncInfo : IInspectable
{
virtual HRESULT STDMETHODCALLTYPE Id(UINT32 *) = 0;
virtual HRESULT STDMETHODCALLTYPE Status(INT32 *) = 0;
virtual HRESULT STDMETHODCALLTYPE ErrorCode(HRESULT *) = 0;
virtual HRESULT STDMETHODCALLTYPE Cancel() = 0;
virtual HRESULT STDMETHODCALLTYPE Close() = 0;
};
struct OcrEngine : IInspectable
{
virtual HRESULT STDMETHODCALLTYPE Recognize(IInspectable *, OcrOperation **) = 0;
virtual HRESULT STDMETHODCALLTYPE Language(IInspectable **) = 0;
};
struct OcrStatics : IInspectable
{
virtual HRESULT STDMETHODCALLTYPE MaxDimension(UINT32 *) = 0;
virtual HRESULT STDMETHODCALLTYPE Languages(IInspectable **) = 0;
virtual HRESULT STDMETHODCALLTYPE Supported(IInspectable *, boolean *) = 0;
virtual HRESULT STDMETHODCALLTYPE FromLanguage(IInspectable *, OcrEngine **) = 0;
virtual HRESULT STDMETHODCALLTYPE FromProfile(OcrEngine **) = 0;
};
Pixels decodeImage(const Clip &clip, UINT maxWidth, UINT maxHeight)
{
Bytes encoded;
for (const auto &f : clip.formats)
if (f.name == L"PNG")
{
encoded = f.data;
break;
}
if (encoded.empty())
for (const auto &f : clip.formats)
if ((f.id == CF_DIB || f.id == CF_DIBV5) && f.data.size() >= 40)
{
BITMAPINFOHEADER h;
memcpy(&h, f.data.data(), 40);
if (h.biSize < 40 || h.biSize > f.data.size())
continue;
uint64_t colors = h.biClrUsed ? h.biClrUsed : (h.biBitCount <= 8 ? 1ull << h.biBitCount : 0);
uint64_t offset = sizeof(BITMAPFILEHEADER) + h.biSize + 4 * colors +
(h.biSize == 40 && h.biCompression == BI_BITFIELDS ? 12 : 0);
if (offset > sizeof(BITMAPFILEHEADER) + f.data.size())
throw std::runtime_error("Invalid bitmap header");
BITMAPFILEHEADER file{};
file.bfType = 0x4d42;
file.bfSize = (DWORD)(sizeof(file) + f.data.size());
file.bfOffBits = (DWORD)offset;
encoded.resize(sizeof(file));
memcpy(encoded.data(), &file, sizeof(file));
encoded.insert(encoded.end(), f.data.begin(), f.data.end());
break;
}
if (encoded.empty())
throw std::runtime_error("This clipboard item has no supported image");
Com<IWICImagingFactory> wic;
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_IWICImagingFactory,
(void **)wic.out()));
Com<IWICStream> stream;
check(wic->CreateStream(stream.out()));
check(stream->InitializeFromMemory(encoded.data(), (DWORD)encoded.size()));
Com<IWICBitmapDecoder> decoder;
check(wic->CreateDecoderFromStream(stream.p, nullptr, WICDecodeMetadataCacheOnLoad, decoder.out()));
Com<IWICBitmapFrameDecode> frame;
check(decoder->GetFrame(0, frame.out()));
Pixels pixels;
check(frame->GetSize(&pixels.width, &pixels.height));
if (!pixels.width || !pixels.height || uint64_t(pixels.width) * pixels.height > 32 * 1024 * 1024)
throw std::runtime_error("Image is too large to decode safely");
Com<IWICFormatConverter> converter;
Com<IWICBitmapScaler> scaler;
IWICBitmapSource *source = frame.p;
if (maxWidth && maxHeight && (pixels.width > maxWidth || pixels.height > maxHeight))
{
const double ratio = std::min(double(maxWidth) / pixels.width, double(maxHeight) / pixels.height);
pixels.width = std::max(1u, UINT(pixels.width * ratio));
pixels.height = std::max(1u, UINT(pixels.height * ratio));
check(wic->CreateBitmapScaler(scaler.out()));
check(scaler->Initialize(frame.p, pixels.width, pixels.height, WICBitmapInterpolationModeFant));
source = scaler.p;
}
check(wic->CreateFormatConverter(converter.out()));
check(converter->Initialize(source, GUID_WICPixelFormat32bppBGRA, WICBitmapDitherTypeNone, nullptr, 0,
WICBitmapPaletteTypeCustom));
pixels.bgra.resize(size_t(pixels.width) * pixels.height * 4);
check(converter->CopyPixels(nullptr, pixels.width * 4, (UINT)pixels.bgra.size(), pixels.bgra.data()));
return pixels;
}
Format imageDib(const Pixels &p)
{
BITMAPINFOHEADER h{};
h.biSize = sizeof(h);
h.biWidth = (LONG)p.width;
h.biHeight = -(LONG)p.height;
h.biPlanes = 1;
h.biBitCount = 32;
Format format;
format.id = CF_DIB;
format.data.resize(sizeof(h));
memcpy(format.data.data(), &h, sizeof(h));
format.data.insert(format.data.end(), p.bgra.begin(), p.bgra.end());
return format;
}
std::wstring recognizeText(const Clip &clip)
{
check(RoInitialize(RO_INIT_MULTITHREADED));
struct Apartment
{
~Apartment()
{
RoUninitialize();
}
} apartment;
auto pixels = decodeImage(clip);
Com<OcrStatics> ocr;
factory(L"Windows.Media.Ocr.OcrEngine", L"{5bffa85a-3384-3540-9940-699120d428a8}", ocr);
UINT32 max{};
check(ocr->MaxDimension(&max));
if (pixels.width > max || pixels.height > max)
throw std::runtime_error("Image exceeds Windows OCR's maximum dimensions");
Com<OcrEngine> engine;
check(ocr->FromProfile(engine.out()));
if (!engine.p)
throw std::runtime_error("No Windows OCR language is installed for your profile");
Com<BufferFactory> buffers;
factory(L"Windows.Storage.Streams.Buffer", L"{71af914d-c10f-484b-bc50-14bc623b3a27}", buffers);
Com<Buffer> buffer;
check(buffers->Create((UINT32)pixels.bgra.size(), buffer.out()));
check(buffer->SetLength((UINT32)pixels.bgra.size()));
Com<BufferBytes> bytes;
check(buffer->QueryInterface(guid(L"{905a0fef-bc53-11df-8c49-001e4fc686da}"), (void **)bytes.out()));
BYTE *memory{};
check(bytes->Data(&memory));
memcpy(memory, pixels.bgra.data(), pixels.bgra.size());
Com<BitmapStatics> bitmaps;
factory(L"Windows.Graphics.Imaging.SoftwareBitmap", L"{df0385db-672f-4a9d-806e-c2442f343e86}", bitmaps);
Com<IInspectable> bitmap;
check(bitmaps->FromBufferAlpha(buffer.p, 87, (INT32)pixels.width, (INT32)pixels.height, 2, bitmap.out()));
Com<OcrOperation> operation;
check(engine->Recognize(bitmap.p, operation.out()));
Com<AsyncInfo> info;
check(operation->QueryInterface(guid(L"{00000036-0000-0000-c000-000000000046}"), (void **)info.out()));
auto deadline = GetTickCount64() + 30000;
INT32 status{};
do
{
check(info->Status(&status));
if (status == 0)
{
if (GetTickCount64() > deadline)
{
info->Cancel();
throw std::runtime_error("Windows OCR timed out");
}
Sleep(20);
}
} while (status == 0);
if (status != 1)
{
HRESULT failure{};
check(info->ErrorCode(&failure));
check(FAILED(failure) ? failure : E_ABORT);
}
Com<OcrResult> result;
check(operation->Results(result.out()));
HSTRING text{};
check(result->Text(&text));
UINT32 length{};
const wchar_t *raw = WindowsGetStringRawBuffer(text, &length);
std::wstring out(raw, length);
WindowsDeleteString(text);
return out;
}
} // namespace ks
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include "platform.h"
namespace ks
{
struct Pixels
{
UINT width{}, height{};
Bytes bgra;
};
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
Format imageDib(const Pixels &);
std::wstring recognizeText(const Clip &);
} // namespace ks
+504
View File
@@ -0,0 +1,504 @@
#include "platform.h"
#include <algorithm>
#include <fstream>
#include <imm.h>
#include <map>
#include <mutex>
#include <set>
#include <shlobj.h>
#include <tlhelp32.h>
#include <wincrypt.h>
namespace ks
{
namespace
{
const UINT scans[] = {0x29, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 16, 17, 18,
19, 20, 21, 22, 23, 24, 25, 26, 27, 30, 31, 32, 33, 34, 35, 36,
37, 38, 39, 40, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 43};
struct ClipboardLock
{
ClipboardLock(HWND w)
{
for (int i = 0; i < 30; ++i)
{
if (OpenClipboard(w))
return;
Sleep(10);
}
throw std::runtime_error("Clipboard is busy");
}
~ClipboardLock()
{
CloseClipboard();
}
};
bool globalFormat(UINT f)
{
return f != CF_BITMAP && f != CF_PALETTE && f != CF_METAFILEPICT && f != CF_ENHMETAFILE &&
!(f >= CF_PRIVATEFIRST && f <= CF_PRIVATELAST) && !(f >= CF_GDIOBJFIRST && f <= CF_GDIOBJLAST) &&
f != CF_OWNERDISPLAY;
}
std::optional<ULONGLONG> started(DWORD id)
{
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, id);
if (!h)
return {};
FILETIME c, e, k, u;
BOOL ok = GetProcessTimes(h, &c, &e, &k, &u);
CloseHandle(h);
return ok ? std::optional<ULONGLONG>((ULONGLONG(c.dwHighDateTime) << 32) | c.dwLowDateTime)
: std::nullopt;
}
} // namespace
std::vector<Layout> readLayouts()
{
int n = GetKeyboardLayoutList(0, nullptr);
std::vector<HKL> handles(n);
n = GetKeyboardLayoutList(n, handles.data());
std::vector<Layout> out;
for (int i = 0; i < n; ++i)
{
Layout l;
l.handle = handles[i];
wchar_t id[32];
swprintf_s(id, L"win:%08x", (unsigned)(uintptr_t)l.handle);
l.id = id;
wchar_t name[128]{};
GetLocaleInfoW(MAKELCID(LOWORD((uintptr_t)l.handle), SORT_DEFAULT), LOCALE_SNATIVELANGNAME, name,
128);
l.name = name;
if (l.name.empty())
l.name = l.id;
l.name += L" (" + l.id.substr(4) + L")";
l.convertible =
ImmGetIMEFileNameW(l.handle, nullptr, 0) == 0 && (((uintptr_t)l.handle >> 16) & 0xf000) != 0xe000;
for (bool shift : {false, true})
for (UINT scan : scans)
{
BYTE state[256]{};
if (shift)
state[VK_SHIFT] = 0x80;
wchar_t buf[8]{};
UINT vk = MapVirtualKeyExW(scan, MAPVK_VSC_TO_VK_EX, l.handle);
int count = ToUnicodeEx(vk, scan, state, buf, 8, 4, l.handle);
wchar_t c =
count == 1 && buf[0] >= 32 && !(buf[0] >= 0xd800 && buf[0] <= 0xdfff) ? buf[0] : 0;
(shift ? l.shifted : l.normal) += c;
}
out.push_back(std::move(l));
}
return out;
}
const Layout &physicalLayout()
{
static const Layout l{nullptr,
L"physical",
L"Physical US",
L"`1234567890-=qwertyuiop[]asdfghjkl;'zxcvbnm,./\\",
L"~!@#$%^&*()_+QWERTYUIOP{}ASDFGHJKL:\"ZXCVBNM<>?|",
true};
return l;
}
wchar_t physicalKey(DWORD scan, bool shift)
{
for (size_t i = 0; i < std::size(scans); ++i)
if (scans[i] == scan)
return (shift ? physicalLayout().shifted : physicalLayout().normal)[i];
return 0;
}
HKL windowLayout(HWND w)
{
return GetKeyboardLayout(GetWindowThreadProcessId(w, nullptr));
}
bool activateWindow(HWND window)
{
if (!IsWindow(window))
return false;
if (SetForegroundWindow(window))
return true;
DWORD foreground = GetWindowThreadProcessId(GetForegroundWindow(), nullptr);
DWORD current = GetCurrentThreadId();
bool attached = foreground && foreground != current && AttachThreadInput(current, foreground, TRUE);
BringWindowToTop(window);
SetForegroundWindow(window);
if (attached)
AttachThreadInput(current, foreground, FALSE);
return GetForegroundWindow() == window;
}
bool webViewDescendant(DWORD owner, DWORD host,
const std::function<std::optional<std::pair<DWORD, std::wstring>>(DWORD)> &read)
{
if (!owner || !host)
return false;
std::set<DWORD> seen;
while (owner != host)
{
if (!seen.insert(owner).second)
return false;
auto p = read(owner);
if (!p || lower(p->second) != L"msedgewebview2.exe" || !p->first)
return false;
owner = p->first;
}
return true;
}
bool ownsClipboard(HWND window)
{
HWND owner = GetClipboardOwner();
if (!owner || !window)
return false;
DWORD a{}, b{};
GetWindowThreadProcessId(owner, &a);
GetWindowThreadProcessId(window, &b);
if (!a || !b)
return false;
if (a == b)
return true;
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot == INVALID_HANDLE_VALUE)
return false;
std::map<DWORD, std::pair<DWORD, std::wstring>> entries;
PROCESSENTRY32W e{};
e.dwSize = sizeof(e);
if (Process32FirstW(snapshot, &e))
do
{
entries[e.th32ProcessID] = {e.th32ParentProcessID, e.szExeFile};
} while (Process32NextW(snapshot, &e));
CloseHandle(snapshot);
return webViewDescendant(a, b,
[&](DWORD id) -> std::optional<std::pair<DWORD, std::wstring>>
{
auto it = entries.find(id);
if (it == entries.end())
return {};
auto child = started(id), parent = started(it->second.first);
if (!child || !parent || *parent > *child)
return {};
return it->second;
});
}
std::wstring processName(HWND window)
{
DWORD pid{};
GetWindowThreadProcessId(window, &pid);
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
if (!h)
return {};
wchar_t path[32768];
DWORD n = 32768;
BOOL ok = QueryFullProcessImageNameW(h, 0, path, &n);
CloseHandle(h);
return ok ? std::filesystem::path(std::wstring(path, n)).stem().wstring() : L"";
}
Clip readClipboard(HWND window, size_t limit)
{
ClipboardLock lock(window);
Clip c;
size_t total = 0;
for (UINT f = EnumClipboardFormats(0); f; f = EnumClipboardFormats(f))
{
if (!globalFormat(f))
continue;
HANDLE handle = GetClipboardData(f);
if (!handle)
continue;
SIZE_T size = GlobalSize(handle);
if (!size)
continue;
if (size > limit - total)
throw std::runtime_error("Clipboard exceeds the configured size limit");
const auto *p = static_cast<const unsigned char *>(GlobalLock(handle));
if (!p)
throw std::runtime_error("Cannot read clipboard format");
Format format;
format.id = f;
format.data.assign(p, p + size);
GlobalUnlock(handle);
if (f >= 0xc000)
{
wchar_t name[256]{};
GetClipboardFormatNameW(f, name, 256);
format.name = name;
}
total += size;
c.formats.push_back(std::move(format));
}
return c;
}
DWORD writeClipboard(HWND window, const Clip &c)
{
// Allocate all blocks before replacing any existing data.
std::vector<std::pair<UINT, HGLOBAL>> blocks;
try
{
for (const auto &f : c.formats)
{
if (f.data.empty())
continue;
UINT id = f.name.empty() ? f.id : RegisterClipboardFormatW(f.name.c_str());
if (!id)
throw std::runtime_error("Cannot register clipboard format");
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, f.data.size());
if (!h)
throw std::bad_alloc();
void *p = GlobalLock(h);
if (!p)
{
GlobalFree(h);
throw std::bad_alloc();
}
memcpy(p, f.data.data(), f.data.size());
GlobalUnlock(h);
blocks.emplace_back(id, h);
}
ClipboardLock lock(window);
if (!EmptyClipboard())
throw std::runtime_error("Cannot replace clipboard");
for (auto &[id, h] : blocks)
{
if (!SetClipboardData(id, h))
throw std::runtime_error("Cannot write clipboard format");
h = nullptr;
}
}
catch (...)
{
for (auto [id, h] : blocks)
if (h)
GlobalFree(h);
throw;
}
return GetClipboardSequenceNumber();
}
Clip textClip(const std::wstring &text)
{
Format f;
f.id = CF_UNICODETEXT;
f.data.resize((text.size() + 1) * sizeof(wchar_t));
memcpy(f.data.data(), text.c_str(), f.data.size());
return Clip{{std::move(f)}};
}
std::wstring Clip::text() const
{
for (const auto &f : formats)
if (f.id == CF_UNICODETEXT && f.data.size() >= 2)
{
std::wstring text(f.data.size() / 2, L'\0');
memcpy(text.data(), f.data.data(), text.size() * 2);
auto pos = text.find(L'\0');
if (pos != text.npos)
text.resize(pos);
return text;
}
return {};
}
size_t Clip::size() const
{
size_t n = 0;
for (const auto &f : formats)
n += f.data.size();
return n;
}
bool chord(WORD key, UINT mods)
{
std::vector<INPUT> inputs;
auto add = [&](WORD k, bool up)
{
INPUT i{};
i.type = INPUT_KEYBOARD;
i.ki.wVk = k;
i.ki.dwFlags = up ? KEYEVENTF_KEYUP : 0;
if (k == VK_LEFT || k == VK_RIGHT || k == VK_INSERT)
i.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
inputs.push_back(i);
};
for (auto [m, k] : {std::pair<UINT, WORD>{MOD_CONTROL, VK_CONTROL},
{MOD_ALT, VK_MENU},
{MOD_SHIFT, VK_SHIFT},
{MOD_WIN, VK_LWIN}})
if (mods & m)
add(k, false);
add(key, false);
add(key, true);
for (auto [m, k] : {std::pair<UINT, WORD>{MOD_WIN, VK_LWIN},
{MOD_SHIFT, VK_SHIFT},
{MOD_ALT, VK_MENU},
{MOD_CONTROL, VK_CONTROL}})
if (mods & m)
add(k, true);
return SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size();
}
bool needsSelectionDelay(HWND target)
{
const auto name = lower(processName(target));
return name == L"ms-teams" || name == L"msedgewebview2";
}
bool selectPrevious(size_t count, const std::function<bool()> &valid)
{
if (count > 100000)
return false;
// Teams' WebView editor applies selection changes asynchronously. A burst of
// Shift+Left events can all use the same caret position and select only one
// character. Give each change time to settle before sending the next one.
const auto target = GetForegroundWindow();
if (needsSelectionDelay(target))
{
for (size_t i = 0; i < count; ++i)
{
if (!valid() || GetForegroundWindow() != target || !chord(VK_LEFT, MOD_SHIFT))
return false;
Sleep(25);
}
return valid() && GetForegroundWindow() == target;
}
for (size_t offset = 0; offset < count; offset += 256)
{
if (!valid())
return false;
std::vector<INPUT> inputs;
auto add = [&](WORD key, DWORD flags)
{
INPUT i{};
i.type = INPUT_KEYBOARD;
i.ki.wVk = key;
i.ki.dwFlags = flags;
inputs.push_back(i);
};
add(VK_SHIFT, 0);
for (size_t i = 0; i < std::min<size_t>(256, count - offset); ++i)
{
add(VK_LEFT, KEYEVENTF_EXTENDEDKEY);
add(VK_LEFT, KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYUP);
}
add(VK_SHIFT, KEYEVENTF_KEYUP);
if (SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) != inputs.size())
return false;
}
return valid();
}
bool modifiersReleased()
{
for (int k : {VK_CONTROL, VK_MENU, VK_SHIFT, VK_LWIN, VK_RWIN})
if (GetAsyncKeyState(k) & 0x8000)
return false;
return true;
}
void releaseModifiers()
{
std::vector<INPUT> inputs;
for (int k : {VK_LCONTROL, VK_RCONTROL, VK_LMENU, VK_RMENU, VK_LSHIFT, VK_RSHIFT, VK_LWIN, VK_RWIN})
if (GetAsyncKeyState(k) & 0x8000)
{
INPUT i{};
i.type = INPUT_KEYBOARD;
i.ki.wVk = (WORD)k;
i.ki.dwFlags = KEYEVENTF_KEYUP;
inputs.push_back(i);
}
if (!inputs.empty())
SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT));
}
std::wstring executable()
{
wchar_t path[32768];
DWORD n = GetModuleFileNameW(nullptr, path, 32768);
if (!n || n == 32768)
throw std::runtime_error("Cannot locate executable");
return std::wstring(path, n);
}
std::filesystem::path dataDirectory()
{
auto folder = std::filesystem::path(executable()).parent_path() / L"data";
std::error_code ec;
std::filesystem::create_directories(folder, ec);
HANDLE probe = CreateFileW((folder / L".probe").c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS,
FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE, nullptr);
if (probe != INVALID_HANDLE_VALUE)
{
CloseHandle(probe);
return folder;
}
PWSTR base{};
if (FAILED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &base)))
throw std::runtime_error("Cannot locate user data folder");
folder = std::filesystem::path(base) / L"KSwitcher";
CoTaskMemFree(base);
std::filesystem::create_directories(folder);
return folder;
}
void atomicWrite(const std::filesystem::path &path, const Bytes &bytes)
{
auto tmp = path;
tmp += L".tmp";
HANDLE h =
CreateFileW(tmp.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (h == INVALID_HANDLE_VALUE)
throw std::runtime_error("Cannot open output file");
DWORD written{};
BOOL ok = WriteFile(h, bytes.data(), (DWORD)bytes.size(), &written, nullptr);
BOOL flushed = FlushFileBuffers(h);
CloseHandle(h);
if (!ok || written != bytes.size() || !flushed ||
!MoveFileExW(tmp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
{
DeleteFileW(tmp.c_str());
throw std::runtime_error("Cannot save file atomically");
}
}
Bytes readFile(const std::filesystem::path &path, size_t limit)
{
auto n = std::filesystem::file_size(path);
if (n > limit)
throw std::runtime_error("File exceeds size limit");
Bytes b((size_t)n);
std::ifstream f(path, std::ios::binary);
if (!f.read((char *)b.data(), (std::streamsize)b.size()))
throw std::runtime_error("Cannot read file");
return b;
}
Bytes protect(const Bytes &bytes)
{
DATA_BLOB in{(DWORD)bytes.size(), const_cast<BYTE *>(bytes.data())}, out{};
if (!CryptProtectData(&in, L"KSwitcher clipboard", nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN,
&out))
throw std::runtime_error("Cannot encrypt history");
Bytes result(out.pbData, out.pbData + out.cbData);
LocalFree(out.pbData);
return result;
}
Bytes unprotect(const Bytes &bytes)
{
DATA_BLOB in{(DWORD)bytes.size(), const_cast<BYTE *>(bytes.data())}, out{};
if (!CryptUnprotectData(&in, nullptr, nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &out))
throw std::runtime_error("Cannot decrypt history for this Windows account");
Bytes result(out.pbData, out.pbData + out.cbData);
LocalFree(out.pbData);
return result;
}
std::wstring lastError(const std::wstring &context)
{
return context + L" (Windows error " + std::to_wstring(GetLastError()) + L")";
}
void log(const std::wstring &message)
{
static std::mutex gate;
std::lock_guard lock(gate);
try
{
auto path = dataDirectory() / L"kswitcher.log";
std::error_code ec;
if (std::filesystem::file_size(path, ec) > 256 * 1024)
std::filesystem::remove(path, ec);
std::ofstream f(path, std::ios::app);
SYSTEMTIME t;
GetLocalTime(&t);
f << t.wYear << '-' << t.wMonth << '-' << t.wDay << ' ' << t.wHour << ':' << t.wMinute << ':'
<< t.wSecond << ' ' << utf8(message) << '\n';
}
catch (...)
{
}
}
} // namespace ks
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include "core.h"
#include <filesystem>
#include <objbase.h>
#include <span>
#include <stdexcept>
namespace ks
{
using Bytes = std::vector<unsigned char>;
struct Format
{
UINT id{};
std::wstring name;
Bytes data;
};
struct Clip
{
std::vector<Format> formats;
std::wstring text() const;
size_t size() const;
};
std::vector<Layout> readLayouts();
const Layout &physicalLayout();
wchar_t physicalKey(DWORD scan, bool shift);
HKL windowLayout(HWND);
bool activateWindow(HWND);
bool ownsClipboard(HWND);
bool webViewDescendant(DWORD owner, DWORD host,
const std::function<std::optional<std::pair<DWORD, std::wstring>>(DWORD)> &read);
std::wstring processName(HWND);
Clip readClipboard(HWND, size_t limit = 32 * 1024 * 1024);
DWORD writeClipboard(HWND, const Clip &);
Clip textClip(const std::wstring &);
bool chord(WORD key, UINT modifiers = 0);
bool needsSelectionDelay(HWND);
bool selectPrevious(size_t count, const std::function<bool()> &valid);
bool modifiersReleased();
void releaseModifiers();
std::filesystem::path dataDirectory();
void atomicWrite(const std::filesystem::path &, const Bytes &);
Bytes readFile(const std::filesystem::path &, size_t limit);
Bytes protect(const Bytes &);
Bytes unprotect(const Bytes &);
void setAutostart(bool, bool elevated = false);
std::wstring currentUserSid();
void configureAutostart(int mode);
std::wstring executable();
std::wstring lastError(const std::wstring &context);
void log(const std::wstring &message);
} // namespace ks
+304
View File
@@ -0,0 +1,304 @@
#include "richtext.h"
#include <algorithm>
#include <cctype>
#include <richedit.h>
#include <string_view>
namespace ks
{
namespace
{
std::string escape(const std::wstring &text)
{
std::string out;
for (wchar_t c : text)
{
if (c == '\r')
continue;
if (c == '\n')
out += "\\line ";
else if (c == '\t')
out += "\\tab ";
else if (c == '\\' || c == '{' || c == '}')
{
out += '\\';
out += char(c);
}
else if (c >= 32 && c < 127)
out += char(c);
else
out += "\\u" + std::to_string((int)(short)c) + "?";
}
return out;
}
const std::string header = "{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Segoe UI;}}\\f0\\fs21\\uc1 ";
std::wstring entities(std::string_view input)
{
std::wstring out;
for (size_t i = 0; i < input.size();)
{
if (input[i] != '&')
{
auto end = input.find('&', i);
if (end == input.npos)
end = input.size();
out += wide(std::string(input.substr(i, end - i)));
i = end;
continue;
}
auto end = input.find(';', i + 1);
if (end != input.npos && end - i < 14)
{
auto name = std::string(input.substr(i + 1, end - i - 1));
uint32_t c = 0;
if (name == "amp")
c = '&';
else if (name == "lt")
c = '<';
else if (name == "gt")
c = '>';
else if (name == "quot")
c = '"';
else if (name == "apos")
c = '\'';
else if (name == "nbsp")
c = 160;
else if (name.starts_with('#'))
try
{
size_t used{};
bool hex = name.size() > 1 && (name[1] == 'x' || name[1] == 'X');
auto digits = name.substr(hex ? 2 : 1);
auto n = std::stoul(digits, &used, hex ? 16 : 10);
if (used == digits.size() && n <= 0x10ffff && !(n >= 0xd800 && n <= 0xdfff))
c = (uint32_t)n;
}
catch (...)
{
}
if (c)
{
if (c > 0xffff)
{
c -= 0x10000;
out += wchar_t(0xd800 + (c >> 10));
out += wchar_t(0xdc00 + (c & 1023));
}
else
out += wchar_t(c);
i = end + 1;
continue;
}
}
out += L'&';
++i;
}
return out;
}
std::string htmlRtf(std::string html)
{
auto start = html.find("<!--StartFragment-->");
auto end = html.find("<!--EndFragment-->");
if (start != html.npos && end != html.npos && end >= start + 20)
html = html.substr(start + 20, end - start - 20);
else
{
auto tag = html.find('<');
if (tag == html.npos)
return {};
html.erase(0, tag);
}
std::string out = header;
std::vector<std::string> stack;
std::string skip;
size_t chars = 0;
bool any = false;
for (size_t i = 0; i < html.size() && chars < 4096 && out.size() < 55000;)
{
if (html[i] != '<')
{
auto next = html.find('<', i);
if (next == html.npos)
next = html.size();
if (skip.empty())
{
auto text = entities(std::string_view(html).substr(i, next - i));
text.resize(std::min(text.size(), 4096 - chars));
chars += text.size();
out += escape(text);
any |= !text.empty();
}
i = next;
continue;
}
if (html.compare(i, 4, "<!--") == 0)
{
auto e = html.find("-->", i + 4);
if (e == html.npos)
break;
i = e + 3;
continue;
}
size_t e = i + 1;
char quote = 0;
for (; e < html.size(); ++e)
{
char c = html[e];
if (quote)
{
if (c == quote)
quote = 0;
}
else if (c == '\'' || c == '"')
quote = c;
else if (c == '>')
break;
}
if (e == html.size())
break;
auto tag = html.substr(i + 1, e - i - 1);
i = e + 1;
std::transform(tag.begin(), tag.end(), tag.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
size_t p = 0;
while (p < tag.size() && isspace((unsigned char)tag[p]))
++p;
bool close = p < tag.size() && tag[p] == '/';
if (close)
++p;
size_t begin = p;
while (p < tag.size() && (isalnum((unsigned char)tag[p]) || tag[p] == '-'))
++p;
auto name = tag.substr(begin, p - begin);
if (!skip.empty())
{
if (close && name == skip)
skip.clear();
continue;
}
if (name == "script" || name == "style" || name == "head")
{
if (!close)
skip = name;
continue;
}
if (name.empty())
continue;
bool block = name == "p" || name == "div" || name == "li" || name == "tr" || name == "blockquote";
if (close)
{
auto it = std::find(stack.rbegin(), stack.rend(), name);
if (it != stack.rend())
{
size_t n = std::distance(stack.rbegin(), it) + 1;
out.append(n, '}');
stack.resize(stack.size() - n);
}
if (block && any)
out += "\\line ";
continue;
}
if (name == "br" || name == "hr")
{
out += "\\line ";
continue;
}
if (name == "img" || name == "meta" || name == "link" || name == "input" || tag.ends_with('/'))
continue;
if (stack.size() >= 128)
return {};
if (block && any && !out.ends_with("\\line "))
out += "\\line ";
out += '{';
stack.push_back(name);
std::string compact = tag;
std::erase_if(compact, [](unsigned char c) { return isspace(c); });
if (name == "b" || name == "strong" || compact.find("font-weight:bold") != compact.npos ||
compact.find("font-weight:700") != compact.npos ||
compact.find("font-weight:600") != compact.npos)
out += "\\b ";
if (name == "i" || name == "em" || compact.find("font-style:italic") != compact.npos)
out += "\\i ";
if (name == "u" || compact.find("text-decoration:underline") != compact.npos ||
compact.find("text-decoration-line:underline") != compact.npos)
out += "\\ul ";
if (name == "s" || name == "strike" || name == "del" || compact.find("line-through") != compact.npos)
out += "\\strike ";
if (compact.find("font-weight:normal") != compact.npos ||
compact.find("font-weight:400") != compact.npos)
out += "\\b0 ";
if (compact.find("font-style:normal") != compact.npos)
out += "\\i0 ";
if (name == "li")
out += "\\bullet\\tab ";
}
if (!any)
return {};
out.append(stack.size(), '}');
out += '}';
return out;
}
struct Stream
{
const std::string &text;
size_t at{};
};
DWORD CALLBACK readStream(DWORD_PTR cookie, LPBYTE buffer, LONG size, LONG *read)
{
auto &s = *(Stream *)cookie;
*read = (LONG)std::min(size_t(size), s.text.size() - s.at);
memcpy(buffer, s.text.data() + s.at, *read);
s.at += *read;
return 0;
}
} // namespace
std::string richPreview(const Clip &clip)
{
for (const auto &f : clip.formats)
if (f.name == L"Rich Text Format" && f.data.size() <= 65536)
{
std::string rtf(f.data.begin(), f.data.end());
while (!rtf.empty() && rtf.back() == 0)
rtf.pop_back();
if (rtf.starts_with("{\\rtf"))
return rtf;
}
for (const auto &f : clip.formats)
if (f.name == L"HTML Format" && f.data.size() <= 1024 * 1024)
{
try
{
auto rtf = htmlRtf(std::string(f.data.begin(), f.data.end()));
if (!rtf.empty())
return rtf;
}
catch (...)
{
}
}
auto text = clip.text();
if (text.size() > 4096)
text.resize(4096);
return header + escape(text) + '}';
}
HWND createRichText(HWND parent, int id, bool visible)
{
static HMODULE rich = LoadLibraryExW(L"Msftedit.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32);
if (!rich)
return nullptr;
return CreateWindowExW(0, L"RICHEDIT50W", L"",
WS_CHILD | ES_MULTILINE | ES_READONLY |
(visible ? (WS_VISIBLE | WS_VSCROLL | ES_AUTOVSCROLL) : 0),
0, 0, 1, 1, parent, (HMENU)(INT_PTR)id, GetModuleHandleW(nullptr), nullptr);
}
bool setRichText(HWND window, const std::string &text)
{
if (!window)
return false;
Stream data{text};
EDITSTREAM stream{(DWORD_PTR)&data, 0, readStream};
SendMessageW(window, EM_STREAMIN, SF_RTF, (LPARAM)&stream);
SendMessageW(window, EM_EMPTYUNDOBUFFER, 0, 0);
return stream.dwError == 0;
}
} // namespace ks
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include "platform.h"
namespace ks
{
std::string richPreview(const Clip &);
HWND createRichText(HWND parent, int id, bool visible);
bool setRichText(HWND, const std::string &);
} // namespace ks
+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
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include "platform.h"
#include <mutex>
namespace ks
{
Settings loadSettings(const std::filesystem::path &, const std::vector<Layout> &);
void saveSettings(const std::filesystem::path &, const Settings &);
void validateSettings(const Settings &);
struct Entry
{
std::wstring id, preview, app;
uint64_t bytes{}, at{};
bool pinned{}, image{}, files{};
};
class History
{
std::filesystem::path folder_;
std::vector<Entry> entries_;
mutable std::mutex mutex_;
void saveIndex();
void prune(const Settings &);
public:
explicit History(const std::filesystem::path &);
std::vector<Entry> entries() const;
Clip load(const std::wstring &id) const;
void add(const Clip &, const std::wstring &app, const Settings &);
void remove(const std::wstring &);
void pin(const std::wstring &);
void clear();
std::vector<Entry> search(const std::wstring &) const;
};
Bytes encodeClip(const Clip &);
Clip decodeClip(const Bytes &);
bool sameClipboardImage(const Clip &, const Clip &);
} // namespace ks
+1155
View File
File diff suppressed because it is too large Load Diff