1835 lines
71 KiB
C++
1835 lines
71 KiB
C++
#include "app.h"
|
|
#include "../resources/resource.h"
|
|
#include "ocr.h"
|
|
#include "configuration.h"
|
|
#include "diagnostics.h"
|
|
#include "paste_probe.h"
|
|
#include "screenshot_notifications.h"
|
|
#include <algorithm>
|
|
#include <commctrl.h>
|
|
#include <set>
|
|
|
|
namespace ks
|
|
{
|
|
App *App::current = nullptr;
|
|
namespace
|
|
{
|
|
const char *transformCommandName(int command)
|
|
{
|
|
return command == Toggle ? "Convert / cycle" : command >= Direct ? "Convert / direct" :
|
|
command == Case ? "Change case" : command == Translit ? "Transliterate" :
|
|
command == Undo ? "Undo" : "Automatic / snippet";
|
|
}
|
|
ULONG_PTR snippetReplayMarker()
|
|
{
|
|
static const char marker{};
|
|
return reinterpret_cast<ULONG_PTR>(&marker);
|
|
}
|
|
std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
|
|
{
|
|
std::vector<std::pair<int, std::wstring>> out{{Toggle, s.toggle},
|
|
{Case, s.casing},
|
|
{Translit, s.translit},
|
|
{Undo, s.undo},
|
|
{ShowHistory, s.clipboard},
|
|
{Screenshot, s.screenshot}};
|
|
for (size_t i = 0; i < s.direct.size(); ++i)
|
|
out.emplace_back(Direct + (int)i, s.direct[i]);
|
|
return out;
|
|
}
|
|
size_t caretSteps(std::wstring text)
|
|
{
|
|
size_t n = 0;
|
|
for (size_t i = 0; i < text.size(); ++i)
|
|
{
|
|
if (text[i] == L'\r' && i + 1 < text.size() && text[i + 1] == L'\n')
|
|
++i;
|
|
else if (text[i] >= 0xd800 && text[i] <= 0xdbff && i + 1 < text.size() && text[i + 1] >= 0xdc00 &&
|
|
text[i + 1] <= 0xdfff)
|
|
++i;
|
|
++n;
|
|
}
|
|
return n;
|
|
}
|
|
std::wstring newlines(std::wstring text)
|
|
{
|
|
std::wstring out;
|
|
for (size_t i = 0; i < text.size(); ++i)
|
|
{
|
|
if (text[i] == L'\r' && i + 1 < text.size() && text[i + 1] == L'\n')
|
|
continue;
|
|
out += text[i];
|
|
}
|
|
return out;
|
|
}
|
|
// Every transaction checks input, foreground and clipboard ownership before replacing text.
|
|
class Transaction
|
|
{
|
|
App &app;
|
|
HWND target;
|
|
uint64_t version;
|
|
Clip snapshot;
|
|
DWORD expected{};
|
|
bool captured{};
|
|
std::wstring adopted;
|
|
|
|
public:
|
|
Transaction(App &a, HWND t, uint64_t v) : app(a), target(t), version(v)
|
|
{
|
|
app.suspended = true;
|
|
try
|
|
{
|
|
expected = GetClipboardSequenceNumber();
|
|
snapshot = readClipboard(app.window, 64 * 1024 * 1024);
|
|
if (expected != GetClipboardSequenceNumber())
|
|
throw std::runtime_error("Clipboard changed while taking snapshot");
|
|
captured = true;
|
|
}
|
|
catch (...)
|
|
{
|
|
app.suspended = false;
|
|
throw;
|
|
}
|
|
}
|
|
~Transaction()
|
|
{
|
|
if (captured && GetClipboardSequenceNumber() == expected)
|
|
try
|
|
{
|
|
app.ownSequence = writeClipboard(app.window, snapshot);
|
|
}
|
|
catch (...)
|
|
{
|
|
app.report(L"Could not restore clipboard; the copied text remains available.");
|
|
}
|
|
app.suspended = false;
|
|
}
|
|
bool valid() const
|
|
{
|
|
return target && IsWindow(target) && GetForegroundWindow() == target &&
|
|
app.inputVersion.load() == version;
|
|
}
|
|
void check()
|
|
{
|
|
if (GetClipboardSequenceNumber() != expected)
|
|
{
|
|
auto now = GetClipboardSequenceNumber();
|
|
if (adopted.empty() || !ownsClipboard(target) || readClipboard(app.window).text() != adopted ||
|
|
GetClipboardSequenceNumber() != now)
|
|
throw std::runtime_error("Operation cancelled: another application changed the clipboard");
|
|
expected = now;
|
|
}
|
|
}
|
|
void write(const Clip &c)
|
|
{
|
|
check();
|
|
adopted.clear();
|
|
// These are temporary transaction contents, not a user's clipboard entry.
|
|
// In particular, do not invite the Windows history service to read our
|
|
// sentinel while the target editor is trying to open the clipboard.
|
|
auto temporary = c;
|
|
const std::wstring exclusion = L"ExcludeClipboardContentFromMonitorProcessing";
|
|
temporary.formats.push_back({RegisterClipboardFormatW(exclusion.c_str()), exclusion, Bytes{0}});
|
|
app.ownSequence = expected = writeClipboard(app.window, temporary);
|
|
// A concurrent writer can win immediately after CloseClipboard, before we read its sequence.
|
|
// Never adopt that writer's sequence as our own and paste its content.
|
|
if (GetClipboardOwner() != app.window)
|
|
throw std::runtime_error("Clipboard changed immediately after writing");
|
|
auto text = c.text();
|
|
if ((!text.empty() && readClipboard(app.window).text() != text) ||
|
|
GetClipboardSequenceNumber() != expected)
|
|
throw std::runtime_error("Clipboard changed while verifying our write");
|
|
}
|
|
std::wstring copy()
|
|
{
|
|
if (!valid())
|
|
return {};
|
|
write(textClip(L"\x2063KSwitcher-" + std::to_wstring(GetTickCount64())));
|
|
DWORD before = expected;
|
|
if (!valid() || !chord(L'C', MOD_CONTROL))
|
|
{
|
|
diagnosticEvent(L"Copy failed: context changed or simulated Ctrl+C blocked");
|
|
return {};
|
|
}
|
|
ULONGLONG deadline = GetTickCount64() + 2000;
|
|
ULONGLONG retryAt = GetTickCount64() + 250;
|
|
unsigned attempts = 1;
|
|
DWORD changed = before;
|
|
ULONGLONG stable = 0;
|
|
bool changedAgain = false;
|
|
while (GetTickCount64() < deadline)
|
|
{
|
|
if (!valid())
|
|
return {};
|
|
DWORD now = GetClipboardSequenceNumber();
|
|
if (now != changed)
|
|
{
|
|
changedAgain |= changed != before;
|
|
changed = now;
|
|
stable = GetTickCount64();
|
|
}
|
|
if (changed != before && GetTickCount64() - stable >= 60)
|
|
break;
|
|
// Native editors can silently drop Ctrl+C if another process has
|
|
// the clipboard open. Retry only while our sentinel is untouched;
|
|
// never overwrite a copy result or another application's content.
|
|
if (changed == before && attempts < 3 && GetTickCount64() >= retryAt &&
|
|
!GetOpenClipboardWindow())
|
|
{
|
|
check();
|
|
if (!valid() || GetClipboardSequenceNumber() != before)
|
|
return {};
|
|
if (!chord(L'C', MOD_CONTROL))
|
|
return {};
|
|
diagnosticEvent(L"Copy retry: clipboard unchanged after Ctrl+C");
|
|
++attempts;
|
|
retryAt = GetTickCount64() + 250;
|
|
}
|
|
Sleep(10);
|
|
}
|
|
if (changed == before || !valid())
|
|
{
|
|
diagnosticEvent(changed == before ? L"Copy failed: clipboard timeout (2000 ms)" :
|
|
L"Copy failed: context changed");
|
|
return {};
|
|
}
|
|
if ((GetClipboardOwner() ? !ownsClipboard(target) : changedAgain) ||
|
|
changed != GetClipboardSequenceNumber())
|
|
throw std::runtime_error("Operation cancelled: copy did not belong to the target application");
|
|
expected = changed;
|
|
auto clip = readClipboard(app.window);
|
|
auto text = clip.text();
|
|
adopted = text;
|
|
check();
|
|
for (auto &f : clip.formats)
|
|
{
|
|
if (f.name == L"VisualStudioEditorOperationsLineCutCopyClipboardTag" ||
|
|
f.name == L"MSDEVLineSelect")
|
|
{
|
|
diagnosticEvent(L"Copy rejected: editor copied line without selection");
|
|
return {};
|
|
}
|
|
if (f.name == L"vscode-editor-data")
|
|
{
|
|
std::string json(f.data.begin(), f.data.end());
|
|
if (json.find("\"isFromEmptySelection\":true") != json.npos ||
|
|
json.find("\"isFromEmptySelection\": true") != json.npos)
|
|
{
|
|
diagnosticEvent(L"Copy rejected: editor reported empty selection");
|
|
return {};
|
|
}
|
|
}
|
|
}
|
|
return text;
|
|
}
|
|
bool select(size_t n)
|
|
{
|
|
return selectPrevious(n, [&] { return valid(); });
|
|
}
|
|
std::wstring copyRange(size_t count, const std::wstring &wordCandidate,
|
|
const std::function<bool(const std::wstring &)> &matches,
|
|
const std::function<bool(const std::wstring &)> &suffixMatches = {})
|
|
{
|
|
// WebView word boundaries are only candidates; exact verification is mandatory.
|
|
if (needsSelectionDelay(target) && !wordCandidate.empty() &&
|
|
std::all_of(wordCandidate.begin(), wordCandidate.end(),
|
|
[](wchar_t c) { return letter(c) || iswdigit(c); }))
|
|
{
|
|
if (!valid() || !chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
|
return {};
|
|
Sleep(25);
|
|
auto text = copy();
|
|
if (valid() && matches(text))
|
|
{
|
|
diagnosticEvent(L"Selection: verified word boundary");
|
|
return text;
|
|
}
|
|
if (!valid() || !chord(VK_RIGHT))
|
|
return {};
|
|
Sleep(25);
|
|
}
|
|
auto text = select(count) ? copy() : std::wstring{};
|
|
// Chromium editors can apply only part of a burst of Shift+Left events.
|
|
// Extend only a verified suffix; never replace a partial or unrelated range.
|
|
if (valid() && !matches(text) && !text.empty() &&
|
|
(suffixMatches ? suffixMatches(text) : newlines(wordCandidate).ends_with(newlines(text))) &&
|
|
caretSteps(text) < count)
|
|
{
|
|
const auto missing = count - caretSteps(text);
|
|
diagnosticEvent(L"Selection: extending verified suffix by " + std::to_wstring(missing));
|
|
for (size_t i = 0; i < missing; ++i)
|
|
{
|
|
if (!valid() || !chord(VK_LEFT, MOD_SHIFT))
|
|
return {};
|
|
Sleep(25);
|
|
}
|
|
text = copy();
|
|
diagnosticEvent(L"Selection: suffix recovery match=" + std::to_wstring(matches(text)));
|
|
}
|
|
return text;
|
|
}
|
|
bool paste(const Clip &clip)
|
|
{
|
|
if (!valid())
|
|
return false;
|
|
write(clip);
|
|
for (int i = 0; i < 2; ++i)
|
|
{
|
|
check();
|
|
if (!valid())
|
|
return false;
|
|
}
|
|
if (!chord(L'V', MOD_CONTROL))
|
|
throw std::runtime_error("Windows blocked simulated input; check application privilege levels");
|
|
Sleep(300);
|
|
return true;
|
|
}
|
|
};
|
|
} // namespace
|
|
App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instance(h), smoke(test)
|
|
{
|
|
updateNotifier = notifyUpdateAvailable;
|
|
current = this;
|
|
data = dataRoot.empty() ? dataDirectory() : dataRoot;
|
|
std::filesystem::create_directories(data);
|
|
layouts = readLayouts();
|
|
try
|
|
{
|
|
settings = loadSettings(data / L"settings.json", layouts);
|
|
}
|
|
catch (const std::exception &e)
|
|
{
|
|
settings = loadSettings(data / L"missing-defaults.json", layouts);
|
|
registrationWarning = L"Settings were not loaded: " + wide(e.what());
|
|
}
|
|
setLoggingEnabled(settings.writeLog);
|
|
if (settings.autoCorrect)
|
|
try { autoPredictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", settings.autoLanguages); }
|
|
catch (const std::exception &e)
|
|
{
|
|
settings.autoCorrect = false;
|
|
registrationWarning += L"\nAutomatic correction unavailable: " + wide(e.what());
|
|
}
|
|
try
|
|
{
|
|
history = std::make_unique<History>(data / L"history");
|
|
}
|
|
catch (const std::exception &e)
|
|
{
|
|
registrationWarning += L"\nHistory is unavailable: " + wide(e.what());
|
|
settings.history = false;
|
|
}
|
|
}
|
|
App::~App()
|
|
{
|
|
if (updateWorker.joinable())
|
|
updateWorker.join();
|
|
if (keyboardHook)
|
|
UnhookWindowsHookEx(keyboardHook);
|
|
if (mouseHook)
|
|
UnhookWindowsHookEx(mouseHook);
|
|
{
|
|
std::lock_guard lock(jobsMutex);
|
|
stop = true;
|
|
jobs.clear();
|
|
pendingHistorySearch = {};
|
|
}
|
|
jobsReady.notify_all();
|
|
historySearchReady.notify_all();
|
|
historyOcrReady.notify_all();
|
|
if (historyOcrWorker.joinable())
|
|
historyOcrWorker.join();
|
|
if (historySearchWorker.joinable())
|
|
historySearchWorker.join();
|
|
if (worker.joinable())
|
|
worker.join();
|
|
RemoveClipboardFormatListener(window);
|
|
NOTIFYICONDATAW icon{sizeof(icon)};
|
|
icon.hWnd = window;
|
|
icon.uID = 1;
|
|
Shell_NotifyIconW(NIM_DELETE, &icon);
|
|
SetThreadExecutionState(ES_CONTINUOUS);
|
|
current = nullptr;
|
|
}
|
|
void App::enqueue(std::function<void()> job)
|
|
{
|
|
{
|
|
std::lock_guard lock(jobsMutex);
|
|
if (stop)
|
|
return;
|
|
jobs.push_back(std::move(job));
|
|
}
|
|
jobsReady.notify_one();
|
|
}
|
|
void App::enqueueHistorySearch(std::function<void()> job)
|
|
{
|
|
{
|
|
std::lock_guard lock(jobsMutex);
|
|
if (stop) return;
|
|
// Only the latest query matters. Clipboard capture and OCR must not delay it.
|
|
pendingHistorySearch = std::move(job);
|
|
if (!historySearchWorker.joinable())
|
|
historySearchWorker = std::thread([this]
|
|
{
|
|
for (;;)
|
|
{
|
|
std::function<void()> searchJob;
|
|
{
|
|
std::unique_lock lock(jobsMutex);
|
|
historySearchReady.wait(lock, [&] { return stop || bool(pendingHistorySearch); });
|
|
if (stop) return;
|
|
searchJob = std::move(pendingHistorySearch);
|
|
pendingHistorySearch = {};
|
|
}
|
|
try { searchJob(); }
|
|
catch (const std::exception &e) { report(wide(e.what())); }
|
|
}
|
|
});
|
|
}
|
|
historySearchReady.notify_one();
|
|
}
|
|
void App::scheduleHistoryOcr(const Settings &config)
|
|
{
|
|
{
|
|
std::lock_guard lock(jobsMutex);
|
|
if (stop || !history || !config.history) return;
|
|
historyOcrSettings = config;
|
|
historyOcrRequested = true;
|
|
if (!historyOcrWorker.joinable())
|
|
historyOcrWorker = std::thread([this]
|
|
{
|
|
for (;;)
|
|
{
|
|
Settings config;
|
|
{
|
|
std::unique_lock lock(jobsMutex);
|
|
historyOcrReady.wait(lock, [&] { return stop || historyOcrRequested; });
|
|
if (stop) return;
|
|
historyOcrRequested = false;
|
|
config = historyOcrSettings;
|
|
}
|
|
for (const auto &entry : history->entries())
|
|
{
|
|
{ std::lock_guard lock(jobsMutex); if (stop) return; }
|
|
auto profile = ocrProfile(config);
|
|
if (!entry.image || (entry.ocrState != 1 && entry.ocrProfile == profile)) continue;
|
|
try
|
|
{
|
|
auto text = recognizeText(history->load(entry.id), config);
|
|
std::lock_guard lock(jobsMutex);
|
|
if (stop || ocrProfile(historyOcrSettings) != profile) continue;
|
|
if (history->saveOcr(entry.id, text, config))
|
|
PostMessageW(window, HistoryMessage, 0, 0);
|
|
}
|
|
catch (const std::exception &e)
|
|
{
|
|
// Automatic OCR never changes the clipboard or opens error dialogs.
|
|
log(L"History OCR: " + wide(e.what()));
|
|
try {
|
|
std::lock_guard lock(jobsMutex);
|
|
if (!stop && ocrProfile(historyOcrSettings) == profile)
|
|
history->saveOcr(entry.id, std::nullopt, config);
|
|
}
|
|
catch (const std::exception &saveError) { log(L"History OCR save: " + wide(saveError.what())); }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
historyOcrReady.notify_one();
|
|
}
|
|
void App::report(const std::wstring &text)
|
|
{
|
|
log(text);
|
|
PostMessageW(window, ErrorMessage, 0, (LPARAM) new std::wstring(text));
|
|
}
|
|
void App::addTray()
|
|
{
|
|
NOTIFYICONDATAW icon{sizeof(icon)};
|
|
icon.hWnd = window;
|
|
icon.uID = 1;
|
|
icon.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
|
|
icon.uCallbackMessage = TrayMessage;
|
|
icon.hIcon = (HICON)LoadImageW(instance, MAKEINTRESOURCEW(IDI_KSWITCHER), IMAGE_ICON,
|
|
GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), LR_SHARED);
|
|
wcscpy_s(icon.szTip, localBuildTitle(L"KSwitcher — keyboard layouts & clipboard").c_str());
|
|
Shell_NotifyIconW(NIM_ADD, &icon);
|
|
icon.uVersion = NOTIFYICON_VERSION_4;
|
|
Shell_NotifyIconW(NIM_SETVERSION, &icon);
|
|
}
|
|
bool App::registerHotkeys(const Settings &s, bool rollback)
|
|
{
|
|
std::vector<std::pair<int, Hotkey>> bindings;
|
|
for (auto &[id, text] : shortcuts(s))
|
|
{
|
|
auto key = Hotkey::parse(text);
|
|
if (key && key->key)
|
|
bindings.emplace_back(id, *key);
|
|
}
|
|
if (!bindings.empty() && !keyboardHook)
|
|
{
|
|
if (!rollback)
|
|
registrationWarning = L"Keyboard interception could not start.";
|
|
return false;
|
|
}
|
|
hookBindings = std::move(bindings);
|
|
return true;
|
|
}
|
|
|
|
void App::applySettings(const Settings &s)
|
|
{
|
|
bool autostartChanged = false;
|
|
AutoCorrector predictor;
|
|
validateSettings(s);
|
|
validateTesseract(s);
|
|
if (s.autoCorrect)
|
|
{
|
|
predictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages);
|
|
}
|
|
if (!registerHotkeys(s))
|
|
{
|
|
registerHotkeys(settings, true);
|
|
throw std::runtime_error(utf8(registrationWarning));
|
|
}
|
|
try
|
|
{
|
|
if (s.autostart != settings.autostart || s.elevatedAutostart != settings.elevatedAutostart)
|
|
{
|
|
setAutostart(s.autostart, s.elevatedAutostart);
|
|
autostartChanged = true;
|
|
}
|
|
saveSettings(data / L"settings.json", s);
|
|
}
|
|
catch (...)
|
|
{
|
|
try
|
|
{
|
|
if (autostartChanged)
|
|
setAutostart(settings.autostart, settings.elevatedAutostart);
|
|
}
|
|
catch (...)
|
|
{
|
|
}
|
|
registerHotkeys(settings, true);
|
|
throw;
|
|
}
|
|
{
|
|
std::lock_guard lock(stateMutex);
|
|
settings = s;
|
|
autoPredictor = std::move(predictor);
|
|
setLoggingEnabled(s.writeLog);
|
|
run.reset();
|
|
last.reset();
|
|
++inputVersion;
|
|
}
|
|
// Clear on the worker so the next check reloads edits to dictionary files.
|
|
enqueue([this] { autoCorrector.configure({}, {}); });
|
|
scheduleHistoryOcr(s);
|
|
SetThreadExecutionState(ES_CONTINUOUS |
|
|
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
|
}
|
|
void App::reloadLayouts()
|
|
{
|
|
auto latest = readLayouts();
|
|
Settings s = settings;
|
|
std::vector<std::wstring> direct(latest.size());
|
|
for (size_t i = 0; i < latest.size(); ++i)
|
|
for (size_t j = 0; j < layouts.size(); ++j)
|
|
if (latest[i].id == layouts[j].id && j < s.direct.size())
|
|
direct[i] = s.direct[j];
|
|
s.direct = direct;
|
|
s.directIds.clear();
|
|
for (auto &layout : latest)
|
|
s.directIds.push_back(layout.id);
|
|
{
|
|
std::lock_guard lock(stateMutex);
|
|
layouts = std::move(latest);
|
|
settings = s;
|
|
run.reset();
|
|
last.reset();
|
|
++inputVersion;
|
|
}
|
|
registerHotkeys(settings);
|
|
}
|
|
void App::clearHistory()
|
|
{
|
|
if (busy) throw std::runtime_error("Wait for the current operation to finish.");
|
|
if (history) history->clear();
|
|
if (historyWindow) refreshHistory(*this);
|
|
}
|
|
void App::setHotkeyCapture(HWND field)
|
|
{
|
|
hotkeyCapture = field;
|
|
hotkeyCaptureProcess = 0;
|
|
if (field) GetWindowThreadProcessId(field, &hotkeyCaptureProcess);
|
|
}
|
|
bool App::isHotkey(DWORD key, UINT mods)
|
|
{
|
|
for (auto &[id, text] : shortcuts(settings))
|
|
{
|
|
auto h = Hotkey::parse(text);
|
|
if (h && h->key == key && h->modifiers == mods)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
void App::resetInput()
|
|
{
|
|
std::lock_guard lock(stateMutex);
|
|
++inputVersion;
|
|
run.reset();
|
|
last.reset();
|
|
}
|
|
void App::clearLog(bool previous) { clearDiagnosticLog(previous); }
|
|
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
|
|
{
|
|
if (ownKeyboardInput(k))
|
|
return;
|
|
DWORD vk = k.vkCode;
|
|
if (vk == VK_SHIFT || vk == VK_CONTROL || vk == VK_MENU ||
|
|
vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LCONTROL || vk == VK_RCONTROL || vk == VK_LMENU ||
|
|
vk == VK_RMENU || vk == VK_LWIN || vk == VK_RWIN)
|
|
return;
|
|
std::lock_guard lock(stateMutex);
|
|
if (isHotkey(vk, mods))
|
|
return;
|
|
++inputVersion;
|
|
last.reset();
|
|
HWND fg = GetForegroundWindow();
|
|
HKL layout = windowLayout(fg);
|
|
if (mods & (MOD_CONTROL | MOD_ALT | MOD_WIN))
|
|
{
|
|
run.reset();
|
|
return;
|
|
}
|
|
if (vk == VK_BACK)
|
|
{
|
|
run.backspace(fg, GetTickCount64());
|
|
return;
|
|
}
|
|
bool shift = (mods & MOD_SHIFT) != 0;
|
|
// VK_PACKET carries UTF-16 in scanCode, not a physical key position.
|
|
DWORD scan = vk == VK_PACKET ? 0 : k.scanCode;
|
|
if (!scan && vk != VK_PACKET)
|
|
scan = MapVirtualKeyExW(vk, MAPVK_VK_TO_VSC_EX, layout);
|
|
wchar_t ch = vk == VK_SPACE ? L' ' : physicalKey(scan, shift);
|
|
if (ch && letter(ch) && (GetKeyState(VK_CAPITAL) & 1))
|
|
ch = shift ? lower(std::wstring(1, ch))[0] : upper(std::wstring(1, ch))[0];
|
|
if (!ch)
|
|
{
|
|
run.reset();
|
|
return;
|
|
}
|
|
run.record(ch, fg, layout, GetTickCount64());
|
|
if (settings.autoCorrect && !snippetTyping && k.dwExtraInfo != snippetReplayMarker() &&
|
|
mods == 0 && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 &&
|
|
run.keys.size() <= 41 && !busy && !run.overflow)
|
|
{
|
|
for (int source = 0; source < (int)layouts.size(); ++source)
|
|
if (layouts[source].handle == layout)
|
|
{
|
|
std::wstring predicted;
|
|
for (wchar_t key : run.keys)
|
|
predicted += key == L' ' ? L' ' : layouts[source].at(physicalLayout().code(key));
|
|
if (autoPredictor.suggest(predicted, source, layouts, settings))
|
|
{
|
|
// Reserve the transaction before the next physical key arrives.
|
|
// The delimiter itself passes through; subsequent input is queued.
|
|
typingTarget = fg;
|
|
autoPending = true;
|
|
busy = true;
|
|
if (!PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0))
|
|
{
|
|
typingTarget = nullptr;
|
|
autoPending = false;
|
|
busy = false;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if (busy || settings.snippets.empty())
|
|
return;
|
|
auto keys = lower(run.keys);
|
|
for (size_t i = 0; i < settings.snippets.size(); ++i)
|
|
{
|
|
auto &sn = settings.snippets[i];
|
|
std::vector<std::wstring> variants{lower(sn.trigger)};
|
|
for (auto &l : layouts)
|
|
{
|
|
bool valid = true;
|
|
std::wstring normalized;
|
|
for (wchar_t c : sn.trigger)
|
|
{
|
|
auto p = physicalLayout().at(l.code(c));
|
|
if (!p)
|
|
{
|
|
valid = false;
|
|
break;
|
|
}
|
|
normalized += p;
|
|
}
|
|
if (valid)
|
|
variants.push_back(lower(normalized));
|
|
}
|
|
for (auto &trigger : variants)
|
|
if (keys.size() >= trigger.size() && keys.ends_with(trigger))
|
|
{
|
|
// Keep subsequent typing out of the selection/paste transaction.
|
|
typingTarget = fg;
|
|
snippetPending = (int)i;
|
|
snippetTyping = true;
|
|
busy = true;
|
|
if (!PostMessageW(window, SnippetMessage, (WPARAM)i, (LPARAM)inputVersion.load()))
|
|
{
|
|
typingTarget = nullptr;
|
|
snippetPending = -1;
|
|
snippetTyping = false;
|
|
busy = false;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
|
{
|
|
// Filter before capture, hotkey latching and typing history. External injected
|
|
// down AND up events use exactly the same path as physical keyboard input.
|
|
if (ownKeyboardInput(key) || installingUpdate)
|
|
return false;
|
|
{
|
|
std::lock_guard lock(stateMutex);
|
|
if (typingTarget && GetForegroundWindow() == typingTarget)
|
|
{
|
|
INPUT input{};
|
|
input.type = INPUT_KEYBOARD;
|
|
input.ki.wVk = static_cast<WORD>(key.vkCode);
|
|
input.ki.wScan = static_cast<WORD>(key.scanCode);
|
|
input.ki.dwFlags = (down ? 0 : KEYEVENTF_KEYUP) |
|
|
((key.flags & LLKHF_EXTENDED) ? KEYEVENTF_EXTENDEDKEY : 0);
|
|
if (key.vkCode == VK_PACKET)
|
|
{
|
|
input.ki.wVk = 0;
|
|
input.ki.dwFlags |= KEYEVENTF_UNICODE;
|
|
}
|
|
// Replay as ordinary input so tracking and shortcuts see it once,
|
|
// after the replacement and layout switch have finished.
|
|
deferredTyping.push_back(input);
|
|
return true;
|
|
}
|
|
}
|
|
DWORD captureProcess{};
|
|
if (hotkeyCapture) GetWindowThreadProcessId(hotkeyCapture, &captureProcess);
|
|
if (hotkeyCapture && (!captureProcess || captureProcess != hotkeyCaptureProcess))
|
|
setHotkeyCapture(nullptr);
|
|
if (hotkeyCapture && GetForegroundWindow() == GetAncestor(hotkeyCapture, GA_ROOT))
|
|
{
|
|
bool modifier = key.vkCode == VK_CONTROL || key.vkCode == VK_SHIFT || key.vkCode == VK_MENU ||
|
|
key.vkCode == VK_LCONTROL || key.vkCode == VK_RCONTROL || key.vkCode == VK_LSHIFT ||
|
|
key.vkCode == VK_RSHIFT || key.vkCode == VK_LMENU || key.vkCode == VK_RMENU ||
|
|
key.vkCode == VK_LWIN || key.vkCode == VK_RWIN;
|
|
if (!modifier)
|
|
{
|
|
if (!down)
|
|
hotkeyHook.update(key.vkCode, false, 0, {});
|
|
if (down)
|
|
{
|
|
if (mods & MOD_WIN)
|
|
chord(0xE8);
|
|
PostMessageW(hotkeyCapture, CaptureHotkeyMessage, key.vkCode, mods);
|
|
}
|
|
return 1; // Record the chord without executing a global shortcut or typing into an editor.
|
|
}
|
|
}
|
|
if (down)
|
|
{
|
|
auto action = hotkeyHook.update(key.vkCode, true, mods, hookBindings);
|
|
if (action > 0)
|
|
{
|
|
// Tell the shell that Win participated in a chord, preventing Start
|
|
// from opening on Win-up after we consumed V. 0xE8 is unassigned.
|
|
if (mods & (MOD_WIN | MOD_ALT))
|
|
chord(0xE8);
|
|
PostMessageW(window, WM_HOTKEY, action, 0);
|
|
}
|
|
if (action != 0)
|
|
return 1;
|
|
keyboard(key, mods);
|
|
}
|
|
else if (hotkeyHook.update(key.vkCode, false, 0, hookBindings) != 0)
|
|
return true;
|
|
return false;
|
|
}
|
|
bool App::beginTypingDeferral(HWND target, uint64_t version, bool snippet)
|
|
{
|
|
std::lock_guard lock(stateMutex);
|
|
if (inputVersion != version || GetForegroundWindow() != target || !modifiersReleased())
|
|
return false;
|
|
typingTarget = target;
|
|
snippetTyping = snippet;
|
|
return true;
|
|
}
|
|
void App::replayTyping()
|
|
{
|
|
std::vector<INPUT> inputs;
|
|
HWND target;
|
|
{
|
|
std::lock_guard lock(stateMutex);
|
|
target = typingTarget;
|
|
typingTarget = nullptr;
|
|
autoPending = false;
|
|
snippetPending = -1;
|
|
inputs.swap(deferredTyping);
|
|
if (snippetTyping)
|
|
for (auto &input : inputs)
|
|
input.ki.dwExtraInfo = snippetReplayMarker();
|
|
snippetTyping = false;
|
|
}
|
|
busy = false;
|
|
if (inputs.empty())
|
|
return;
|
|
if (GetForegroundWindow() != target)
|
|
{
|
|
resetInput();
|
|
report(L"Typing during correction was not replayed because the active window changed.");
|
|
return;
|
|
}
|
|
// Runs on the hook thread: no physical key can pass between releasing the
|
|
// gate and submitting the entire ordered batch to the Windows input queue.
|
|
if (SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT)) != inputs.size())
|
|
{
|
|
resetInput();
|
|
report(L"Windows could not replay all typing buffered during correction.");
|
|
}
|
|
}
|
|
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
|
|
{
|
|
if (code == HC_ACTION && current &&
|
|
(wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN || wp == WM_KEYUP || wp == WM_SYSKEYUP))
|
|
{
|
|
// The current event has not updated asynchronous state yet; the preceding
|
|
// modifier events (including MWB input) have. Own events must still reach Windows.
|
|
UINT mods = 0;
|
|
if (GetAsyncKeyState(VK_CONTROL) & 0x8000) mods |= MOD_CONTROL;
|
|
if (GetAsyncKeyState(VK_MENU) & 0x8000) mods |= MOD_ALT;
|
|
if (GetAsyncKeyState(VK_SHIFT) & 0x8000) mods |= MOD_SHIFT;
|
|
if ((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000) mods |= MOD_WIN;
|
|
if (current->handleKeyboard(*(KBDLLHOOKSTRUCT *)lp,
|
|
wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN, mods))
|
|
return 1;
|
|
}
|
|
return CallNextHookEx(nullptr, code, wp, lp);
|
|
}
|
|
LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
|
|
{
|
|
if (code == HC_ACTION && current)
|
|
{
|
|
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
|
|
// range just like local clicks, including while a transaction is pending.
|
|
if (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL)
|
|
current->resetInput();
|
|
}
|
|
return CallNextHookEx(nullptr, code, wp, lp);
|
|
}
|
|
void App::dispatch(int command)
|
|
{
|
|
if (installingUpdate)
|
|
return;
|
|
try
|
|
{
|
|
if (command == 23) { checkUpdate(true); return; }
|
|
if (command == Screenshot)
|
|
{
|
|
if (!busy) { resetInput(); launchScreenshot(*this); }
|
|
return;
|
|
}
|
|
if (command == ShowHistory)
|
|
{
|
|
showHistory(*this);
|
|
return;
|
|
}
|
|
if (command == 20)
|
|
{
|
|
showSettings(*this);
|
|
return;
|
|
}
|
|
if (command == 22)
|
|
{
|
|
showLogs(*this);
|
|
return;
|
|
}
|
|
if (command == 21)
|
|
{
|
|
showSnippets(*this);
|
|
return;
|
|
}
|
|
if (command == 99)
|
|
{
|
|
PostMessageW(window, WM_CLOSE, 0, 0);
|
|
return;
|
|
}
|
|
transform(command);
|
|
}
|
|
catch (const std::exception &e)
|
|
{
|
|
report(wide(e.what()));
|
|
}
|
|
}
|
|
void App::transform(int command, int snippet)
|
|
{
|
|
if (installingUpdate) return;
|
|
const auto attempt = ++transformAttempt;
|
|
const auto requestedAt = GetTickCount64();
|
|
HWND target = GetForegroundWindow();
|
|
const bool manual = command != AutoCorrect && snippet < 0;
|
|
if (manual)
|
|
{
|
|
const auto currentVersion = inputVersion.load();
|
|
repeatAttempts = target == previousAttemptWindow && command == previousAttemptCommand &&
|
|
currentVersion == previousAttemptVersion &&
|
|
requestedAt - previousAttemptAt < 10000
|
|
? repeatAttempts + 1 : 1;
|
|
previousAttemptWindow = target;
|
|
previousAttemptCommand = command;
|
|
previousAttemptVersion = currentVersion;
|
|
previousAttemptAt = requestedAt;
|
|
diagnosticEvent(L"Transform request id=" + std::to_wstring(attempt) + L" command=" +
|
|
std::to_wstring(command) + L" process=" + processName(target) +
|
|
L" window=" + std::to_wstring(reinterpret_cast<uintptr_t>(target)) +
|
|
L" version=" + std::to_wstring(currentVersion) +
|
|
L" repeat=" + std::to_wstring(repeatAttempts));
|
|
}
|
|
const bool preDeferred = (command == AutoCorrect && autoPending) ||
|
|
(snippet >= 0 && snippetPending == snippet);
|
|
if (busy.exchange(true) && !preDeferred)
|
|
{
|
|
if (manual)
|
|
{
|
|
diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy");
|
|
try
|
|
{
|
|
DiagnosticJson record{{"attempt", std::to_string(attempt)}, {"process", utf8(processName(target))},
|
|
{"command", transformCommandName(command)}, {"source", "Unknown"}, {"target", "Unknown"},
|
|
{"outcome", "busy"}, {"verification", "not-attempted"}, {"problem", true},
|
|
{"elapsedMs", GetTickCount64() - requestedAt}};
|
|
diagnosticOperation(record.dump());
|
|
}
|
|
catch (...) {}
|
|
}
|
|
return;
|
|
}
|
|
if (preDeferred)
|
|
{
|
|
autoPending = false;
|
|
snippetPending = -1;
|
|
}
|
|
Run recent;
|
|
std::optional<LastTransform> previous;
|
|
Settings config;
|
|
std::vector<Layout> available;
|
|
uint64_t version;
|
|
{
|
|
std::lock_guard lock(stateMutex);
|
|
recent = run;
|
|
previous = last;
|
|
config = settings;
|
|
available = layouts;
|
|
version = inputVersion;
|
|
}
|
|
enqueue(
|
|
[this, target, recent, previous, config, available, version, command, snippet, attempt, manual, requestedAt, preDeferred]
|
|
{
|
|
struct BusyReset
|
|
{
|
|
std::atomic<bool> &b;
|
|
HWND window;
|
|
bool deferred{};
|
|
~BusyReset()
|
|
{
|
|
if (deferred)
|
|
PostMessageW(window, ReplayTypingMessage, 0, 0);
|
|
else
|
|
b = false;
|
|
}
|
|
} reset{busy, window, preDeferred};
|
|
auto start = GetTickCount64();
|
|
std::wstring reason = L"cancelled-before-selection";
|
|
std::wstring text, result, expected, actual, verification = L"not-attempted";
|
|
int hint = -1, to = command >= Direct ? command - Direct : -1;
|
|
if (manual)
|
|
{
|
|
std::wstring layoutIds;
|
|
for (const auto &layout : available)
|
|
{
|
|
layoutIds += layout.id + L",";
|
|
if (layout.handle == recent.layout)
|
|
for (wchar_t key : recent.keys)
|
|
expected += key == L' ' ? L' ' : layout.at(physicalLayout().code(key));
|
|
}
|
|
diagnosticEvent(L"Transform context id=" + std::to_wstring(attempt) +
|
|
L" layouts=" + layoutIds + L" physicalKeys=" + diagnosticText(recent.keys) +
|
|
L" trackedExpected=" + diagnosticText(expected));
|
|
}
|
|
struct Audit
|
|
{
|
|
App &app;
|
|
HWND target;
|
|
uint64_t version, attempt;
|
|
ULONGLONG start;
|
|
bool enabled;
|
|
std::wstring &reason;
|
|
std::wstring &text, &result, &expected;
|
|
std::wstring &actual, &verification;
|
|
int &hint, &to;
|
|
int command;
|
|
const std::vector<Layout> &layouts;
|
|
~Audit()
|
|
{
|
|
if (enabled)
|
|
try
|
|
{
|
|
auto layout = [&](int index) { return index >= 0 && index < (int)layouts.size()
|
|
? utf8(layouts[index].name + L" (" + layouts[index].id + L")") : std::string("Unknown"); };
|
|
auto bounded = [](const std::wstring &value) { return utf8(value.substr(0, 4096)); };
|
|
DiagnosticJson record{{"attempt", std::to_string(attempt)}, {"process", utf8(processName(target))},
|
|
{"command", transformCommandName(command)}, {"source", layout(text.empty() ? hint : detect(text, layouts, hint))}, {"target", layout(to)},
|
|
{"outcome", utf8(reason)}, {"verification", utf8(verification)},
|
|
{"problem", reason != L"Paste verified" && reason != L"layout-only" &&
|
|
!(command == 0 && reason == L"paste-sent-unverified")},
|
|
{"elapsedMs", GetTickCount64() - start}, {"foregroundSame", GetForegroundWindow() == target},
|
|
{"inputSame", app.inputVersion.load() == version}, {"original", bounded(text)},
|
|
{"expected", bounded(result)}, {"actual", bounded(actual)},
|
|
{"originalUnits", text.size()}, {"expectedUnits", result.size()}, {"actualUnits", actual.size()},
|
|
{"textTruncated", text.size() > 4096 || result.size() > 4096 || actual.size() > 4096}};
|
|
diagnosticOperation(record.dump(-1, ' ', true, DiagnosticJson::error_handler_t::replace));
|
|
}
|
|
catch (...) {} // Logging must never abort a completed input transaction.
|
|
}
|
|
} audit{*this, target, version, attempt, requestedAt, manual || snippet >= 0, reason, text, result, expected,
|
|
actual, verification, hint, to, command, available};
|
|
try
|
|
{
|
|
auto valid = [&]
|
|
{ return GetForegroundWindow() == target && inputVersion.load() == version; };
|
|
for (int i = 0; i < 8 && !modifiersReleased(); ++i)
|
|
Sleep(10);
|
|
if (!modifiersReleased() && command == AutoCorrect)
|
|
return;
|
|
if (!modifiersReleased())
|
|
releaseModifiers();
|
|
if (!valid())
|
|
return;
|
|
for (int i = 0; i < (int)available.size(); ++i)
|
|
if (available[i].handle == windowLayout(target))
|
|
hint = i;
|
|
reason = L"invalid-target-layout";
|
|
if (to >= (int)available.size())
|
|
return;
|
|
if (to >= 0 && !available[to].convertible)
|
|
{
|
|
reason = switchWindowLayout(target, available[to].handle, valid)
|
|
? L"layout-only" : L"layout-switch-failed";
|
|
return;
|
|
}
|
|
if (command == AutoCorrect)
|
|
{
|
|
if (!config.autoCorrect || recent.window != target || recent.overflow ||
|
|
recent.keys.empty() || recent.keys.back() != L' ' || hint < 0)
|
|
return;
|
|
autoCorrector.configure(std::filesystem::path(executable()).parent_path() /
|
|
L"dictionaries",
|
|
config.autoLanguages);
|
|
std::wstring predicted;
|
|
for (wchar_t key : recent.keys)
|
|
predicted += key == L' ' ? L' ' : available[hint].at(physicalLayout().code(key));
|
|
expected = predicted;
|
|
if (!autoCorrector.suggest(predicted, hint, available, config) ||
|
|
!autoCorrectionAllowed(target, config) || !valid())
|
|
return;
|
|
}
|
|
reason = L"clipboard-snapshot";
|
|
if (command == AutoCorrect || snippet >= 0)
|
|
{
|
|
if (!beginTypingDeferral(target, version, snippet >= 0))
|
|
return;
|
|
reset.deferred = true;
|
|
}
|
|
// Collapse a temporary selection on every failed auto-correction,
|
|
// including clipboard exceptions, before queued typing is replayed.
|
|
bool autoSelected = false;
|
|
struct SelectionReset
|
|
{
|
|
bool &selected;
|
|
std::function<bool()> valid;
|
|
~SelectionReset() { if (selected && valid()) chord(VK_RIGHT); }
|
|
} selectionReset{autoSelected, valid};
|
|
Transaction tx(*this, target, version);
|
|
reason = L"selection-unavailable";
|
|
diagnosticEvent(L"Transform target=" + processName(target) + L" command=" + std::to_wstring(command) +
|
|
L" recent=" + std::to_wstring(config.recent) + L" keys=" +
|
|
std::to_wstring(recent.keys.size()) + L" sameWindow=" +
|
|
std::to_wstring(recent.window == target) + L" overflow=" +
|
|
std::to_wstring(recent.overflow) + L" ageMs=" +
|
|
std::to_wstring(recent.at ? start - recent.at : 0) +
|
|
L" id=" + std::to_wstring(attempt));
|
|
bool automatic = false;
|
|
if (command == AutoCorrect)
|
|
{
|
|
// The editor must select the complete previous word. Unlike manual
|
|
// conversion, automatic correction must never fall back to a suffix.
|
|
if (!tx.valid() || !chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
|
return;
|
|
autoSelected = true;
|
|
Sleep(25);
|
|
text = tx.copy();
|
|
// Editors may split hj,bnm at the comma. Extend only a verified
|
|
// suffix of the tracked input, and still require a complete match.
|
|
for (int i = 0; i < 3 && tx.valid() &&
|
|
matchAutoInput(recent, text, available, physicalLayout(), config) < 0 &&
|
|
(autoClosingSelection(text, config) ||
|
|
(!text.empty() && text.size() < expected.size() && expected.ends_with(text))); ++i)
|
|
{
|
|
if (!chord(VK_LEFT, MOD_CONTROL | MOD_SHIFT))
|
|
break;
|
|
Sleep(25);
|
|
text = tx.copy();
|
|
}
|
|
hint = matchAutoInput(recent, text, available, physicalLayout(), config);
|
|
auto suggestion = autoCorrector.suggest(text, hint, available, config);
|
|
if (!suggestion || !tx.valid() || !autoCorrectionAllowed(target, config))
|
|
{
|
|
if (tx.valid())
|
|
{
|
|
chord(VK_RIGHT);
|
|
autoSelected = false;
|
|
}
|
|
return;
|
|
}
|
|
to = suggestion->target;
|
|
result = suggestion->text;
|
|
automatic = true;
|
|
}
|
|
else if (command == Undo)
|
|
{
|
|
if (previous)
|
|
expected = previous->result;
|
|
reason = L"undo-unavailable";
|
|
if (!previous || previous->window != target || previous->version != version ||
|
|
GetTickCount64() - previous->at > 120000)
|
|
return;
|
|
text = tx.copyRange(caretSteps(previous->result), previous->result,
|
|
[&](const std::wstring &candidate)
|
|
{ return newlines(candidate) == newlines(previous->result); });
|
|
if (newlines(text) != newlines(previous->result))
|
|
{
|
|
reason = L"undo-verification-mismatch";
|
|
if (tx.valid())
|
|
chord(VK_RIGHT);
|
|
return;
|
|
}
|
|
result = previous->original;
|
|
if (previous->autoCorrected)
|
|
{
|
|
to = previous->sourceLayout;
|
|
}
|
|
automatic = true;
|
|
}
|
|
else if (snippet >= 0)
|
|
{
|
|
if (snippet >= (int)config.snippets.size() || recent.window != target)
|
|
return;
|
|
const auto &sn = config.snippets[snippet];
|
|
if (recent.keys.size() < sn.trigger.size())
|
|
return;
|
|
Run tail = recent;
|
|
tail.keys = tail.keys.substr(tail.keys.size() - sn.trigger.size());
|
|
if (!tx.select(sn.trigger.size()))
|
|
return;
|
|
autoSelected = true;
|
|
reason = L"snippet-verification-mismatch";
|
|
text = tx.copy();
|
|
if (tail.match(text, available, physicalLayout()) < 0)
|
|
return;
|
|
result = parseTemplate(sn.text).text;
|
|
automatic = true;
|
|
}
|
|
else if (config.recent && recent.window == target && !recent.keys.empty() && !recent.overflow)
|
|
{
|
|
text =
|
|
tx.copyRange(recent.keys.size(), recent.keys, [&](const std::wstring &candidate)
|
|
{ return recent.match(candidate, available, physicalLayout()) >= 0; },
|
|
[&](const std::wstring &candidate)
|
|
{ return recent.matchSuffix(candidate, available, physicalLayout()) >= 0; });
|
|
hint = recent.match(text, available, physicalLayout());
|
|
diagnosticEvent(L"Recent verification copied=" + std::to_wstring(text.size()) + L" keys=" +
|
|
std::to_wstring(recent.keys.size()) + L" match=" + std::to_wstring(hint));
|
|
if (hint < 0)
|
|
{
|
|
reason = text.empty() ? L"recent-copy-empty" : L"recent-verification-mismatch";
|
|
if (tx.valid())
|
|
chord(VK_RIGHT);
|
|
return;
|
|
}
|
|
automatic = true;
|
|
}
|
|
else if (config.recent && previous && previous->window == target &&
|
|
previous->version == version)
|
|
{
|
|
expected = previous->result;
|
|
diagnosticEvent(L"Previous result expected=" + diagnosticText(expected));
|
|
text = tx.copyRange(caretSteps(previous->result), previous->result,
|
|
[&](const std::wstring &candidate)
|
|
{ return newlines(candidate) == newlines(previous->result); });
|
|
if (newlines(text) != newlines(previous->result))
|
|
{
|
|
reason = L"previous-result-verification-mismatch";
|
|
if (tx.valid())
|
|
chord(VK_RIGHT);
|
|
return;
|
|
}
|
|
hint = previous->layout;
|
|
automatic = true;
|
|
}
|
|
else
|
|
text = tx.copy();
|
|
reason = L"context-changed-after-copy";
|
|
if (!tx.valid())
|
|
return;
|
|
if (text.empty())
|
|
{
|
|
reason = L"copy-empty";
|
|
if (to >= 0 && config.switchLayout)
|
|
switchWindowLayout(target, available[to].handle, valid);
|
|
return;
|
|
}
|
|
if (command != Undo && command != AutoCorrect && snippet < 0)
|
|
{
|
|
if (command == Case)
|
|
result = cycleCase(text);
|
|
else if (command == Translit)
|
|
result = transliterate(text);
|
|
else
|
|
{
|
|
if (command == Toggle)
|
|
{
|
|
std::vector<int> cycle;
|
|
for (auto &id : config.cycle)
|
|
for (int i = 0; i < (int)available.size(); ++i)
|
|
if (available[i].id == id && available[i].convertible)
|
|
cycle.push_back(i);
|
|
if (cycle.size() < 2)
|
|
{
|
|
reason = L"cycle-has-fewer-than-two-layouts";
|
|
if (automatic && tx.valid())
|
|
chord(VK_RIGHT);
|
|
return;
|
|
}
|
|
to = nextConversionLayout(text, available, cycle, hint);
|
|
}
|
|
result = convertTo(text, available, to, hint);
|
|
}
|
|
}
|
|
bool sent = false;
|
|
PasteProbe probe(config.writeLog && manual && result != text, text);
|
|
diagnosticEvent(L"Conversion source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) +
|
|
L" changed=" + std::to_wstring(result != text));
|
|
if (result != text)
|
|
sent = tx.paste(textClip(result));
|
|
else if (automatic && tx.valid())
|
|
chord(VK_RIGHT);
|
|
if (sent || result == text)
|
|
autoSelected = false;
|
|
reason = result == text ? L"conversion-unchanged" :
|
|
sent ? L"paste-sent-unverified" : L"paste-cancelled";
|
|
if (manual && result == text)
|
|
{
|
|
reason = L"Conversion unchanged";
|
|
verification = L"calculated-result-equals-original";
|
|
}
|
|
if (sent && manual && config.writeLog)
|
|
{
|
|
verification = probe.status;
|
|
if (tx.valid())
|
|
{
|
|
auto observed = probe.read((int)caretSteps(result));
|
|
verification = probe.status;
|
|
if (observed && tx.valid())
|
|
{
|
|
actual = *observed;
|
|
reason = conversionReadbackOutcome(newlines(actual), newlines(text), newlines(result));
|
|
}
|
|
else reason = L"Paste not verified";
|
|
}
|
|
else verification = L"context-changed-after-paste";
|
|
}
|
|
if (sent && !tx.valid())
|
|
reason = L"context-changed-after-paste";
|
|
if (sent && command == Undo && previous && previous->autoCorrected)
|
|
autoCorrector.reject(previous->original, config);
|
|
if (tx.valid() && (sent || automatic))
|
|
{
|
|
std::lock_guard lock(stateMutex);
|
|
run.reset();
|
|
if (command == Undo || snippet >= 0)
|
|
last.reset();
|
|
else
|
|
last = LastTransform{target, text, result, to >= 0 ? to : hint,
|
|
GetTickCount64(), version, hint, command == AutoCorrect};
|
|
}
|
|
if (snippet >= 0 && sent && tx.valid())
|
|
{
|
|
auto t = parseTemplate(config.snippets[snippet].text);
|
|
if (!t.suffix.empty())
|
|
{
|
|
if (!tx.select(caretSteps(t.suffix))) return;
|
|
auto suffix = tx.copy();
|
|
if (!tx.valid()) return;
|
|
if (newlines(suffix) != newlines(t.suffix))
|
|
{
|
|
chord(VK_RIGHT);
|
|
return;
|
|
}
|
|
if (!chord(VK_LEFT)) return;
|
|
}
|
|
// Action markers run after insertion and cursor placement,
|
|
// before any physical typing buffered during expansion.
|
|
for (int i = 0; i < t.enters && tx.valid(); ++i)
|
|
if (!chord(VK_RETURN)) break;
|
|
}
|
|
if (to >= 0 && config.switchLayout && tx.valid() && (sent || result == text))
|
|
{
|
|
bool switched = switchWindowLayout(target, available[to].handle, valid);
|
|
diagnosticEvent(L"Layout switch confirmed=" + std::to_wstring(switched));
|
|
if (!switched)
|
|
reason += L";layout-switch-failed";
|
|
}
|
|
diagnosticEvent(L"Text operation completed in " + std::to_wstring(GetTickCount64() - start) + L" ms");
|
|
}
|
|
catch (const std::exception &e)
|
|
{
|
|
reason = L"exception:" + wide(e.what());
|
|
report(wide(e.what()));
|
|
}
|
|
});
|
|
}
|
|
void App::pasteEntry(std::wstring id, bool plain)
|
|
{
|
|
if (installingUpdate) return;
|
|
if (!history || busy.exchange(true))
|
|
return;
|
|
HWND target = pasteTarget;
|
|
if (historyWindow)
|
|
SendMessageW(historyWindow, WM_CLOSE, 0, 0);
|
|
activateWindow(target);
|
|
auto version = inputVersion.load();
|
|
enqueue(
|
|
[this, id, plain, target, version]
|
|
{
|
|
struct Reset
|
|
{
|
|
std::atomic<bool> &b;
|
|
~Reset()
|
|
{
|
|
b = false;
|
|
}
|
|
} reset{busy};
|
|
try
|
|
{
|
|
for (int i = 0; i < 20 && !modifiersReleased(); ++i)
|
|
Sleep(10);
|
|
if (!modifiersReleased() || GetForegroundWindow() != target || inputVersion != version)
|
|
return;
|
|
auto clip = history->load(id);
|
|
if (plain)
|
|
{
|
|
auto text = clip.text();
|
|
if (text.empty())
|
|
return;
|
|
clip = textClip(text);
|
|
}
|
|
Transaction tx(*this, target, version);
|
|
tx.paste(clip);
|
|
}
|
|
catch (const std::exception &e)
|
|
{
|
|
report(wide(e.what()));
|
|
}
|
|
});
|
|
}
|
|
void App::captureHistory()
|
|
{
|
|
DWORD sequence = GetClipboardSequenceNumber();
|
|
if (suspended || sequence == ownSequence || !settings.history || !history ||
|
|
GetTickCount64() < pauseUntil)
|
|
return;
|
|
Settings config = settings;
|
|
enqueue(
|
|
[this, sequence, config]
|
|
{
|
|
try
|
|
{
|
|
if (suspended || sequence != GetClipboardSequenceNumber() || sequence == ownSequence)
|
|
return;
|
|
UINT exclude = RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing"),
|
|
ignore = RegisterClipboardFormatW(L"Clipboard Viewer Ignore");
|
|
if (IsClipboardFormatAvailable(exclude) || IsClipboardFormatAvailable(ignore))
|
|
return;
|
|
auto owner = GetClipboardOwner();
|
|
auto app = processName(owner ? owner : GetForegroundWindow());
|
|
for (auto &name : config.excluded)
|
|
if (lower(name) == lower(app))
|
|
return;
|
|
auto clip = readClipboard(window, (size_t)config.maxImageMiB * 1024 * 1024);
|
|
if (sequence != GetClipboardSequenceNumber())
|
|
return;
|
|
for (auto &f : clip.formats)
|
|
if (f.name == L"CanIncludeInClipboardHistory" && f.data.size() >= 4 && !f.data[0] &&
|
|
!f.data[1] && !f.data[2] && !f.data[3])
|
|
return;
|
|
history->add(clip, app, config);
|
|
scheduleHistoryOcr(config);
|
|
PostMessageW(window, HistoryMessage, 0, 0);
|
|
}
|
|
catch (const std::exception &e)
|
|
{
|
|
log(L"History capture: " + wide(e.what()));
|
|
}
|
|
});
|
|
}
|
|
void App::recognizeEntry(std::wstring id)
|
|
{
|
|
if (!history || busy.exchange(true))
|
|
return;
|
|
auto sequence = GetClipboardSequenceNumber();
|
|
Settings config = settings;
|
|
enqueue(
|
|
[this, id, sequence, config]
|
|
{
|
|
struct Reset
|
|
{
|
|
std::atomic<bool> &b;
|
|
~Reset()
|
|
{
|
|
b = false;
|
|
}
|
|
} reset{busy};
|
|
try
|
|
{
|
|
auto cached = history->ocrText(id, ocrProfile(config));
|
|
auto text = cached ? *cached : recognizeText(history->load(id), config);
|
|
if (!cached) history->saveOcr(id, text, config);
|
|
if (text.empty())
|
|
{
|
|
report(L"No text was found in the image.");
|
|
return;
|
|
}
|
|
auto clip = textClip(text);
|
|
if (config.history)
|
|
history->add(clip, L"Windows OCR", config);
|
|
if (GetClipboardSequenceNumber() == sequence)
|
|
ownSequence = writeClipboard(window, clip);
|
|
else
|
|
report(L"The clipboard changed during OCR. Recognized text is available in history.");
|
|
PostMessageW(window, HistoryMessage, 0, 0);
|
|
}
|
|
catch (const std::exception &e)
|
|
{
|
|
report(wide(e.what()));
|
|
}
|
|
});
|
|
}
|
|
void App::trayMenu()
|
|
{
|
|
HMENU menu = CreatePopupMenu();
|
|
AppendMenuW(menu, MF_STRING, 10, L"Clipboard history");
|
|
AppendMenuW(menu, MF_STRING, 24, L"Capture screen region…");
|
|
AppendMenuW(menu, MF_STRING, 11, L"Settings…");
|
|
SetMenuDefaultItem(menu, 11, FALSE);
|
|
AppendMenuW(menu, MF_STRING, 12, L"Snippets…");
|
|
AppendMenuW(menu, MF_STRING, 13, L"Keyboard shortcuts…");
|
|
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
|
AppendMenuW(menu, MF_STRING | (settings.awake ? MF_CHECKED : 0), 14, L"Keep screen awake");
|
|
HMENU pause = CreatePopupMenu();
|
|
AppendMenuW(pause, MF_STRING, 20, L"Resume");
|
|
AppendMenuW(pause, MF_STRING, 21, L"10 minutes");
|
|
AppendMenuW(pause, MF_STRING, 22, L"1 hour");
|
|
AppendMenuW(pause, MF_STRING, 23, L"Until restart");
|
|
AppendMenuW(menu, MF_POPUP, (UINT_PTR)pause, L"Pause clipboard history");
|
|
AppendMenuW(menu, MF_STRING, 15, L"Open data folder");
|
|
AppendMenuW(menu, MF_STRING, 18, L"View log…");
|
|
AppendMenuW(menu, MF_STRING | (updateRunning ? MF_GRAYED : 0), 19,
|
|
updateRunning ? L"Update (checking / preparing…)" :
|
|
availableUpdate.empty() ? L"Update" : L"Update (new version available)");
|
|
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
|
|
AppendMenuW(menu, MF_STRING, 16, L"About KSwitcher");
|
|
AppendMenuW(menu, MF_STRING, 17, L"Exit");
|
|
POINT pt;
|
|
GetCursorPos(&pt);
|
|
pasteTarget = GetForegroundWindow();
|
|
SetForegroundWindow(window);
|
|
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON, pt.x, pt.y, 0, window, nullptr);
|
|
DestroyMenu(menu);
|
|
PostMessageW(window, WM_NULL, 0, 0);
|
|
try
|
|
{
|
|
switch (cmd)
|
|
{
|
|
case 24:
|
|
dispatch(Screenshot);
|
|
break;
|
|
case 10:
|
|
showHistory(*this);
|
|
break;
|
|
case 11:
|
|
showSettings(*this);
|
|
break;
|
|
case 12:
|
|
showSnippets(*this);
|
|
break;
|
|
case 13:
|
|
showShortcuts(*this);
|
|
break;
|
|
case 14:
|
|
{
|
|
auto s = settings;
|
|
s.awake = !s.awake;
|
|
applySettings(s);
|
|
break;
|
|
}
|
|
case 15:
|
|
ShellExecuteW(window, L"open", data.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
|
|
break;
|
|
case 16:
|
|
MessageBoxW(window,
|
|
(std::wstring(L"KSwitcher ") + AppVersion + L"\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on "
|
|
L"Typlo by bitcodepro. MIT license.\nHistory is encrypted for your Windows "
|
|
L"account.\nNo .NET, WebView or graphics framework.").c_str(),
|
|
L"About KSwitcher", MB_OK);
|
|
break;
|
|
case 17:
|
|
PostMessageW(window, WM_CLOSE, 0, 0);
|
|
break;
|
|
case 18:
|
|
showLogs(*this);
|
|
break;
|
|
case 19:
|
|
checkUpdate(true);
|
|
break;
|
|
case 20:
|
|
pauseUntil = 0;
|
|
break;
|
|
case 21:
|
|
pauseUntil = GetTickCount64() + 600000;
|
|
break;
|
|
case 22:
|
|
pauseUntil = GetTickCount64() + 3600000;
|
|
break;
|
|
case 23:
|
|
pauseUntil = ~ULONGLONG{};
|
|
break;
|
|
}
|
|
}
|
|
catch (const std::exception &e)
|
|
{
|
|
report(wide(e.what()));
|
|
}
|
|
}
|
|
void App::checkUpdate(bool interactive, bool install, const std::wstring &version)
|
|
{
|
|
if (updateRunning) return;
|
|
if (install && FindWindowW(L"KSwitcher.Config", nullptr))
|
|
{
|
|
report(L"Close the configurator before installing an update.");
|
|
return;
|
|
}
|
|
if (install && busy)
|
|
{
|
|
report(L"Wait for the current text operation to finish, then select Update again.");
|
|
return;
|
|
}
|
|
if (updateWorker.joinable()) updateWorker.join();
|
|
updateRunning = true;
|
|
installingUpdate = install;
|
|
updateWorker = std::thread([this, interactive, install, version]
|
|
{
|
|
auto result = std::make_unique<UpdateResult>();
|
|
try { *result = runUpdate(install, version, interactive); }
|
|
catch (const std::exception &e) { result->state = L"error"; result->error = wide(e.what()); }
|
|
result->interactive = interactive;
|
|
result->install = install;
|
|
if (PostMessageW(window, UpdateMessage, 0, reinterpret_cast<LPARAM>(result.get()))) result.release();
|
|
});
|
|
}
|
|
LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
|
|
{
|
|
auto *app = current;
|
|
if (!app)
|
|
return DefWindowProcW(hwnd, msg, wp, lp);
|
|
if (msg == app->taskbarCreated && app->taskbarCreated)
|
|
{
|
|
app->addTray();
|
|
return 0;
|
|
}
|
|
switch (msg)
|
|
{
|
|
case UpdateMessage:
|
|
{
|
|
std::unique_ptr<UpdateResult> result(reinterpret_cast<UpdateResult *>(lp));
|
|
if (app->updateWorker.joinable()) app->updateWorker.join();
|
|
app->updateRunning = false;
|
|
app->installingUpdate = false;
|
|
log(L"Update check: " + result->state + (result->version.empty() ? L"" : L" " + result->version) +
|
|
(result->error.empty() ? L"" : L"; " + result->error));
|
|
if (result->state == L"prepared")
|
|
{
|
|
// The helper has verified and staged the package; it waits for process exit.
|
|
DestroyWindow(hwnd);
|
|
}
|
|
else if (result->state == L"available")
|
|
{
|
|
app->availableUpdate = result->version;
|
|
if (result->interactive)
|
|
{
|
|
auto prompt = L"Install KSwitcher " + result->version +
|
|
L"?\n\nThe program will close and restart. Settings, history and existing dictionaries are preserved.";
|
|
if (MessageBoxW(hwnd, prompt.c_str(), L"KSwitcher Update", MB_YESNO | MB_ICONQUESTION) == IDYES)
|
|
app->checkUpdate(true, true, result->version);
|
|
}
|
|
else if (app->notifiedUpdate != result->version)
|
|
{
|
|
if (app->updateNotifier(result->version))
|
|
{
|
|
app->notifiedUpdate = result->version;
|
|
log(L"Update notification submitted to Windows: " + result->version);
|
|
}
|
|
else
|
|
{
|
|
log(L"Update notification failed; retrying in 5 minutes: " + result->version);
|
|
SetTimer(hwnd, 5, UpdateNotificationRetryMs, nullptr);
|
|
}
|
|
}
|
|
}
|
|
else if (result->state == L"current")
|
|
{
|
|
app->availableUpdate.clear();
|
|
if (result->interactive)
|
|
MessageBoxW(hwnd, (std::wstring(L"No newer stable release is available.\nInstalled version: ") + AppVersion).c_str(),
|
|
L"KSwitcher Update", MB_OK | MB_ICONINFORMATION);
|
|
}
|
|
else if (result->interactive)
|
|
MessageBoxW(hwnd, (L"Update failed.\n\n" + result->error).c_str(), L"KSwitcher Update", MB_OK | MB_ICONERROR);
|
|
else
|
|
diagnosticEvent(L"Background update check failed: " + result->error);
|
|
return 0;
|
|
}
|
|
case WM_HOTKEY:
|
|
app->dispatch((int)wp);
|
|
return 0;
|
|
case ReplayTypingMessage:
|
|
app->replayTyping();
|
|
return 0;
|
|
case TrayMessage:
|
|
if (LOWORD(lp) == WM_CONTEXTMENU)
|
|
app->trayMenu();
|
|
else if (LOWORD(lp) == WM_LBUTTONDBLCLK)
|
|
// The shell still owns the mouse gesture on the second button-down.
|
|
// Activate after release, so the tray cannot take focus back on button-up.
|
|
SetTimer(hwnd, 4, 50, nullptr);
|
|
else if (LOWORD(lp) == NIN_KEYSELECT)
|
|
app->dispatch(20); // Settings is the tray's default action.
|
|
return 0;
|
|
case WM_CLIPBOARDUPDATE:
|
|
app->captureHistory();
|
|
return 0;
|
|
case AutoCorrectMessage:
|
|
if (app->autoPending)
|
|
{
|
|
if (!app->installingUpdate && !app->snippetTyping && app->settings.autoCorrect && app->inputVersion.load() == (uint64_t)wp)
|
|
app->transform(AutoCorrect);
|
|
else
|
|
app->replayTyping();
|
|
}
|
|
return 0;
|
|
case SnippetMessage:
|
|
if (app->snippetPending == (int)wp)
|
|
{
|
|
if (!app->installingUpdate && app->inputVersion.load() == (uint64_t)lp)
|
|
app->transform(0, (int)wp);
|
|
else
|
|
app->replayTyping();
|
|
}
|
|
return 0;
|
|
case HistoryMessage:
|
|
if (app->historyWindow && IsWindowVisible(app->historyWindow))
|
|
refreshHistory(*app);
|
|
return 0;
|
|
case ErrorMessage:
|
|
{
|
|
std::unique_ptr<std::wstring> text((std::wstring *)lp);
|
|
NOTIFYICONDATAW icon{sizeof(icon)};
|
|
icon.hWnd = hwnd;
|
|
icon.uID = 1;
|
|
icon.uFlags = NIF_INFO;
|
|
wcsncpy_s(icon.szInfo, text->c_str(), _TRUNCATE);
|
|
wcscpy_s(icon.szInfoTitle, L"KSwitcher");
|
|
icon.dwInfoFlags = NIIF_WARNING;
|
|
Shell_NotifyIconW(NIM_MODIFY, &icon);
|
|
return 0;
|
|
}
|
|
case WM_COPYDATA:
|
|
{
|
|
auto *data = (COPYDATASTRUCT *)lp;
|
|
if (data && data->dwData >= ConfigRead && data->dwData < ConfigEnd)
|
|
{
|
|
if (app->installingUpdate) return FALSE;
|
|
return configurationRequest(*app, (HWND)wp, *data);
|
|
}
|
|
if (data && data->dwData == 1 && data->cbData == sizeof(int))
|
|
{
|
|
int cmd;
|
|
memcpy(&cmd, data->lpData, sizeof(cmd));
|
|
app->dispatch(cmd);
|
|
}
|
|
return TRUE;
|
|
}
|
|
case WM_TIMER:
|
|
if (wp == 5)
|
|
{
|
|
// After the initial 5-second check, repeat while the resident stays open.
|
|
// Rearm even when a check is already running or the previous one failed.
|
|
SetTimer(hwnd, 5, UpdateCheckIntervalMs, nullptr);
|
|
app->checkUpdate(false);
|
|
return 0;
|
|
}
|
|
if (wp == 4)
|
|
{
|
|
if (GetAsyncKeyState(VK_LBUTTON) & 0x8000)
|
|
return 0;
|
|
KillTimer(hwnd, 4);
|
|
app->dispatch(20);
|
|
return 0;
|
|
}
|
|
if (wp == 2)
|
|
{
|
|
PostMessageW(hwnd, WM_CLOSE, 0, 0);
|
|
return 0;
|
|
}
|
|
if (app->settings.awake)
|
|
{
|
|
LASTINPUTINFO idle{sizeof(idle)};
|
|
if (GetLastInputInfo(&idle) && DWORD(GetTickCount() - idle.dwTime) >= 180000)
|
|
chord(VK_F15);
|
|
}
|
|
return 0;
|
|
case WM_CLOSE:
|
|
if (app->updateRunning)
|
|
{
|
|
app->report(L"Wait for the update check or preparation to finish before exiting.");
|
|
return 0;
|
|
}
|
|
if (app->busy)
|
|
{
|
|
app->report(L"Wait for the current text operation to finish before exiting.");
|
|
return 0;
|
|
}
|
|
DestroyWindow(hwnd);
|
|
return 0;
|
|
case WM_DESTROY:
|
|
if (auto config = FindWindowW(L"KSwitcher.Config", nullptr);
|
|
config && configurationPeer(config, L"KSwitcher.Config.exe"))
|
|
PostMessageW(config, WM_CLOSE, 0, 0);
|
|
PostQuitMessage(0);
|
|
return 0;
|
|
default:
|
|
return DefWindowProcW(hwnd, msg, wp, lp);
|
|
}
|
|
}
|
|
int App::execute()
|
|
{
|
|
INITCOMMONCONTROLSEX controls{sizeof(controls), ICC_STANDARD_CLASSES | ICC_LISTVIEW_CLASSES};
|
|
InitCommonControlsEx(&controls);
|
|
WNDCLASSW cls{};
|
|
cls.hInstance = instance;
|
|
cls.lpfnWndProc = windowProc;
|
|
cls.lpszClassName = L"KSwitcher.Main";
|
|
cls.hIcon = LoadIconW(instance, MAKEINTRESOURCEW(IDI_KSWITCHER));
|
|
RegisterClassW(&cls);
|
|
window = CreateWindowExW(WS_EX_TOOLWINDOW, cls.lpszClassName, L"KSwitcher", WS_POPUP, 0, 0, 0, 0, nullptr,
|
|
nullptr, instance, nullptr);
|
|
if (!window)
|
|
throw std::runtime_error("Cannot create message window");
|
|
taskbarCreated = RegisterWindowMessageW(L"TaskbarCreated");
|
|
worker = std::thread(
|
|
[this]
|
|
{
|
|
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
|
for (;;)
|
|
{
|
|
std::function<void()> job;
|
|
{
|
|
std::unique_lock lock(jobsMutex);
|
|
jobsReady.wait(lock, [&] { return stop || !jobs.empty(); });
|
|
if (stop)
|
|
break;
|
|
job = std::move(jobs.front());
|
|
jobs.pop_front();
|
|
}
|
|
try
|
|
{
|
|
job();
|
|
}
|
|
catch (const std::exception &e)
|
|
{
|
|
report(wide(e.what()));
|
|
}
|
|
}
|
|
CoUninitialize();
|
|
});
|
|
keyboardHook = SetWindowsHookExW(WH_KEYBOARD_LL, keyProc, instance, 0);
|
|
mouseHook = SetWindowsHookExW(WH_MOUSE_LL, mouseProc, instance, 0);
|
|
registerHotkeys(settings);
|
|
if (!keyboardHook || !mouseHook)
|
|
report(L"Input tracking could not start. Automatic recent-text conversion is unavailable.");
|
|
AddClipboardFormatListener(window);
|
|
// Resume pending OCR, including older images without a saved OCR state.
|
|
scheduleHistoryOcr(settings);
|
|
addTray();
|
|
SetTimer(window, 1, 60000, nullptr);
|
|
SetThreadExecutionState(ES_CONTINUOUS |
|
|
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
|
if (!registrationWarning.empty())
|
|
report(registrationWarning);
|
|
if (smoke)
|
|
SetTimer(window, 2, 10000, nullptr);
|
|
else
|
|
SetTimer(window, 5, 5000, nullptr);
|
|
if (keyboardHook && mouseHook && confirmUpdatedStartup(updateStartupEvent))
|
|
log(std::wstring(L"Update completed; running version ") + AppVersion);
|
|
if (startCommand)
|
|
PostMessageW(window, WM_HOTKEY, startCommand, 0);
|
|
try { registerScreenshotNotifications(); }
|
|
catch(const std::exception &e) { log(L"Screenshot notification registration: "+wide(e.what())); }
|
|
auto screenshotActivation=startScreenshotActivationListener();
|
|
MSG msg;
|
|
while (GetMessageW(&msg, nullptr, 0, 0) > 0)
|
|
{
|
|
if (settingsWindow && !IsWindowEnabled(settingsWindow))
|
|
{
|
|
HWND popup = GetLastActivePopup(settingsWindow);
|
|
if (popup != settingsWindow && IsDialogMessageW(popup, &msg))
|
|
continue;
|
|
}
|
|
if (settingsWindow && IsDialogMessageW(settingsWindow, &msg))
|
|
continue;
|
|
if (snippetsWindow && IsDialogMessageW(snippetsWindow, &msg))
|
|
continue;
|
|
if (logWindow && IsDialogMessageW(logWindow, &msg))
|
|
continue;
|
|
TranslateMessage(&msg);
|
|
DispatchMessageW(&msg);
|
|
}
|
|
if(screenshotActivation) CoRevokeClassObject(screenshotActivation);
|
|
return (int)msg.wParam;
|
|
}
|
|
} // namespace ks
|