Implement native Windows KSwitcher with configurable hotkeys and clipboard history
This commit is contained in:
+1139
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user