From cf623b442083d1534b3b736def52b2bea042aa17 Mon Sep 17 00:00:00 2001 From: Stefan Verleysen Date: Wed, 11 Mar 2026 16:36:03 -0400 Subject: [PATCH 1/5] feat: add server-side key anchoring Per-screen anchoredKeys option keeps specified keys and combos local on the server instead of relaying them to clients. Supports single keys (F13, Delete), modifier combos (Alt+Tab, Ctrl+A), and arbitrary VK codes via a 256-bit bitmask. Combos use a pending-modifier mechanism that holds modifier keys until the next keypress determines whether to anchor locally or flush to the client. Co-Authored-By: Claude Opus 4.6 --- src/gui/src/KeySequence.h | 3 +- src/gui/src/ScreenSettingsDialog.cpp | 4 + src/gui/src/ScreenSettingsDialogBase.ui | 65 +++++ src/lib/deskflow/option_types.h | 26 ++ src/lib/gui/config/Screen.cpp | 13 +- src/lib/gui/config/Screen.h | 13 +- src/lib/platform/MSWindowsHook.cpp | 228 +++++++++++++++- src/lib/platform/MSWindowsHook.h | 4 + src/lib/platform/MSWindowsScreen.cpp | 52 +++- src/lib/server/Config.cpp | 329 ++++++++++++++++++++++++ 10 files changed, 730 insertions(+), 7 deletions(-) diff --git a/src/gui/src/KeySequence.h b/src/gui/src/KeySequence.h index f2d721605..6898af23c 100644 --- a/src/gui/src/KeySequence.h +++ b/src/gui/src/KeySequence.h @@ -53,6 +53,7 @@ class KeySequence bool operator==(const KeySequence &ks) const; private: + static QString keyToString(int key); void setValid(bool b) { m_IsValid = b; @@ -74,8 +75,6 @@ class KeySequence inline static const int kStrSize = 4; inline static const int kBase = 16; inline static const QChar kFillChar = QChar('0'); - - static QString keyToString(int key); }; #endif diff --git a/src/gui/src/ScreenSettingsDialog.cpp b/src/gui/src/ScreenSettingsDialog.cpp index 2bd7e9593..c07a528d8 100644 --- a/src/gui/src/ScreenSettingsDialog.cpp +++ b/src/gui/src/ScreenSettingsDialog.cpp @@ -73,6 +73,8 @@ ScreenSettingsDialog::ScreenSettingsDialog(QWidget *parent, Screen *pScreen, con m_pCheckBoxNumLock->setChecked(m_pScreen->fix(NumLock)); m_pCheckBoxScrollLock->setChecked(m_pScreen->fix(ScrollLock)); m_pCheckBoxXTest->setChecked(m_pScreen->fix(XTest)); + + m_pLineEditAnchoredKeys->setText(m_pScreen->anchoredKeys()); } void ScreenSettingsDialog::accept() @@ -122,6 +124,8 @@ void ScreenSettingsDialog::accept() m_pScreen->setFix(static_cast(ScrollLock), m_pCheckBoxScrollLock->isChecked()); m_pScreen->setFix(static_cast(XTest), m_pCheckBoxXTest->isChecked()); + m_pScreen->setAnchoredKeys(m_pLineEditAnchoredKeys->text()); + QDialog::accept(); } diff --git a/src/gui/src/ScreenSettingsDialogBase.ui b/src/gui/src/ScreenSettingsDialogBase.ui index 4262e3fdc..d7a0cc5da 100644 --- a/src/gui/src/ScreenSettingsDialogBase.ui +++ b/src/gui/src/ScreenSettingsDialogBase.ui @@ -711,6 +711,71 @@ + + + + + 0 + 0 + + + + Anchored keys + + + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + 10 + + + 10 + + + + + e.g. F13, Ctrl+A, Alt+Tab, LeftCtrl + + + + + + + Keys that stay on this computer when focus is elsewhere. +Comma-separated. Use + for combos (e.g. Alt+Tab, Ctrl+A). + + + true + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 3 + + + + diff --git a/src/lib/deskflow/option_types.h b/src/lib/deskflow/option_types.h index 99db87a47..5e075def5 100644 --- a/src/lib/deskflow/option_types.h +++ b/src/lib/deskflow/option_types.h @@ -69,6 +69,32 @@ static const OptionID kOptionWin32KeepForeground = OPTION_CODE("_KFW"); static const OptionID kOptionDisableLockToScreen = OPTION_CODE("DLTS"); static const OptionID kOptionClipboardSharing = OPTION_CODE("CLPS"); static const OptionID kOptionClipboardSharingSize = OPTION_CODE("CLSZ"); +static const OptionID kOptionAnchoredKeys = OPTION_CODE("ANKK"); +static const OptionID kOptionAnchoredKeys0 = OPTION_CODE("AK_0"); +static const OptionID kOptionAnchoredKeys1 = OPTION_CODE("AK_1"); +static const OptionID kOptionAnchoredKeys2 = OPTION_CODE("AK_2"); +static const OptionID kOptionAnchoredKeys3 = OPTION_CODE("AK_3"); +static const OptionID kOptionAnchoredKeys4 = OPTION_CODE("AK_4"); +static const OptionID kOptionAnchoredKeys5 = OPTION_CODE("AK_5"); +static const OptionID kOptionAnchoredKeys6 = OPTION_CODE("AK_6"); +static const OptionID kOptionAnchoredKeys7 = OPTION_CODE("AK_7"); +static const OptionID kOptionAnchoredCombos0 = OPTION_CODE("AC_0"); +static const OptionID kOptionAnchoredCombos1 = OPTION_CODE("AC_1"); +static const OptionID kOptionAnchoredCombos2 = OPTION_CODE("AC_2"); +static const OptionID kOptionAnchoredCombos3 = OPTION_CODE("AC_3"); +static const OptionID kOptionAnchoredCombos4 = OPTION_CODE("AC_4"); +static const OptionID kOptionAnchoredCombos5 = OPTION_CODE("AC_5"); +static const OptionID kOptionAnchoredCombos6 = OPTION_CODE("AC_6"); +static const OptionID kOptionAnchoredCombos7 = OPTION_CODE("AC_7"); +static const OptionID kOptionAnchoredCombos8 = OPTION_CODE("AC_8"); +static const OptionID kOptionAnchoredCombos9 = OPTION_CODE("AC_9"); +static const OptionID kOptionAnchoredCombos10 = OPTION_CODE("AC10"); +static const OptionID kOptionAnchoredCombos11 = OPTION_CODE("AC11"); +static const OptionID kOptionAnchoredCombos12 = OPTION_CODE("AC12"); +static const OptionID kOptionAnchoredCombos13 = OPTION_CODE("AC13"); +static const OptionID kOptionAnchoredCombos14 = OPTION_CODE("AC14"); +static const OptionID kOptionAnchoredCombos15 = OPTION_CODE("AC15"); +static const OptionID kOptionAnchoredCombosCount = OPTION_CODE("ACCN"); //@} //! @name Screen switch corner enumeration diff --git a/src/lib/gui/config/Screen.cpp b/src/lib/gui/config/Screen.cpp index f19899064..0ece35c36 100644 --- a/src/lib/gui/config/Screen.cpp +++ b/src/lib/gui/config/Screen.cpp @@ -58,6 +58,8 @@ void Screen::init() for (int i = 0; i < static_cast(NumFixes); i++) fixes() << false; + + setAnchoredKeys(QString()); } void Screen::loadSettings(QSettingsProxy &settings) @@ -73,6 +75,8 @@ void Screen::loadSettings(QSettingsProxy &settings) readSettings(settings, modifiers(), "modifier", static_cast(DefaultMod), static_cast(NumModifiers)); readSettings(settings, switchCorners(), "switchCorner", 0, static_cast(NumSwitchCorners)); readSettings(settings, fixes(), "fix", 0, static_cast(NumFixes)); + + setAnchoredKeys(settings.value("anchoredKeys").toString()); } void Screen::saveSettings(QSettingsProxy &settings) const @@ -88,6 +92,8 @@ void Screen::saveSettings(QSettingsProxy &settings) const writeSettings(settings, modifiers(), "modifier"); writeSettings(settings, switchCorners(), "switchCorner"); writeSettings(settings, fixes(), "fix"); + + settings.setValue("anchoredKeys", anchoredKeys()); } QTextStream &Screen::writeScreensSection(QTextStream &outStream) const @@ -111,6 +117,10 @@ QTextStream &Screen::writeScreensSection(QTextStream &outStream) const outStream << "\t\t" << "switchCornerSize = " << switchCornerSize() << Qt::endl; + if (!anchoredKeys().isEmpty()) + outStream << "\t\t" + << "anchoredKeys = " << anchoredKeys() << Qt::endl; + return outStream; } @@ -130,5 +140,6 @@ bool Screen::operator==(const Screen &screen) const { return m_Name == screen.m_Name && m_Aliases == screen.m_Aliases && m_Modifiers == screen.m_Modifiers && m_SwitchCorners == screen.m_SwitchCorners && m_SwitchCornerSize == screen.m_SwitchCornerSize && - m_Fixes == screen.m_Fixes && m_Swapped == screen.m_Swapped && m_isServer == screen.m_isServer; + m_Fixes == screen.m_Fixes && m_AnchoredKeys == screen.m_AnchoredKeys && m_Swapped == screen.m_Swapped && + m_isServer == screen.m_isServer; } diff --git a/src/lib/gui/config/Screen.h b/src/lib/gui/config/Screen.h index f5f7cf4a9..604298d6b 100644 --- a/src/lib/gui/config/Screen.h +++ b/src/lib/gui/config/Screen.h @@ -42,13 +42,13 @@ class Screen : public ScreenConfig friend QDataStream &operator<<(QDataStream &outStream, const Screen &screen) { return outStream << screen.name() << screen.switchCornerSize() << screen.aliases() << screen.modifiers() - << screen.switchCorners() << screen.fixes() << screen.isServer(); + << screen.switchCorners() << screen.fixes() << screen.isServer() << screen.anchoredKeys(); } friend QDataStream &operator>>(QDataStream &inStream, Screen &screen) { return inStream >> screen.m_Name >> screen.m_SwitchCornerSize >> screen.m_Aliases >> screen.m_Modifiers >> - screen.m_SwitchCorners >> screen.m_Fixes >> screen.m_isServer; + screen.m_SwitchCorners >> screen.m_Fixes >> screen.m_isServer >> screen.m_AnchoredKeys; } public: @@ -106,6 +106,10 @@ class Screen : public ScreenConfig QTextStream &writeScreensSection(QTextStream &outStream) const; QTextStream &writeAliasesSection(QTextStream &outStream) const; + const QString &anchoredKeys() const + { + return m_AnchoredKeys; + } bool swapped() const { return m_Swapped; @@ -172,6 +176,10 @@ class Screen : public ScreenConfig { m_Swapped = on; } + void setAnchoredKeys(const QString &keys) + { + m_AnchoredKeys = keys; + } private: QPixmap m_Pixmap = QPixmap(":res/icons/64x64/video-display.png"); @@ -181,6 +189,7 @@ class Screen : public ScreenConfig QList m_SwitchCorners; int m_SwitchCornerSize; QList m_Fixes; + QString m_AnchoredKeys; bool m_Swapped = false; bool m_isServer = false; }; diff --git a/src/lib/platform/MSWindowsHook.cpp b/src/lib/platform/MSWindowsHook.cpp index b51b1a734..ba451d010 100644 --- a/src/lib/platform/MSWindowsHook.cpp +++ b/src/lib/platform/MSWindowsHook.cpp @@ -21,6 +21,8 @@ #include "deskflow/XScreen.h" #include "deskflow/protocol_types.h" +#include + static const char *g_name = "dfwhook"; static DWORD g_processID = 0; @@ -44,6 +46,55 @@ static BYTE g_keyState[256] = {0}; static DWORD g_hookThread = 0; static bool g_fakeServerInput = false; static BOOL g_isPrimary = TRUE; +static UInt32 g_anchoredKeysMask[8] = {}; + +struct AnchoredCombo +{ + UInt8 modifiers; + UInt8 vk; +}; +static const int kMaxAnchoredCombos = 32; +static AnchoredCombo g_anchoredCombos[kMaxAnchoredCombos] = {}; +static int g_anchoredComboCount = 0; + +static const UInt8 kModShift = 0x01; +static const UInt8 kModCtrl = 0x02; +static const UInt8 kModAlt = 0x04; +static const UInt8 kModWin = 0x08; + +struct PendingMod +{ + DWORD vk; + LPARAM lParam; + UInt8 modBit; +}; +static PendingMod g_pendingMods[4] = {}; +static int g_pendingModCount = 0; +static UInt8 g_pendingModBits = 0; +static UInt8 g_comboFiredModBits = 0; + +static UInt8 modBitForVk(DWORD vk) +{ + switch (vk) { + case VK_SHIFT: + case VK_LSHIFT: + case VK_RSHIFT: + return kModShift; + case VK_CONTROL: + case VK_LCONTROL: + case VK_RCONTROL: + return kModCtrl; + case VK_MENU: + case VK_LMENU: + case VK_RMENU: + return kModAlt; + case VK_LWIN: + case VK_RWIN: + return kModWin; + default: + return 0; + } +} MSWindowsHook::MSWindowsHook() { @@ -148,6 +199,32 @@ void MSWindowsHook::setMode(EHookMode mode) g_mode = mode; } +void MSWindowsHook::setAnchoredKeys(const UInt32 mask[8]) +{ + memcpy(g_anchoredKeysMask, mask, sizeof(g_anchoredKeysMask)); +} + +void MSWindowsHook::setAnchoredCombos(const UInt8 *data, int count) +{ + g_anchoredComboCount = (count > kMaxAnchoredCombos) ? kMaxAnchoredCombos : count; + for (int i = 0; i < g_anchoredComboCount; ++i) { + g_anchoredCombos[i].modifiers = data[i * 2]; + g_anchoredCombos[i].vk = data[i * 2 + 1]; + } +} + +void MSWindowsHook::setAnchoredKeysFKeys(UInt32 fKeyBitmask) +{ + UInt32 mask[8] = {}; + for (int i = 0; i < 24; ++i) { + if (fKeyBitmask & (1u << i)) { + int vk = VK_F1 + i; + mask[vk / 32] |= (1u << (vk % 32)); + } + } + setAnchoredKeys(mask); +} + static void keyboardGetState(BYTE keys[256], DWORD vkCode, bool kf_up) { // we have to use GetAsyncKeyState() rather than GetKeyState() because @@ -192,6 +269,50 @@ static WPARAM makeKeyMsg(UINT virtKey, WCHAR wc, bool noAltGr) return MAKEWPARAM((WORD)wc, MAKEWORD(virtKey & 0xff, noAltGr ? 1 : 0)); } +static void flushPendingMods() +{ + for (int i = 0; i < g_pendingModCount; i++) { + WPARAM charAndVirtKey = makeKeyMsg((UINT)g_pendingMods[i].vk, 0, false); + PostThreadMessage(g_threadID, DESKFLOW_MSG_KEY, charAndVirtKey, g_pendingMods[i].lParam); + } + g_pendingModCount = 0; + g_pendingModBits = 0; +} + +static void injectComboLocally(int triggerVk, WORD triggerScanCode) +{ + INPUT inputs[8] = {}; + int idx = 0; + + inputs[idx].type = INPUT_KEYBOARD; + inputs[idx].ki.wVk = VK_CANCEL; + inputs[idx].ki.wScan = 0; + inputs[idx].ki.dwFlags = 0; + idx++; + + for (int i = 0; i < g_pendingModCount; i++) { + inputs[idx].type = INPUT_KEYBOARD; + inputs[idx].ki.wVk = (WORD)g_pendingMods[i].vk; + inputs[idx].ki.wScan = (WORD)MapVirtualKey(g_pendingMods[i].vk, MAPVK_VK_TO_VSC); + inputs[idx].ki.dwFlags = 0; + idx++; + } + + inputs[idx].type = INPUT_KEYBOARD; + inputs[idx].ki.wVk = (WORD)triggerVk; + inputs[idx].ki.wScan = triggerScanCode; + inputs[idx].ki.dwFlags = 0; + idx++; + + inputs[idx].type = INPUT_KEYBOARD; + inputs[idx].ki.wVk = VK_CANCEL; + inputs[idx].ki.wScan = 0; + inputs[idx].ki.dwFlags = KEYEVENTF_KEYUP; + idx++; + + SendInput(idx, inputs, sizeof(INPUT)); +} + static void setDeadKey(WCHAR wc[], int size, UINT flags) { if (g_deadVirtKey != 0) { @@ -237,6 +358,110 @@ static bool keyboardHookHandler(WPARAM wParam, LPARAM lParam) return false; } + if (g_mode == kHOOK_RELAY_EVENTS) { + UInt32 vk = static_cast(vkCode); + + if (vk < 256 && (g_anchoredKeysMask[vk / 32] & (1u << (vk % 32))) != 0) { + if ((lParam & 0x80000000u) == 0 && (lParam & 0x40000000u) == 0) + PostThreadMessage(g_threadID, DESKFLOW_MSG_DEBUG, 0xAC000000u | vk, lParam); + return false; + } + + if (g_anchoredComboCount > 0 && vk < 256) { + bool isKeyDown = (lParam & 0x80000000u) == 0; + UInt8 modBit = modBitForVk(vk); + + // Modifier UP after combo fired: pass through locally, don't relay + if (!isKeyDown && modBit != 0 && (g_comboFiredModBits & modBit)) { + g_comboFiredModBits &= ~modBit; + return false; + } + + // While combo is active, repeat combo trigger stays local + if (isKeyDown && modBit == 0 && g_comboFiredModBits != 0) { + for (int i = 0; i < g_anchoredComboCount; ++i) { + if (g_anchoredCombos[i].vk == vk && g_anchoredCombos[i].modifiers == g_comboFiredModBits) { + WORD sc = (WORD)((lParam >> 16) & 0x1FF); + INPUT inputs[3] = {}; + inputs[0].type = INPUT_KEYBOARD; + inputs[0].ki.wVk = VK_CANCEL; + inputs[0].ki.dwFlags = 0; + inputs[1].type = INPUT_KEYBOARD; + inputs[1].ki.wVk = (WORD)vk; + inputs[1].ki.wScan = sc; + inputs[1].ki.dwFlags = 0; + inputs[2].type = INPUT_KEYBOARD; + inputs[2].ki.wVk = VK_CANCEL; + inputs[2].ki.dwFlags = KEYEVENTF_KEYUP; + SendInput(3, inputs, sizeof(INPUT)); + PostThreadMessage(g_threadID, DESKFLOW_MSG_DEBUG, 0xAC000000u | vk | (g_comboFiredModBits << 8), lParam); + return true; + } + } + } + + // While combo is active, trigger UP stays local + if (!isKeyDown && modBit == 0 && g_comboFiredModBits != 0) { + for (int i = 0; i < g_anchoredComboCount; ++i) { + if (g_anchoredCombos[i].vk == vk) + return false; + } + } + + // While combo is active, non-combo keys also stay local + if (g_comboFiredModBits != 0) + return false; + + // Modifier DOWN: hold it if used in any anchored combo + if (isKeyDown && modBit != 0) { + if (g_pendingModBits & modBit) + return true; // already pending (repeat), eat it + + bool usedInCombo = false; + for (int i = 0; i < g_anchoredComboCount; ++i) { + if (g_anchoredCombos[i].modifiers & modBit) { + usedInCombo = true; + break; + } + } + if (usedInCombo && g_pendingModCount < 4) { + g_pendingMods[g_pendingModCount].vk = vk; + g_pendingMods[g_pendingModCount].lParam = lParam; + g_pendingMods[g_pendingModCount].modBit = modBit; + g_pendingModCount++; + g_pendingModBits |= modBit; + return true; // eat it, don't relay yet + } + } + + // Modifier UP while pending: no combo arrived, flush to client + if (!isKeyDown && modBit != 0 && (g_pendingModBits & modBit)) { + flushPendingMods(); + // fall through to relay this UP normally + } + + // Non-modifier key while mods are pending + if (modBit == 0 && g_pendingModBits != 0) { + if (isKeyDown) { + for (int i = 0; i < g_anchoredComboCount; ++i) { + if (g_anchoredCombos[i].vk == vk && g_anchoredCombos[i].modifiers == g_pendingModBits) { + WORD sc = (WORD)((lParam >> 16) & 0x1FF); + injectComboLocally(vk, sc); + g_comboFiredModBits = g_pendingModBits; + g_pendingModCount = 0; + g_pendingModBits = 0; + PostThreadMessage(g_threadID, DESKFLOW_MSG_DEBUG, 0xAC000000u | vk | (g_comboFiredModBits << 8), lParam); + return true; // eat the real trigger + } + } + } + // No combo match, flush pending mods to client + flushPendingMods(); + // fall through to relay this key normally + } + } + } + // VK_RSHIFT may be sent with an extended scan code but right shift // is not an extended key so we reset that bit. if (wParam == VK_RSHIFT) { @@ -631,7 +856,8 @@ EHookResult MSWindowsHook::install() #endif // check that we got all the hooks we wanted - if ((g_mouseLL == NULL) || + if ( + (g_mouseLL == NULL) || #if !NO_GRAB_KEYBOARD (g_keyboardLL == NULL) #endif diff --git a/src/lib/platform/MSWindowsHook.h b/src/lib/platform/MSWindowsHook.h index 51684395f..13541c936 100644 --- a/src/lib/platform/MSWindowsHook.h +++ b/src/lib/platform/MSWindowsHook.h @@ -49,4 +49,8 @@ class MSWindowsHook static int installScreenSaver(); static int uninstallScreenSaver(); + + void setAnchoredKeys(const UInt32 mask[8]); + void setAnchoredCombos(const UInt8 *data, int count); + void setAnchoredKeysFKeys(UInt32 fKeyBitmask); }; diff --git a/src/lib/platform/MSWindowsScreen.cpp b/src/lib/platform/MSWindowsScreen.cpp index f585110cf..8be1067dd 100644 --- a/src/lib/platform/MSWindowsScreen.cpp +++ b/src/lib/platform/MSWindowsScreen.cpp @@ -476,6 +476,50 @@ void MSWindowsScreen::resetOptions() void MSWindowsScreen::setOptions(const OptionsList &options) { m_desks->setOptions(options); + + UInt32 anchoredMask[8] = {}; + bool hasAnchoredKeys = false; + bool hasLegacyAnchored = false; + UInt32 legacyFKeyMask = 0; + UInt8 comboData[64] = {}; + int comboCount = 0; + bool hasCombos = false; + + for (UInt32 i = 0, n = (UInt32)options.size(); i < n; i += 2) { + UInt32 akIndex = options[i] - kOptionAnchoredKeys0; + if (akIndex < 8) { + anchoredMask[akIndex] = static_cast(options[i + 1]); + hasAnchoredKeys = true; + } else if (options[i] == kOptionAnchoredKeys) { + legacyFKeyMask = static_cast(options[i + 1]); + hasLegacyAnchored = true; + } else if (options[i] == kOptionAnchoredCombosCount) { + comboCount = static_cast(options[i + 1]); + hasCombos = true; + } else { + UInt32 acIndex = options[i] - kOptionAnchoredCombos0; + if (acIndex < 16) { + UInt32 packed = static_cast(options[i + 1]); + comboData[acIndex * 4 + 0] = (packed >> 24) & 0xFF; + comboData[acIndex * 4 + 1] = (packed >> 16) & 0xFF; + comboData[acIndex * 4 + 2] = (packed >> 8) & 0xFF; + comboData[acIndex * 4 + 3] = packed & 0xFF; + } + } + } + + if (hasAnchoredKeys) { + m_hook.setAnchoredKeys(anchoredMask); + LOG((CLOG_DEBUG "anchored keys mask set (256-bit)")); + } else if (hasLegacyAnchored) { + m_hook.setAnchoredKeysFKeys(legacyFKeyMask); + LOG((CLOG_DEBUG "anchored keys mask set (legacy F-key)")); + } + + if (hasCombos && comboCount > 0) { + m_hook.setAnchoredCombos(comboData, comboCount); + LOG((CLOG_DEBUG "anchored combos set (%d)", comboCount)); + } } void MSWindowsScreen::setSequenceNumber(UInt32 seqNum) @@ -955,7 +999,13 @@ bool MSWindowsScreen::onPreDispatch(HWND hwnd, UINT message, WPARAM wParam, LPAR return onScreensaver(wParam != 0); case DESKFLOW_MSG_DEBUG: - LOG((CLOG_DEBUG1 "hook: 0x%08x 0x%08x", wParam, lParam)); + if ((wParam & 0xFF000000u) == 0xAC000000u) { + UInt32 vk = wParam & 0xFF; + UInt32 mods = (wParam >> 8) & 0xFF; + LOG((CLOG_INFO "anchored key vk=0x%02x mods=0x%02x", vk, mods)); + } else { + LOG((CLOG_DEBUG1 "hook: 0x%08x 0x%08x", wParam, lParam)); + } return true; } diff --git a/src/lib/server/Config.cpp b/src/lib/server/Config.cpp index aeeef31b4..907f915cc 100644 --- a/src/lib/server/Config.cpp +++ b/src/lib/server/Config.cpp @@ -30,6 +30,205 @@ using namespace deskflow::string; +namespace { + +struct VkNameEntry +{ + const char *name; + int vkCode; +}; + +static const VkNameEntry s_vkNames[] = { + {"A", 0x41}, + {"B", 0x42}, + {"C", 0x43}, + {"D", 0x44}, + {"E", 0x45}, + {"F", 0x46}, + {"G", 0x47}, + {"H", 0x48}, + {"I", 0x49}, + {"J", 0x4A}, + {"K", 0x4B}, + {"L", 0x4C}, + {"M", 0x4D}, + {"N", 0x4E}, + {"O", 0x4F}, + {"P", 0x50}, + {"Q", 0x51}, + {"R", 0x52}, + {"S", 0x53}, + {"T", 0x54}, + {"U", 0x55}, + {"V", 0x56}, + {"W", 0x57}, + {"X", 0x58}, + {"Y", 0x59}, + {"Z", 0x5A}, + {"0", 0x30}, + {"1", 0x31}, + {"2", 0x32}, + {"3", 0x33}, + {"4", 0x34}, + {"5", 0x35}, + {"6", 0x36}, + {"7", 0x37}, + {"8", 0x38}, + {"9", 0x39}, + {"F1", 0x70}, + {"F2", 0x71}, + {"F3", 0x72}, + {"F4", 0x73}, + {"F5", 0x74}, + {"F6", 0x75}, + {"F7", 0x76}, + {"F8", 0x77}, + {"F9", 0x78}, + {"F10", 0x79}, + {"F11", 0x7A}, + {"F12", 0x7B}, + {"F13", 0x7C}, + {"F14", 0x7D}, + {"F15", 0x7E}, + {"F16", 0x7F}, + {"F17", 0x80}, + {"F18", 0x81}, + {"F19", 0x82}, + {"F20", 0x83}, + {"F21", 0x84}, + {"F22", 0x85}, + {"F23", 0x86}, + {"F24", 0x87}, + {"Insert", 0x2D}, + {"Delete", 0x2E}, + {"Home", 0x24}, + {"End", 0x23}, + {"PageUp", 0x21}, + {"PageDown", 0x22}, + {"Up", 0x26}, + {"Down", 0x28}, + {"Left", 0x25}, + {"Right", 0x27}, + {"NumPad0", 0x60}, + {"NumPad1", 0x61}, + {"NumPad2", 0x62}, + {"NumPad3", 0x63}, + {"NumPad4", 0x64}, + {"NumPad5", 0x65}, + {"NumPad6", 0x66}, + {"NumPad7", 0x67}, + {"NumPad8", 0x68}, + {"NumPad9", 0x69}, + {"NumPadMultiply", 0x6A}, + {"NumPadAdd", 0x6B}, + {"NumPadSubtract", 0x6D}, + {"NumPadDecimal", 0x6E}, + {"NumPadDivide", 0x6F}, + {"Space", 0x20}, + {"Enter", 0x0D}, + {"Tab", 0x09}, + {"Backspace", 0x08}, + {"Escape", 0x1B}, + {"PrintScreen", 0x2C}, + {"Pause", 0x13}, + {"Apps", 0x5D}, + {"Shift", 0x10}, + {"Control", 0x11}, + {"Alt", 0x12}, + {"LeftShift", 0xA0}, + {"RightShift", 0xA1}, + {"LeftCtrl", 0xA2}, + {"RightCtrl", 0xA3}, + {"LeftAlt", 0xA4}, + {"RightAlt", 0xA5}, + {"LeftWin", 0x5B}, + {"RightWin", 0x5C}, + {"Win", 0x5B}, + {"CapsLock", 0x14}, + {"NumLock", 0x90}, + {"ScrollLock", 0x91}, + {"BrowserBack", 0xA6}, + {"BrowserForward", 0xA7}, + {"BrowserRefresh", 0xA8}, + {"BrowserStop", 0xA9}, + {"BrowserSearch", 0xAA}, + {"BrowserFavorites", 0xAB}, + {"BrowserHome", 0xAC}, + {"VolumeMute", 0xAD}, + {"VolumeDown", 0xAE}, + {"VolumeUp", 0xAF}, + {"MediaNext", 0xB0}, + {"MediaPrev", 0xB1}, + {"MediaStop", 0xB2}, + {"MediaPlay", 0xB3}, +}; + +static int lookupVkByName(const String &name) +{ + for (const auto &entry : s_vkNames) { + if (CaselessCmp::equal(name, String(entry.name))) + return entry.vkCode; + } + return -1; +} + +static const char *lookupNameByVk(int vk) +{ + for (const auto &entry : s_vkNames) { + if (entry.vkCode == vk) + return entry.name; + } + return nullptr; +} + +static const OptionID kAnchoredKeysOptions[8] = { + kOptionAnchoredKeys0, kOptionAnchoredKeys1, kOptionAnchoredKeys2, kOptionAnchoredKeys3, + kOptionAnchoredKeys4, kOptionAnchoredKeys5, kOptionAnchoredKeys6, kOptionAnchoredKeys7, +}; + +static const OptionID kAnchoredCombosOptions[16] = { + kOptionAnchoredCombos0, kOptionAnchoredCombos1, kOptionAnchoredCombos2, kOptionAnchoredCombos3, + kOptionAnchoredCombos4, kOptionAnchoredCombos5, kOptionAnchoredCombos6, kOptionAnchoredCombos7, + kOptionAnchoredCombos8, kOptionAnchoredCombos9, kOptionAnchoredCombos10, kOptionAnchoredCombos11, + kOptionAnchoredCombos12, kOptionAnchoredCombos13, kOptionAnchoredCombos14, kOptionAnchoredCombos15, +}; + +static const UInt8 kComboModShift = 0x01; +static const UInt8 kComboModCtrl = 0x02; +static const UInt8 kComboModAlt = 0x04; +static const UInt8 kComboModWin = 0x08; + +static UInt8 modBitsFromName(const String &name) +{ + if (CaselessCmp::equal(name, String("Shift"))) + return kComboModShift; + if (CaselessCmp::equal(name, String("Ctrl"))) + return kComboModCtrl; + if (CaselessCmp::equal(name, String("Control"))) + return kComboModCtrl; + if (CaselessCmp::equal(name, String("Alt"))) + return kComboModAlt; + if (CaselessCmp::equal(name, String("Win"))) + return kComboModWin; + return 0; +} + +static String modBitsToString(UInt8 mods) +{ + String result; + if (mods & kComboModShift) + result += "Shift+"; + if (mods & kComboModCtrl) + result += "Ctrl+"; + if (mods & kComboModAlt) + result += "Alt+"; + if (mods & kComboModWin) + result += "Win+"; + return result; +} + +} // namespace + namespace deskflow::server { // @@ -824,6 +1023,87 @@ void Config::readSectionScreens(ConfigReadContext &s) addOption(screen, kOptionScreenSwitchCornerSize, s.parseInt(value)); } else if (name == "preserveFocus") { addOption(screen, kOptionScreenPreserveFocus, s.parseBoolean(value)); + } else if (name == "anchoredKeys") { + UInt32 mask[8] = {0}; + UInt8 comboPairs[64] = {}; + int comboCount = 0; + + String::size_type pos = 0; + while (pos < value.size()) { + String::size_type comma = value.find(',', pos); + if (comma == String::npos) + comma = value.size(); + String token = value.substr(pos, comma - pos); + String::size_type start = token.find_first_not_of(" \t"); + String::size_type end = token.find_last_not_of(" \t"); + if (start != String::npos) { + token = token.substr(start, end - start + 1); + + UInt8 mods = 0; + String keyPart = token; + String::size_type plus = token.find('+'); + while (plus != String::npos) { + String mod = token.substr(0, plus); + UInt8 m = modBitsFromName(mod); + if (m == 0) + break; + mods |= m; + token = token.substr(plus + 1); + plus = token.find('+'); + } + keyPart = token; + + int vk = lookupVkByName(keyPart); + if (vk < 0) + throw XConfigRead(s, "anchoredKeys: unknown key name \"%{1}\"", keyPart); + + if (mods != 0) { + if (comboCount < 32) { + comboPairs[comboCount * 2] = mods; + comboPairs[comboCount * 2 + 1] = static_cast(vk); + ++comboCount; + } + } else { + mask[vk / 32] |= (1u << (vk % 32)); + if (vk == 0x10) { + mask[0xA0 / 32] |= (1u << (0xA0 % 32)); + mask[0xA1 / 32] |= (1u << (0xA1 % 32)); + } + if (vk == 0x11) { + mask[0xA2 / 32] |= (1u << (0xA2 % 32)); + mask[0xA3 / 32] |= (1u << (0xA3 % 32)); + } + if (vk == 0x12) { + mask[0xA4 / 32] |= (1u << (0xA4 % 32)); + mask[0xA5 / 32] |= (1u << (0xA5 % 32)); + } + } + } + pos = comma + 1; + } + + for (int i = 0; i < 8; ++i) + addOption(screen, kAnchoredKeysOptions[i], static_cast(mask[i])); + + OptionValue fKeyMask = 0; + for (int i = 0; i < 24; ++i) { + int vk = 0x70 + i; + if (mask[vk / 32] & (1u << (vk % 32))) + fKeyMask |= (1 << i); + } + addOption(screen, kOptionAnchoredKeys, fKeyMask); + + if (comboCount > 0) { + addOption(screen, kOptionAnchoredCombosCount, static_cast(comboCount)); + for (int i = 0; i < (comboCount + 1) / 2; ++i) { + UInt32 packed = 0; + packed |= static_cast(comboPairs[i * 4 + 0]) << 24; + packed |= static_cast(comboPairs[i * 4 + 1]) << 16; + packed |= static_cast(comboPairs[i * 4 + 2]) << 8; + packed |= static_cast(comboPairs[i * 4 + 3]); + addOption(screen, kAnchoredCombosOptions[i], static_cast(packed)); + } + } } else { // unknown argument throw XConfigRead(s, "unknown argument \"%{1}\"", name); @@ -1616,6 +1896,55 @@ std::ostream &operator<<(std::ostream &s, const Config &config) s << "\t\t" << name << " = " << value << std::endl; } } + + UInt32 mask[8] = {0}; + bool hasAnchoredKeys = false; + for (int i = 0; i < 8; ++i) { + auto it = options->find(kAnchoredKeysOptions[i]); + if (it != options->end()) { + mask[i] = static_cast(it->second); + if (mask[i] != 0) + hasAnchoredKeys = true; + } + } + + int comboCount = 0; + auto ccIt = options->find(kOptionAnchoredCombosCount); + if (ccIt != options->end()) + comboCount = static_cast(ccIt->second); + + std::string keyList; + if (hasAnchoredKeys) { + for (int vk = 0; vk < 256; ++vk) { + if (mask[vk / 32] & (1u << (vk % 32))) { + const char *vkName = lookupNameByVk(vk); + if (vkName != nullptr) { + if (!keyList.empty()) + keyList += ", "; + keyList += vkName; + } + } + } + } + for (int ci = 0; ci < comboCount; ++ci) { + int slotIdx = ci / 2; + auto cIt = options->find(kAnchoredCombosOptions[slotIdx]); + if (cIt == options->end()) + continue; + UInt32 packed = static_cast(cIt->second); + int byteOff = (ci % 2) * 2; + UInt8 mods = (packed >> (24 - byteOff * 8)) & 0xFF; + UInt8 vk = (packed >> (16 - byteOff * 8)) & 0xFF; + const char *vkName = lookupNameByVk(vk); + if (vkName != nullptr) { + if (!keyList.empty()) + keyList += ", "; + keyList += modBitsToString(mods); + keyList += vkName; + } + } + if (!keyList.empty()) + s << "\t\t" << "anchoredKeys" << " = " << keyList << std::endl; } } s << "end" << std::endl; From c50df90a24b73488aed8a047fcffe31848035aac Mon Sep 17 00:00:00 2001 From: Stefan Verleysen Date: Fri, 20 Mar 2026 09:39:18 -0400 Subject: [PATCH 2/5] fix: anchored combo modifier state and foreground targeting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/platform/MSWindowsDesks.cpp | 3 ++ src/lib/platform/MSWindowsHook.cpp | 52 +++++++++++++++++++++++------ src/lib/platform/MSWindowsHook.h | 1 + 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/lib/platform/MSWindowsDesks.cpp b/src/lib/platform/MSWindowsDesks.cpp index c9afc4d31..9c561173c 100644 --- a/src/lib/platform/MSWindowsDesks.cpp +++ b/src/lib/platform/MSWindowsDesks.cpp @@ -28,6 +28,7 @@ #include "deskflow/win32/AppUtilWindows.h" #include "mt/Lock.h" #include "mt/Thread.h" +#include "platform/MSWindowsHook.h" #include "platform/MSWindowsScreen.h" #include "platform/dfwhook.h" @@ -538,6 +539,7 @@ void MSWindowsDesks::deskEnter(Desk *desk) AttachThreadInput(thatThread, thisThread, FALSE); EnableWindow(desk->m_window, desk->m_lowLevel ? FALSE : TRUE); desk->m_foregroundWindow = NULL; + MSWindowsHook::setAnchorTargets(NULL, NULL); } void MSWindowsDesks::deskLeave(Desk *desk, HKL keyLayout) @@ -597,6 +599,7 @@ void MSWindowsDesks::deskLeave(Desk *desk, HKL keyLayout) AttachThreadInput(thatThread, thisThread, TRUE); SetForegroundWindow(desk->m_window); AttachThreadInput(thatThread, thisThread, FALSE); + MSWindowsHook::setAnchorTargets(desk->m_foregroundWindow, desk->m_window); } } } else { diff --git a/src/lib/platform/MSWindowsHook.cpp b/src/lib/platform/MSWindowsHook.cpp index ba451d010..2007cde24 100644 --- a/src/lib/platform/MSWindowsHook.cpp +++ b/src/lib/platform/MSWindowsHook.cpp @@ -72,6 +72,8 @@ static PendingMod g_pendingMods[4] = {}; static int g_pendingModCount = 0; static UInt8 g_pendingModBits = 0; static UInt8 g_comboFiredModBits = 0; +static HWND g_anchorForeground = NULL; +static HWND g_anchorDeskWindow = NULL; static UInt8 modBitForVk(DWORD vk) { @@ -225,6 +227,12 @@ void MSWindowsHook::setAnchoredKeysFKeys(UInt32 fKeyBitmask) setAnchoredKeys(mask); } +void MSWindowsHook::setAnchorTargets(HWND foregroundWindow, HWND deskWindow) +{ + g_anchorForeground = foregroundWindow; + g_anchorDeskWindow = deskWindow; +} + static void keyboardGetState(BYTE keys[256], DWORD vkCode, bool kf_up) { // we have to use GetAsyncKeyState() rather than GetKeyState() because @@ -281,15 +289,9 @@ static void flushPendingMods() static void injectComboLocally(int triggerVk, WORD triggerScanCode) { - INPUT inputs[8] = {}; + INPUT inputs[16] = {}; int idx = 0; - inputs[idx].type = INPUT_KEYBOARD; - inputs[idx].ki.wVk = VK_CANCEL; - inputs[idx].ki.wScan = 0; - inputs[idx].ki.dwFlags = 0; - idx++; - for (int i = 0; i < g_pendingModCount; i++) { inputs[idx].type = INPUT_KEYBOARD; inputs[idx].ki.wVk = (WORD)g_pendingMods[i].vk; @@ -305,12 +307,36 @@ static void injectComboLocally(int triggerVk, WORD triggerScanCode) idx++; inputs[idx].type = INPUT_KEYBOARD; - inputs[idx].ki.wVk = VK_CANCEL; - inputs[idx].ki.wScan = 0; + inputs[idx].ki.wVk = (WORD)triggerVk; + inputs[idx].ki.wScan = triggerScanCode; inputs[idx].ki.dwFlags = KEYEVENTF_KEYUP; idx++; - SendInput(idx, inputs, sizeof(INPUT)); + for (int i = g_pendingModCount - 1; i >= 0; i--) { + inputs[idx].type = INPUT_KEYBOARD; + inputs[idx].ki.wVk = (WORD)g_pendingMods[i].vk; + inputs[idx].ki.wScan = (WORD)MapVirtualKey(g_pendingMods[i].vk, MAPVK_VK_TO_VSC); + inputs[idx].ki.dwFlags = KEYEVENTF_KEYUP; + idx++; + } + + HWND target = g_anchorForeground; + HWND deskWnd = g_anchorDeskWindow; + if (target != NULL && deskWnd != NULL) { + DWORD deskThread = GetWindowThreadProcessId(deskWnd, NULL); + DWORD targetThread = GetWindowThreadProcessId(target, NULL); + AttachThreadInput(targetThread, deskThread, TRUE); + SetForegroundWindow(target); + AttachThreadInput(targetThread, deskThread, FALSE); + + SendInput(idx, inputs, sizeof(INPUT)); + + AttachThreadInput(targetThread, deskThread, TRUE); + SetForegroundWindow(deskWnd); + AttachThreadInput(targetThread, deskThread, FALSE); + } else { + SendInput(idx, inputs, sizeof(INPUT)); + } } static void setDeadKey(WCHAR wc[], int size, UINT flags) @@ -374,6 +400,9 @@ static bool keyboardHookHandler(WPARAM wParam, LPARAM lParam) // Modifier UP after combo fired: pass through locally, don't relay if (!isKeyDown && modBit != 0 && (g_comboFiredModBits & modBit)) { g_comboFiredModBits &= ~modBit; + g_keyState[vk] = 0; + if (vk == VK_LSHIFT || vk == VK_RSHIFT) + g_keyState[VK_SHIFT] = g_keyState[VK_LSHIFT] | g_keyState[VK_RSHIFT]; return false; } @@ -430,6 +459,9 @@ static bool keyboardHookHandler(WPARAM wParam, LPARAM lParam) g_pendingMods[g_pendingModCount].modBit = modBit; g_pendingModCount++; g_pendingModBits |= modBit; + g_keyState[vk] = 0x80; + if (vk == VK_LSHIFT || vk == VK_RSHIFT) + g_keyState[VK_SHIFT] = 0x80; return true; // eat it, don't relay yet } } diff --git a/src/lib/platform/MSWindowsHook.h b/src/lib/platform/MSWindowsHook.h index 13541c936..8bd1f33eb 100644 --- a/src/lib/platform/MSWindowsHook.h +++ b/src/lib/platform/MSWindowsHook.h @@ -53,4 +53,5 @@ class MSWindowsHook void setAnchoredKeys(const UInt32 mask[8]); void setAnchoredCombos(const UInt8 *data, int count); void setAnchoredKeysFKeys(UInt32 fKeyBitmask); + static void setAnchorTargets(HWND foregroundWindow, HWND deskWindow); }; From 804471fce64a96fe89d422ea19ddc41d7d884ef3 Mon Sep 17 00:00:00 2001 From: Stefan Verleysen Date: Wed, 25 Mar 2026 06:33:11 -0400 Subject: [PATCH 3/5] fix: combo injection loop and anchored key dispatch Repurpose unused lParam bit 30 to carry LLKHF_INJECTED so the combo handler can distinguish physical keypresses from its own SendInput events. Prevents infinite re-entry on combo repeat and modifier-UP paths. Keys that are both combo triggers and bare-anchored now enter the combo state machine correctly instead of short-circuiting. Combo state is cleared on mode transitions to prevent stale holdover. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/platform/MSWindowsHook.cpp | 46 +++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/src/lib/platform/MSWindowsHook.cpp b/src/lib/platform/MSWindowsHook.cpp index 2007cde24..bc570c4ea 100644 --- a/src/lib/platform/MSWindowsHook.cpp +++ b/src/lib/platform/MSWindowsHook.cpp @@ -198,6 +198,11 @@ void MSWindowsHook::setMode(EHookMode mode) // no change return; } + if (g_mode == kHOOK_RELAY_EVENTS) { + g_pendingModCount = 0; + g_pendingModBits = 0; + g_comboFiredModBits = 0; + } g_mode = mode; } @@ -388,26 +393,42 @@ static bool keyboardHookHandler(WPARAM wParam, LPARAM lParam) UInt32 vk = static_cast(vkCode); if (vk < 256 && (g_anchoredKeysMask[vk / 32] & (1u << (vk % 32))) != 0) { - if ((lParam & 0x80000000u) == 0 && (lParam & 0x40000000u) == 0) - PostThreadMessage(g_threadID, DESKFLOW_MSG_DEBUG, 0xAC000000u | vk, lParam); - return false; + bool isComboTrigger = false; + for (int i = 0; i < g_anchoredComboCount; ++i) { + if (g_anchoredCombos[i].vk == vk) { + isComboTrigger = true; + break; + } + } + if (!isComboTrigger) { + if ((lParam & 0x80000000u) == 0 && (lParam & 0x40000000u) == 0) + PostThreadMessage(g_threadID, DESKFLOW_MSG_DEBUG, 0xAC000000u | vk, lParam); + return false; + } } if (g_anchoredComboCount > 0 && vk < 256) { bool isKeyDown = (lParam & 0x80000000u) == 0; + bool isInjected = (lParam & 0x40000000u) != 0; UInt8 modBit = modBitForVk(vk); - // Modifier UP after combo fired: pass through locally, don't relay + // Modifier UP after combo fired: pass through locally, don't relay. + // Only clear combo state for physical (non-injected) UPs so the + // combo stays active while injectComboLocally's synthetic events + // are processed. The combo ends when the user physically releases. if (!isKeyDown && modBit != 0 && (g_comboFiredModBits & modBit)) { - g_comboFiredModBits &= ~modBit; + if (!isInjected) { + g_comboFiredModBits &= ~modBit; + } g_keyState[vk] = 0; if (vk == VK_LSHIFT || vk == VK_RSHIFT) g_keyState[VK_SHIFT] = g_keyState[VK_LSHIFT] | g_keyState[VK_RSHIFT]; return false; } - // While combo is active, repeat combo trigger stays local - if (isKeyDown && modBit == 0 && g_comboFiredModBits != 0) { + // While combo is active, repeat combo trigger stays local. + // Skip injected events to avoid an infinite SendInput loop. + if (isKeyDown && !isInjected && modBit == 0 && g_comboFiredModBits != 0) { for (int i = 0; i < g_anchoredComboCount; ++i) { if (g_anchoredCombos[i].vk == vk && g_anchoredCombos[i].modifiers == g_comboFiredModBits) { WORD sc = (WORD)((lParam >> 16) & 0x1FF); @@ -491,6 +512,11 @@ static bool keyboardHookHandler(WPARAM wParam, LPARAM lParam) flushPendingMods(); // fall through to relay this key normally } + + if (g_pendingModBits == 0 && g_comboFiredModBits == 0 && + vk < 256 && (g_anchoredKeysMask[vk / 32] & (1u << (vk % 32))) != 0) { + return false; + } } } @@ -715,9 +741,9 @@ static LRESULT CALLBACK keyboardLLHook(int code, WPARAM wParam, LPARAM lParam) if (info->flags & LLKHF_UP) { lParam |= (1lu << 31); // transition } - // FIXME -- bit 30 should be set if key was already down but - // we don't know that info. as a result we'll never generate - // key repeat events. + if (info->flags & LLKHF_INJECTED) { + lParam |= (1lu << 30); // injected event marker + } // handle the message if (keyboardHookHandler(wParam, lParam)) { From 861fdd385334618999faedb6a587d0e08e85c285 Mon Sep 17 00:00:00 2001 From: Stefan Verleysen Date: Tue, 31 Mar 2026 10:27:47 -0400 Subject: [PATCH 4/5] fix: anchored combo held-key support injectComboLocally fired a full down+up cycle on every keypress, causing held keys to produce rapid press-release instead of a sustained hold. Split into injectComboLocally (downs only) and releaseComboLocally (ups on physical release). Extracted sendInputToTarget to share the foreground-flip logic. Added !isInjected guard on trigger-UP path to prevent infinite SendInput loop. Combo state cleanup on mode transitions now includes g_firedModCount. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/platform/MSWindowsHook.cpp | 75 ++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 25 deletions(-) diff --git a/src/lib/platform/MSWindowsHook.cpp b/src/lib/platform/MSWindowsHook.cpp index bc570c4ea..a965c0d8d 100644 --- a/src/lib/platform/MSWindowsHook.cpp +++ b/src/lib/platform/MSWindowsHook.cpp @@ -72,6 +72,8 @@ static PendingMod g_pendingMods[4] = {}; static int g_pendingModCount = 0; static UInt8 g_pendingModBits = 0; static UInt8 g_comboFiredModBits = 0; +static PendingMod g_firedMods[4] = {}; +static int g_firedModCount = 0; static HWND g_anchorForeground = NULL; static HWND g_anchorDeskWindow = NULL; @@ -202,6 +204,7 @@ void MSWindowsHook::setMode(EHookMode mode) g_pendingModCount = 0; g_pendingModBits = 0; g_comboFiredModBits = 0; + g_firedModCount = 0; } g_mode = mode; } @@ -292,9 +295,30 @@ static void flushPendingMods() g_pendingModBits = 0; } +static void sendInputToTarget(INPUT *inputs, int count) +{ + HWND target = g_anchorForeground; + HWND deskWnd = g_anchorDeskWindow; + if (target != NULL && deskWnd != NULL) { + DWORD deskThread = GetWindowThreadProcessId(deskWnd, NULL); + DWORD targetThread = GetWindowThreadProcessId(target, NULL); + AttachThreadInput(targetThread, deskThread, TRUE); + SetForegroundWindow(target); + AttachThreadInput(targetThread, deskThread, FALSE); + + SendInput(count, inputs, sizeof(INPUT)); + + AttachThreadInput(targetThread, deskThread, TRUE); + SetForegroundWindow(deskWnd); + AttachThreadInput(targetThread, deskThread, FALSE); + } else { + SendInput(count, inputs, sizeof(INPUT)); + } +} + static void injectComboLocally(int triggerVk, WORD triggerScanCode) { - INPUT inputs[16] = {}; + INPUT inputs[8] = {}; int idx = 0; for (int i = 0; i < g_pendingModCount; i++) { @@ -311,37 +335,34 @@ static void injectComboLocally(int triggerVk, WORD triggerScanCode) inputs[idx].ki.dwFlags = 0; idx++; + g_firedModCount = g_pendingModCount; + for (int i = 0; i < g_pendingModCount; i++) + g_firedMods[i] = g_pendingMods[i]; + + sendInputToTarget(inputs, idx); +} + +static void releaseComboLocally(int triggerVk, WORD triggerScanCode) +{ + INPUT inputs[8] = {}; + int idx = 0; + inputs[idx].type = INPUT_KEYBOARD; inputs[idx].ki.wVk = (WORD)triggerVk; inputs[idx].ki.wScan = triggerScanCode; inputs[idx].ki.dwFlags = KEYEVENTF_KEYUP; idx++; - for (int i = g_pendingModCount - 1; i >= 0; i--) { + for (int i = g_firedModCount - 1; i >= 0; i--) { inputs[idx].type = INPUT_KEYBOARD; - inputs[idx].ki.wVk = (WORD)g_pendingMods[i].vk; - inputs[idx].ki.wScan = (WORD)MapVirtualKey(g_pendingMods[i].vk, MAPVK_VK_TO_VSC); + inputs[idx].ki.wVk = (WORD)g_firedMods[i].vk; + inputs[idx].ki.wScan = (WORD)MapVirtualKey(g_firedMods[i].vk, MAPVK_VK_TO_VSC); inputs[idx].ki.dwFlags = KEYEVENTF_KEYUP; idx++; } - HWND target = g_anchorForeground; - HWND deskWnd = g_anchorDeskWindow; - if (target != NULL && deskWnd != NULL) { - DWORD deskThread = GetWindowThreadProcessId(deskWnd, NULL); - DWORD targetThread = GetWindowThreadProcessId(target, NULL); - AttachThreadInput(targetThread, deskThread, TRUE); - SetForegroundWindow(target); - AttachThreadInput(targetThread, deskThread, FALSE); - - SendInput(idx, inputs, sizeof(INPUT)); - - AttachThreadInput(targetThread, deskThread, TRUE); - SetForegroundWindow(deskWnd); - AttachThreadInput(targetThread, deskThread, FALSE); - } else { - SendInput(idx, inputs, sizeof(INPUT)); - } + g_firedModCount = 0; + sendInputToTarget(inputs, idx); } static void setDeadKey(WCHAR wc[], int size, UINT flags) @@ -450,11 +471,15 @@ static bool keyboardHookHandler(WPARAM wParam, LPARAM lParam) } } - // While combo is active, trigger UP stays local - if (!isKeyDown && modBit == 0 && g_comboFiredModBits != 0) { + // While combo is active, trigger UP releases the combo. + // Skip injected events to avoid an infinite SendInput loop. + if (!isKeyDown && !isInjected && modBit == 0 && g_comboFiredModBits != 0) { for (int i = 0; i < g_anchoredComboCount; ++i) { - if (g_anchoredCombos[i].vk == vk) - return false; + if (g_anchoredCombos[i].vk == vk) { + WORD sc = (WORD)((lParam >> 16) & 0x1FF); + releaseComboLocally(vk, sc); + return true; + } } } From 01cc46937bfc7e09a2266743fb7f65212ec62c31 Mon Sep 17 00:00:00 2001 From: Stefan Verleysen Date: Wed, 1 Apr 2026 14:47:19 -0400 Subject: [PATCH 5/5] fix: anchored combo stuck keys and pedal support VK_CANCEL wrapping in sendInputToTarget so injected events bypass the hook instead of being relayed to the client. Clear g_keyState on mode change to prevent stale modifier state. Remove isInjected guards that blocked pedal (SendInput) events from releasing combos. Order-independent trigger buffering for simultaneous key arrival. --- src/lib/platform/MSWindowsHook.cpp | 141 ++++++++++++++++++++++------- 1 file changed, 106 insertions(+), 35 deletions(-) diff --git a/src/lib/platform/MSWindowsHook.cpp b/src/lib/platform/MSWindowsHook.cpp index a965c0d8d..0cf3aa9f5 100644 --- a/src/lib/platform/MSWindowsHook.cpp +++ b/src/lib/platform/MSWindowsHook.cpp @@ -74,6 +74,10 @@ static UInt8 g_pendingModBits = 0; static UInt8 g_comboFiredModBits = 0; static PendingMod g_firedMods[4] = {}; static int g_firedModCount = 0; +static DWORD g_comboTriggerVk = 0; +static WORD g_comboTriggerSc = 0; +static DWORD g_pendingTriggerVk = 0; +static LPARAM g_pendingTriggerLParam = 0; static HWND g_anchorForeground = NULL; static HWND g_anchorDeskWindow = NULL; @@ -194,6 +198,9 @@ void MSWindowsHook::setZone(SInt32 x, SInt32 y, SInt32 w, SInt32 h, SInt32 jumpZ g_hScreen = h; } +static void releaseComboLocally(int triggerVk, WORD triggerScanCode); +static void injectComboLocally(int triggerVk, WORD triggerScanCode); + void MSWindowsHook::setMode(EHookMode mode) { if (mode == g_mode) { @@ -201,10 +208,17 @@ void MSWindowsHook::setMode(EHookMode mode) return; } if (g_mode == kHOOK_RELAY_EVENTS) { + if (g_comboFiredModBits != 0 && g_comboTriggerVk != 0) { + releaseComboLocally(g_comboTriggerVk, g_comboTriggerSc); + } g_pendingModCount = 0; g_pendingModBits = 0; + g_pendingTriggerVk = 0; + g_pendingTriggerLParam = 0; g_comboFiredModBits = 0; g_firedModCount = 0; + g_comboTriggerVk = 0; + memset(g_keyState, 0, sizeof(g_keyState)); } g_mode = mode; } @@ -295,10 +309,64 @@ static void flushPendingMods() g_pendingModBits = 0; } +static void flushPendingTrigger() +{ + if (g_pendingTriggerVk != 0) { + WPARAM charAndVirtKey = makeKeyMsg((UINT)g_pendingTriggerVk, 0, false); + PostThreadMessage(g_threadID, DESKFLOW_MSG_KEY, charAndVirtKey, g_pendingTriggerLParam); + g_pendingTriggerVk = 0; + g_pendingTriggerLParam = 0; + } +} + +static bool tryFireCombo() +{ + if (g_pendingTriggerVk == 0 || g_pendingModBits == 0) + return false; + for (int i = 0; i < g_anchoredComboCount; ++i) { + if (g_anchoredCombos[i].vk == g_pendingTriggerVk && + g_anchoredCombos[i].modifiers == g_pendingModBits) { + WORD sc = (WORD)((g_pendingTriggerLParam >> 16) & 0x1FF); + injectComboLocally(g_pendingTriggerVk, sc); + g_comboFiredModBits = g_pendingModBits; + g_comboTriggerVk = g_pendingTriggerVk; + g_comboTriggerSc = sc; + PostThreadMessage(g_threadID, DESKFLOW_MSG_DEBUG, + 0xAC000000u | g_pendingTriggerVk | (g_comboFiredModBits << 8), + g_pendingTriggerLParam); + g_pendingModCount = 0; + g_pendingModBits = 0; + g_pendingTriggerVk = 0; + g_pendingTriggerLParam = 0; + return true; + } + } + return false; +} + static void sendInputToTarget(INPUT *inputs, int count) { HWND target = g_anchorForeground; HWND deskWnd = g_anchorDeskWindow; + + INPUT wrapped[12] = {}; + int idx = 0; + + wrapped[idx].type = INPUT_KEYBOARD; + wrapped[idx].ki.wVk = DESKFLOW_HOOK_FAKE_INPUT_VIRTUAL_KEY; + wrapped[idx].ki.wScan = DESKFLOW_HOOK_FAKE_INPUT_SCANCODE; + wrapped[idx].ki.dwFlags = 0; + idx++; + + for (int i = 0; i < count && idx < 11; i++) + wrapped[idx++] = inputs[i]; + + wrapped[idx].type = INPUT_KEYBOARD; + wrapped[idx].ki.wVk = DESKFLOW_HOOK_FAKE_INPUT_VIRTUAL_KEY; + wrapped[idx].ki.wScan = DESKFLOW_HOOK_FAKE_INPUT_SCANCODE; + wrapped[idx].ki.dwFlags = KEYEVENTF_KEYUP; + idx++; + if (target != NULL && deskWnd != NULL) { DWORD deskThread = GetWindowThreadProcessId(deskWnd, NULL); DWORD targetThread = GetWindowThreadProcessId(target, NULL); @@ -306,13 +374,13 @@ static void sendInputToTarget(INPUT *inputs, int count) SetForegroundWindow(target); AttachThreadInput(targetThread, deskThread, FALSE); - SendInput(count, inputs, sizeof(INPUT)); + SendInput(idx, wrapped, sizeof(INPUT)); AttachThreadInput(targetThread, deskThread, TRUE); SetForegroundWindow(deskWnd); AttachThreadInput(targetThread, deskThread, FALSE); } else { - SendInput(count, inputs, sizeof(INPUT)); + SendInput(idx, wrapped, sizeof(INPUT)); } } @@ -430,16 +498,15 @@ static bool keyboardHookHandler(WPARAM wParam, LPARAM lParam) if (g_anchoredComboCount > 0 && vk < 256) { bool isKeyDown = (lParam & 0x80000000u) == 0; - bool isInjected = (lParam & 0x40000000u) != 0; UInt8 modBit = modBitForVk(vk); // Modifier UP after combo fired: pass through locally, don't relay. - // Only clear combo state for physical (non-injected) UPs so the - // combo stays active while injectComboLocally's synthetic events - // are processed. The combo ends when the user physically releases. + // No isInjected guard -- VK_CANCEL wrapping keeps our own events out. if (!isKeyDown && modBit != 0 && (g_comboFiredModBits & modBit)) { - if (!isInjected) { - g_comboFiredModBits &= ~modBit; + g_comboFiredModBits &= ~modBit; + if (g_comboFiredModBits == 0 && g_comboTriggerVk != 0) { + releaseComboLocally(g_comboTriggerVk, g_comboTriggerSc); + g_comboTriggerVk = 0; } g_keyState[vk] = 0; if (vk == VK_LSHIFT || vk == VK_RSHIFT) @@ -448,23 +515,16 @@ static bool keyboardHookHandler(WPARAM wParam, LPARAM lParam) } // While combo is active, repeat combo trigger stays local. - // Skip injected events to avoid an infinite SendInput loop. - if (isKeyDown && !isInjected && modBit == 0 && g_comboFiredModBits != 0) { + if (isKeyDown && modBit == 0 && g_comboFiredModBits != 0) { for (int i = 0; i < g_anchoredComboCount; ++i) { if (g_anchoredCombos[i].vk == vk && g_anchoredCombos[i].modifiers == g_comboFiredModBits) { WORD sc = (WORD)((lParam >> 16) & 0x1FF); - INPUT inputs[3] = {}; + INPUT inputs[1] = {}; inputs[0].type = INPUT_KEYBOARD; - inputs[0].ki.wVk = VK_CANCEL; + inputs[0].ki.wVk = (WORD)vk; + inputs[0].ki.wScan = sc; inputs[0].ki.dwFlags = 0; - inputs[1].type = INPUT_KEYBOARD; - inputs[1].ki.wVk = (WORD)vk; - inputs[1].ki.wScan = sc; - inputs[1].ki.dwFlags = 0; - inputs[2].type = INPUT_KEYBOARD; - inputs[2].ki.wVk = VK_CANCEL; - inputs[2].ki.dwFlags = KEYEVENTF_KEYUP; - SendInput(3, inputs, sizeof(INPUT)); + sendInputToTarget(inputs, 1); PostThreadMessage(g_threadID, DESKFLOW_MSG_DEBUG, 0xAC000000u | vk | (g_comboFiredModBits << 8), lParam); return true; } @@ -472,12 +532,12 @@ static bool keyboardHookHandler(WPARAM wParam, LPARAM lParam) } // While combo is active, trigger UP releases the combo. - // Skip injected events to avoid an infinite SendInput loop. - if (!isKeyDown && !isInjected && modBit == 0 && g_comboFiredModBits != 0) { + if (!isKeyDown && modBit == 0 && g_comboFiredModBits != 0) { for (int i = 0; i < g_anchoredComboCount; ++i) { if (g_anchoredCombos[i].vk == vk) { WORD sc = (WORD)((lParam >> 16) & 0x1FF); releaseComboLocally(vk, sc); + g_comboTriggerVk = 0; return true; } } @@ -508,34 +568,45 @@ static bool keyboardHookHandler(WPARAM wParam, LPARAM lParam) g_keyState[vk] = 0x80; if (vk == VK_LSHIFT || vk == VK_RSHIFT) g_keyState[VK_SHIFT] = 0x80; + if (tryFireCombo()) + return true; return true; // eat it, don't relay yet } } // Modifier UP while pending: no combo arrived, flush to client if (!isKeyDown && modBit != 0 && (g_pendingModBits & modBit)) { + flushPendingTrigger(); flushPendingMods(); // fall through to relay this UP normally } - // Non-modifier key while mods are pending - if (modBit == 0 && g_pendingModBits != 0) { - if (isKeyDown) { + if (isKeyDown && modBit == 0) { + if (g_pendingModBits != 0 || g_pendingTriggerVk != 0) { + bool isThisComboTrigger = false; for (int i = 0; i < g_anchoredComboCount; ++i) { - if (g_anchoredCombos[i].vk == vk && g_anchoredCombos[i].modifiers == g_pendingModBits) { - WORD sc = (WORD)((lParam >> 16) & 0x1FF); - injectComboLocally(vk, sc); - g_comboFiredModBits = g_pendingModBits; - g_pendingModCount = 0; - g_pendingModBits = 0; - PostThreadMessage(g_threadID, DESKFLOW_MSG_DEBUG, 0xAC000000u | vk | (g_comboFiredModBits << 8), lParam); - return true; // eat the real trigger + if (g_anchoredCombos[i].vk == vk) { + isThisComboTrigger = true; + break; } } + + if (isThisComboTrigger) { + g_pendingTriggerVk = vk; + g_pendingTriggerLParam = lParam; + if (tryFireCombo()) + return true; + return true; + } + + flushPendingTrigger(); + flushPendingMods(); } - // No combo match, flush pending mods to client + } + + if (!isKeyDown && modBit == 0 && g_pendingTriggerVk != 0) { + flushPendingTrigger(); flushPendingMods(); - // fall through to relay this key normally } if (g_pendingModBits == 0 && g_comboFiredModBits == 0 &&