Files
hrynco-kswitcher/src/app.cpp
T

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