Release v0.2.1: support remote input, safe updates and high-DPI windows

This commit is contained in:
2026-10-28 11:02:15 +02:00
parent cf18a0a3c5
commit 1093bb5b2e
19 changed files with 994 additions and 112 deletions
+144 -58
View File
@@ -256,6 +256,8 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot) : instan
}
App::~App()
{
if (updateWorker.joinable())
updateWorker.join();
if (keyboardHook)
UnhookWindowsHookEx(keyboardHook);
if (mouseHook)
@@ -413,23 +415,15 @@ void App::resetInput()
run.reset();
last.reset();
}
void App::keyboard(const KBDLLHOOKSTRUCT &k)
void App::keyboard(const KBDLLHOOKSTRUCT &k, UINT mods)
{
if (k.flags & LLKHF_INJECTED)
if (ownKeyboardInput(k))
return;
DWORD vk = k.vkCode;
if (vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LCONTROL || vk == VK_RCONTROL || vk == VK_LMENU ||
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;
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;
std::lock_guard lock(stateMutex);
if (isHotkey(vk, mods))
return;
@@ -448,7 +442,11 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k)
return;
}
bool shift = (mods & MOD_SHIFT) != 0;
wchar_t ch = vk == VK_SPACE ? L' ' : physicalKey(k.scanCode, shift);
// 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)
@@ -492,85 +490,83 @@ void App::keyboard(const KBDLLHOOKSTRUCT &k)
}
}
}
LRESULT CALLBACK App::keyProc(int code, WPARAM wp, LPARAM lp)
bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
{
if (code == HC_ACTION && (((KBDLLHOOKSTRUCT *)lp)->flags & LLKHF_INJECTED) &&
((KBDLLHOOKSTRUCT *)lp)->vkCode == 0xE8)
return CallNextHookEx(nullptr, code, wp, lp); // Our shell-mask key is never recorded.
if (code == HC_ACTION && current && current->hotkeyCapture &&
GetForegroundWindow() == GetAncestor(current->hotkeyCapture, GA_ROOT))
// 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;
if (hotkeyCapture && GetForegroundWindow() == GetAncestor(hotkeyCapture, GA_ROOT))
{
auto &key = *(KBDLLHOOKSTRUCT *)lp;
bool modifier = key.vkCode == VK_LCONTROL || key.vkCode == VK_RCONTROL || key.vkCode == VK_LSHIFT ||
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 (wp == WM_KEYUP || wp == WM_SYSKEYUP)
current->hotkeyHook.update(key.vkCode, false, 0, {});
if (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN)
if (!down)
hotkeyHook.update(key.vkCode, false, 0, {});
if (down)
{
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 (mods & MOD_WIN)
chord(0xE8);
PostMessageW(current->hotkeyCapture, CaptureHotkeyMessage, key.vkCode, mods);
PostMessageW(hotkeyCapture, CaptureHotkeyMessage, key.vkCode, mods);
}
return 1; // Record the chord without executing a global shortcut or typing into an editor.
}
}
if (code == HC_ACTION && (((KBDLLHOOKSTRUCT *)lp)->flags & LLKHF_INJECTED))
return CallNextHookEx(nullptr, code, wp, lp);
if (code == HC_ACTION && (wp == WM_KEYDOWN || wp == WM_SYSKEYDOWN) && current)
if (down)
{
auto &key = *(KBDLLHOOKSTRUCT *)lp;
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;
auto action = current->hotkeyHook.update(key.vkCode, true, mods, current->hookBindings);
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(current->window, WM_HOTKEY, action, 0);
PostMessageW(window, WM_HOTKEY, action, 0);
}
if (action != 0)
return 1;
current->keyboard(*(KBDLLHOOKSTRUCT *)lp);
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;
}
if (code == HC_ACTION && (wp == WM_KEYUP || wp == WM_SYSKEYUP) && current &&
current->hotkeyHook.update(((KBDLLHOOKSTRUCT *)lp)->vkCode, false, 0, current->hookBindings) != 0)
return 1;
return CallNextHookEx(nullptr, code, wp, lp);
}
LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
{
if (code == HC_ACTION && current)
{
auto *e = (MSLLHOOKSTRUCT *)lp;
if (!(e->flags & LLMHF_INJECTED) &&
(wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL))
// 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 == ShowHistory)
@@ -602,6 +598,7 @@ void App::dispatch(int command)
}
void App::transform(int command, int snippet)
{
if (installingUpdate) return;
const auto attempt = ++transformAttempt;
const auto requestedAt = GetTickCount64();
HWND target = GetForegroundWindow();
@@ -957,6 +954,7 @@ void App::transform(int command, int snippet)
}
void App::pasteEntry(std::wstring id, bool plain)
{
if (installingUpdate) return;
if (!history || busy.exchange(true))
return;
HWND target = pasteTarget;
@@ -1095,6 +1093,9 @@ void App::trayMenu()
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");
@@ -1133,7 +1134,7 @@ void App::trayMenu()
break;
case 16:
MessageBoxW(window,
L"KSwitcher 0.2.0\nNative C++ / Win32 keyboard and clipboard utility\n\nBased on "
L"KSwitcher 0.2.1\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.",
L"About KSwitcher", MB_OK);
@@ -1144,6 +1145,9 @@ void App::trayMenu()
case 18:
showLogs(*this);
break;
case 19:
checkUpdate(true);
break;
case 20:
pauseUntil = 0;
break;
@@ -1163,6 +1167,27 @@ void App::trayMenu()
report(wide(e.what()));
}
}
void App::checkUpdate(bool interactive, bool install, const std::wstring &version)
{
if (updateRunning) 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); }
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;
@@ -1175,6 +1200,50 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
}
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;
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;
@@ -1192,10 +1261,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
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:
@@ -1227,6 +1298,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
return TRUE;
}
case WM_TIMER:
if (wp == 5)
{
KillTimer(hwnd, 5);
app->checkUpdate(false);
return 0;
}
if (wp == 4)
{
if (GetAsyncKeyState(VK_LBUTTON) & 0x8000)
@@ -1238,7 +1315,7 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
if (wp == 3)
{
KillTimer(hwnd, 3);
if (app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
if (!app->installingUpdate && app->settings.autoCorrect && app->inputVersion.load() == app->pendingAutoVersion)
app->transform(AutoCorrect);
return 0;
}
@@ -1255,6 +1332,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
}
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.");
@@ -1324,6 +1406,10 @@ int App::execute()
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;