#include "app.h" #include "../resources/resource.h" #include "ocr.h" #include "configuration.h" #include #include #include namespace ks { App *App::current = nullptr; namespace { std::vector> shortcuts(const Settings &s) { std::vector> 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(); app.ownSequence = expected = writeClipboard(app.window, c); // 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; 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; 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 &matches) { // 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); } return select(count) ? copy() : std::wstring{}; } 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) { 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); try { history = std::make_unique(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(); } jobsReady.notify_all(); 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 job) { { std::lock_guard lock(jobsMutex); if (stop) return; jobs.push_back(std::move(job)); } jobsReady.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, L"KSwitcher — keyboard layouts & clipboard"); 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> 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; validateSettings(s); if (s.autoCorrect) { AutoCorrector probe; probe.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; 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({}, {}); }); SetThreadExecutionState(ES_CONTINUOUS | (settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0)); } void App::reloadLayouts() { auto latest = readLayouts(); Settings s = settings; std::vector 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::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 && ch == L' ' && run.keys.size() >= (size_t)settings.autoMinLength + 1 && run.keys.size() <= 41 && !busy && !run.overflow) PostMessageW(window, AutoCorrectMessage, (WPARAM)inputVersion.load(), 0); 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 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)) { PostMessageW(window, SnippetMessage, (WPARAM)i, 0); 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; 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; } 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 == 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(target)) + L" version=" + std::to_wstring(currentVersion) + L" repeat=" + std::to_wstring(repeatAttempts)); } if (busy.exchange(true)) { if (manual) diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=busy", true); return; } Run recent; std::optional previous; Settings config; std::vector 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] { struct BusyReset { std::atomic &b; ~BusyReset() { b = false; } } reset{busy}; auto start = GetTickCount64(); std::wstring reason = L"cancelled-before-selection"; std::wstring text, result, expected; 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; int &hint, &to; ~Audit() { if (enabled) diagnosticEvent(L"Transform outcome id=" + std::to_wstring(attempt) + L" reason=" + reason + L" foregroundSame=" + std::to_wstring(GetForegroundWindow() == target) + L" inputSame=" + std::to_wstring(app.inputVersion.load() == version) + L" elapsedMs=" + std::to_wstring(GetTickCount64() - start) + L" source=" + std::to_wstring(hint) + L" target=" + std::to_wstring(to) + L" expected=" + diagnosticText(expected) + L" copied=" + diagnosticText(text) + L" result=" + diagnosticText(result), reason != L"paste-sent-unverified" && reason != L"layout-only" && reason != L"unchanged-expected"); } } audit{*this, target, version, attempt, requestedAt, manual, reason, text, result, expected, hint, to}; 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)); if (!autoCorrector.suggest(predicted, hint, available, config) || !autoCorrectionAllowed(target, config) || !valid()) return; } reason = L"clipboard-snapshot"; 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; Sleep(25); text = tx.copy(); // Some editors first select only ") ". Extend by whole-word // boundaries, then still verify against the complete tracked run. for (int i = 0; i < 3 && autoClosingSelection(text, config) && tx.valid(); ++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); 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; text = tx.copy(); if (tail.match(text, available, physicalLayout()) < 0) { if (tx.valid()) chord(VK_RIGHT); 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; }); 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; 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 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; } int source = detect(text, available, hint); if (source < 0) source = hint; auto it = std::find(cycle.begin(), cycle.end(), source); to = it == cycle.end() || ++it == cycle.end() ? cycle.front() : *it; } result = convertTo(text, available, to, hint); } } bool sent = false; 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); reason = result == text ? L"conversion-unchanged" : sent ? L"paste-sent-unverified" : L"paste-cancelled"; if (result == text && (std::none_of(text.begin(), text.end(), letter) || (command >= Direct && detect(text, available, hint) == to))) reason = L"unchanged-expected"; 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() && tx.select(caretSteps(t.suffix))) { auto suffix = tx.copy(); if (tx.valid()) chord(newlines(suffix) == newlines(t.suffix) ? VK_LEFT : VK_RIGHT); } } 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 &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); 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 &b; ~Reset() { b = false; } } reset{busy}; try { auto text = recognizeText(history->load(id)); 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(); try { *result = runUpdate(install, version); } 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(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 result(reinterpret_cast(lp)); if (app->updateWorker.joinable()) app->updateWorker.join(); app->updateRunning = false; app->installingUpdate = false; 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 { NOTIFYICONDATAW icon{sizeof(icon)}; icon.hWnd = hwnd; icon.uID = 1; icon.uFlags = NIF_INFO; icon.dwInfoFlags = NIIF_INFO; wcscpy_s(icon.szInfoTitle, L"KSwitcher update available"); auto text = result->version + L" is available. Select Update in the tray menu to install."; wcsncpy_s(icon.szInfo, text.c_str(), _TRUNCATE); Shell_NotifyIconW(NIM_MODIFY, &icon); } } 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"Startup update check failed: " + result->error); return 0; } case WM_HOTKEY: app->dispatch((int)wp); 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->installingUpdate) return 0; app->pendingAutoVersion = (uint64_t)wp; SetTimer(hwnd, 3, 250, nullptr); return 0; case SnippetMessage: if (app->installingUpdate) return 0; app->transform(0, (int)wp); return 0; case HistoryMessage: if (app->historyWindow && IsWindowVisible(app->historyWindow)) refreshHistory(*app); return 0; case ErrorMessage: { std::unique_ptr 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 < ConfigReply) { 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) { KillTimer(hwnd, 5); 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 == 3) { KillTimer(hwnd, 3); if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion) app->transform(AutoCorrect); 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 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); 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); if (startCommand) PostMessageW(window, WM_HOTKEY, startCommand, 0); 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); } return (int)msg.wParam; } } // namespace ks