diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ee2f2c..b32f73b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,6 +90,7 @@ add_executable(SteamlessController WIN32 src/app/ControllerManager.cpp src/app/VirtualController.cpp src/app/TrackpadMouse.cpp + src/app/KeyInput.cpp src/app/SteamWatcher.cpp src/app/DeviceRestart.cpp src/app/EventLog.cpp diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index 9156e55..02dfddf 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -2,6 +2,7 @@ #include "EventLog.h" #include "VirtualController.h" #include "TrackpadMouse.h" +#include "KeyInput.h" #include "steam/SteamController.h" #include #include @@ -9,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -34,10 +36,11 @@ struct ControllerManager::Slot { // thread for that timeout on every one of those attempts. std::chrono::steady_clock::time_point lastSilentAt{}; - // Mouse buttons currently held down by a back paddle, so leaving game mode - // mid-press releases them instead of stranding the button down. - bool paddleMouseLeftDown = false; - bool paddleMouseRightDown = false; + // What each paddle (L4, L5, R4, R5) is currently holding down, so leaving + // game mode mid-press releases it instead of stranding the input down. + // Records what the press actually sent rather than reading the binding + // again on release, so rebinding a paddle mid-hold still releases cleanly. + BackButtonBinding paddleHeld[4]; // Haptic edge-detection state for touch (movement ticks). bool hapticWasRightTouching = false; @@ -204,7 +207,7 @@ ControllerManager::~ControllerManager() { if (slot->gameModeActive) { StopReadLoop(*slot); slot->trackpad.Reset(); - ReleasePaddleMouseButtons(*slot); + ReleaseHeldPaddleInputs(*slot); slot->vc.reset(); slot->gameModeActive = false; } @@ -284,17 +287,44 @@ void ControllerManager::SetBackButtonConfig(const BackButtonConfig& cfg) { // change takes effect on the very next report. } -void ControllerManager::ReleasePaddleMouseButtons(Slot& slot) { - auto send = [](DWORD flags) { +// Sends the key or mouse event a binding stands for. Gamepad actions reach the +// virtual pad instead and are ignored here. Returns whether anything was sent. +static bool SendPaddleInput(const BackButtonBinding& binding, bool down) { + auto sendMouse = [](DWORD flags) { INPUT inp{}; inp.type = INPUT_MOUSE; inp.mi.dwFlags = flags; SendInput(1, &inp, sizeof(INPUT)); }; - if (slot.paddleMouseLeftDown) send(MOUSEEVENTF_LEFTUP); - if (slot.paddleMouseRightDown) send(MOUSEEVENTF_RIGHTUP); - slot.paddleMouseLeftDown = false; - slot.paddleMouseRightDown = false; + + switch (binding.kind) { + case BackButtonBinding::Kind::Key: + SendKeyInput(binding.code, down); + return true; + + case BackButtonBinding::Kind::MouseButton: + // Extended mouse buttons are not bindable yet. + return false; + + case BackButtonBinding::Kind::Action: + if (binding.IsAction(BackButtonAction::LeftMouseButton)) { + sendMouse(down ? MOUSEEVENTF_LEFTDOWN : MOUSEEVENTF_LEFTUP); + return true; + } + if (binding.IsAction(BackButtonAction::RightMouseButton)) { + sendMouse(down ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_RIGHTUP); + return true; + } + return false; + } + return false; +} + +void ControllerManager::ReleaseHeldPaddleInputs(Slot& slot) { + for (auto& held : slot.paddleHeld) { + SendPaddleInput(held, false); + held = BackButtonBinding{}; + } } void ControllerManager::SetControllerPlatform(ControllerPlatform platform) { @@ -458,7 +488,7 @@ ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, EventLog::Write("GAMEMODE: enabled %ls", slot.path.c_str()); slot.gameModeActive = true; slot.trackpad.Reset(); - ReleasePaddleMouseButtons(slot); + ReleaseHeldPaddleInputs(slot); slot.trackpad.SetTrackpadEnabled(m_trackpadMouseEnabled); slot.trackpad.SetUseLeftTrackpad(m_useLeftTrackpad); StartReadLoop(slot); @@ -472,7 +502,7 @@ void ControllerManager::DisableGameModeSlot(Slot& slot) { if (slot.sc->IsOpen()) slot.sc->SetRumble(0, 0); slot.trackpad.Reset(); - ReleasePaddleMouseButtons(slot); + ReleaseHeldPaddleInputs(slot); slot.vc.reset(); slot.sc->EnableLizardMode(); // Reopen shared so Steam can obtain write access — game mode is no longer active. @@ -778,8 +808,9 @@ void ControllerManager::ReadLoop(Slot* slot) { slot->hapticWasLeftTouching = lt; } - // Fire mouse button events for back paddles mapped to LeftMouseButton/RightMouseButton. - // Uses edge detection so we only send DOWN on press and UP on release. + // Deliver back paddles bound to a key or a mouse button. These go out + // through SendInput rather than the virtual pad. Edge-detected so each + // press sends exactly one down and each release exactly one up. if (hasPrev) { struct PaddleEdge { bool cur; bool prev; const BackButtonBinding& binding; }; const PaddleEdge edges[] = { @@ -788,17 +819,17 @@ void ControllerManager::ReadLoop(Slot* slot) { { n>2 && (buf[2]&SteamController::BTN_R4)!=0, (prevBuf[2]&SteamController::BTN_R4)!=0, m_backConfig.r4 }, { n>3 && (buf[3]&SteamController::BTN_R5)!=0, (prevBuf[3]&SteamController::BTN_R5)!=0, m_backConfig.r5 }, }; - for (const auto& e : edges) { + for (size_t i = 0; i < std::size(edges); ++i) { + const auto& e = edges[i]; if (e.cur == e.prev) continue; - DWORD flag = 0; - if (e.binding.IsAction(BackButtonAction::LeftMouseButton)) { - flag = e.cur ? MOUSEEVENTF_LEFTDOWN : MOUSEEVENTF_LEFTUP; - slot->paddleMouseLeftDown = e.cur; - } else if (e.binding.IsAction(BackButtonAction::RightMouseButton)) { - flag = e.cur ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_RIGHTUP; - slot->paddleMouseRightDown = e.cur; + if (e.cur) { + if (SendPaddleInput(e.binding, true)) slot->paddleHeld[i] = e.binding; + } else { + // Release what the press sent, not what the binding says + // now — the two differ if the user rebound mid-hold. + SendPaddleInput(slot->paddleHeld[i], false); + slot->paddleHeld[i] = BackButtonBinding{}; } - if (flag) { INPUT inp{}; inp.type = INPUT_MOUSE; inp.mi.dwFlags = flag; SendInput(1, &inp, sizeof(INPUT)); } } } diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index c68e2d5..0ad91f3 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -79,7 +79,7 @@ private: GameModeOutcome EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, uint32_t stateWaitMs); void DisableGameModeSlot(Slot& slot); - void ReleasePaddleMouseButtons(Slot& slot); + void ReleaseHeldPaddleInputs(Slot& slot); void StartReadLoop(Slot& slot); void StopReadLoop(Slot& slot); void ReadLoop(Slot* slot); diff --git a/src/app/KeyInput.cpp b/src/app/KeyInput.cpp new file mode 100644 index 0000000..f322b27 --- /dev/null +++ b/src/app/KeyInput.cpp @@ -0,0 +1,153 @@ +#include "KeyInput.h" +#include +#include + +namespace { + +struct JsCodeToVk { const char* code; uint16_t vk; }; + +// KeyboardEvent.code values are physical positions, so a binding made on one +// keyboard layout means the same physical key on another. Escape is absent on +// purpose — the remap window uses it to cancel listening. +const JsCodeToVk kCodeMap[] = { + // Letters + {"KeyA",'A'},{"KeyB",'B'},{"KeyC",'C'},{"KeyD",'D'},{"KeyE",'E'},{"KeyF",'F'}, + {"KeyG",'G'},{"KeyH",'H'},{"KeyI",'I'},{"KeyJ",'J'},{"KeyK",'K'},{"KeyL",'L'}, + {"KeyM",'M'},{"KeyN",'N'},{"KeyO",'O'},{"KeyP",'P'},{"KeyQ",'Q'},{"KeyR",'R'}, + {"KeyS",'S'},{"KeyT",'T'},{"KeyU",'U'},{"KeyV",'V'},{"KeyW",'W'},{"KeyX",'X'}, + {"KeyY",'Y'},{"KeyZ",'Z'}, + + // Digit row + {"Digit0",'0'},{"Digit1",'1'},{"Digit2",'2'},{"Digit3",'3'},{"Digit4",'4'}, + {"Digit5",'5'},{"Digit6",'6'},{"Digit7",'7'},{"Digit8",'8'},{"Digit9",'9'}, + + // Whitespace and editing + {"Space", VK_SPACE}, + {"Enter", VK_RETURN}, + {"Tab", VK_TAB}, + {"Backspace", VK_BACK}, + {"Delete", VK_DELETE}, + {"Insert", VK_INSERT}, + + // Navigation + {"ArrowUp", VK_UP}, + {"ArrowDown", VK_DOWN}, + {"ArrowLeft", VK_LEFT}, + {"ArrowRight", VK_RIGHT}, + {"Home", VK_HOME}, + {"End", VK_END}, + {"PageUp", VK_PRIOR}, + {"PageDown", VK_NEXT}, + + // Modifiers — bound to the specific left/right key, not the merged VK_SHIFT + {"ShiftLeft", VK_LSHIFT}, {"ShiftRight", VK_RSHIFT}, + {"ControlLeft", VK_LCONTROL}, {"ControlRight", VK_RCONTROL}, + {"AltLeft", VK_LMENU}, {"AltRight", VK_RMENU}, + + // Function row + {"F1",VK_F1},{"F2",VK_F2},{"F3",VK_F3},{"F4",VK_F4},{"F5",VK_F5},{"F6",VK_F6}, + {"F7",VK_F7},{"F8",VK_F8},{"F9",VK_F9},{"F10",VK_F10},{"F11",VK_F11},{"F12",VK_F12}, + + // Punctuation (OEM codes are positional, which suits a physical binding) + {"Minus", VK_OEM_MINUS}, + {"Equal", VK_OEM_PLUS}, + {"BracketLeft", VK_OEM_4}, + {"BracketRight", VK_OEM_6}, + {"Backslash", VK_OEM_5}, + {"Semicolon", VK_OEM_1}, + {"Quote", VK_OEM_7}, + {"Backquote", VK_OEM_3}, + {"Comma", VK_OEM_COMMA}, + {"Period", VK_OEM_PERIOD}, + {"Slash", VK_OEM_2}, + + // Numpad + {"Numpad0",VK_NUMPAD0},{"Numpad1",VK_NUMPAD1},{"Numpad2",VK_NUMPAD2}, + {"Numpad3",VK_NUMPAD3},{"Numpad4",VK_NUMPAD4},{"Numpad5",VK_NUMPAD5}, + {"Numpad6",VK_NUMPAD6},{"Numpad7",VK_NUMPAD7},{"Numpad8",VK_NUMPAD8}, + {"Numpad9",VK_NUMPAD9}, + {"NumpadAdd", VK_ADD}, + {"NumpadSubtract", VK_SUBTRACT}, + {"NumpadMultiply", VK_MULTIPLY}, + {"NumpadDivide", VK_DIVIDE}, + {"NumpadDecimal", VK_DECIMAL}, + // Windows gives numpad Enter the same virtual key as the main one and + // separates them by scan code alone. A binding stores only the VK, so this + // one behaves as a plain Enter — which is what nearly all software wants. + {"NumpadEnter", VK_RETURN}, + + // Locks + {"CapsLock", VK_CAPITAL}, + {"NumLock", VK_NUMLOCK}, + {"ScrollLock", VK_SCROLL}, +}; + +// The navigation cluster shares scan codes with the numpad and is told apart +// only by the 0xE0 extended prefix — drop it and an Up arrow arrives as numpad +// 8. MapVirtualKey is documented to report that prefix under MAPVK_VK_TO_VSC_EX +// but does not actually do so for these keys, so the set is spelled out here. +bool IsExtendedKey(uint16_t vk) { + switch (vk) { + case VK_RCONTROL: case VK_RMENU: + case VK_INSERT: case VK_DELETE: + case VK_HOME: case VK_END: + case VK_PRIOR: case VK_NEXT: + case VK_UP: case VK_DOWN: + case VK_LEFT: case VK_RIGHT: + case VK_NUMLOCK: case VK_DIVIDE: + case VK_SNAPSHOT: + case VK_LWIN: case VK_RWIN: case VK_APPS: + return true; + default: + return false; + } +} + +struct ScanCode { WORD scan; bool extended; }; + +ScanCode ScanCodeFor(uint16_t vk) { + // _EX is still the right lookup for the scan code itself: it distinguishes + // left from right modifiers, which the plain mapping collapses. + const UINT mapped = MapVirtualKeyW(vk, MAPVK_VK_TO_VSC_EX); + return { static_cast(mapped & 0xFF), IsExtendedKey(vk) }; +} + +} // namespace + +uint16_t VkFromJsCode(const std::string& jsCode) { + for (const auto& e : kCodeMap) + if (jsCode == e.code) return e.vk; + return 0; +} + +std::wstring KeyDisplayName(uint16_t vk) { + if (vk == 0) return L"Key"; + + const ScanCode sc = ScanCodeFor(vk); + if (sc.scan == 0) return L"Key"; + + // GetKeyNameText takes the scan code and extended flag positioned as they + // appear in a WM_KEYDOWN lParam. + LONG lParam = static_cast(sc.scan) << 16; + if (sc.extended) lParam |= (1L << 24); + + wchar_t buf[64] = {}; + const int n = GetKeyNameTextW(lParam, buf, static_cast(std::size(buf))); + return n > 0 ? std::wstring(buf, static_cast(n)) : std::wstring(L"Key"); +} + +void SendKeyInput(uint16_t vk, bool down) { + if (vk == 0) return; + + const ScanCode sc = ScanCodeFor(vk); + if (sc.scan == 0) return; + + INPUT input = {}; + input.type = INPUT_KEYBOARD; + input.ki.wVk = 0; // ignored, and must be zero, when sending a scan code + input.ki.wScan = sc.scan; + input.ki.dwFlags = KEYEVENTF_SCANCODE; + if (sc.extended) input.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY; + if (!down) input.ki.dwFlags |= KEYEVENTF_KEYUP; + SendInput(1, &input, sizeof(INPUT)); +} diff --git a/src/app/KeyInput.h b/src/app/KeyInput.h new file mode 100644 index 0000000..20eadca --- /dev/null +++ b/src/app/KeyInput.h @@ -0,0 +1,25 @@ +#pragma once +#include +#include + +// Keyboard support for paddle bindings. +// +// Capture happens in the remap window's WebView2 page rather than through a +// keyboard hook: the page is already focused while a row is listening, and a +// low-level hook is a heavy, AV-bait mechanism for a convenience feature. +// The page reports the physical key as a KeyboardEvent.code string, which this +// module turns into a virtual-key code. + +// Maps a KeyboardEvent.code value ("KeyA", "Space", "ArrowUp") to a Windows +// virtual-key code. Returns 0 for anything unrecognised or not bindable. +uint16_t VkFromJsCode(const std::string& jsCode); + +// Human-readable name for a virtual-key code, from the active keyboard layout — +// so a non-US layout shows its own key names. Falls back to "Key" if the layout +// has no name for it. +std::wstring KeyDisplayName(uint16_t vk); + +// Presses or releases a key. Sends a scan code rather than a virtual key, and +// flags the extended keys, because software that reads scan codes directly +// (games, in particular) sees nothing useful otherwise. +void SendKeyInput(uint16_t vk, bool down); diff --git a/src/app/RemapWindow.cpp b/src/app/RemapWindow.cpp index 1d0157c..263eab7 100644 --- a/src/app/RemapWindow.cpp +++ b/src/app/RemapWindow.cpp @@ -1,4 +1,5 @@ #include "RemapWindow.h" +#include "KeyInput.h" #include "ControllerManager.h" #include #include @@ -104,7 +105,7 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back

Back Button Mapping

-

Click Rebind on a button, then press any gamepad button on your controller. The new binding shows up here instantly. Pick Off to stop a paddle doing anything at all.

+

Click Rebind on a button, then press any gamepad button on your controller — or any key on your keyboard. The new binding shows up here instantly. Pick Off to stop a paddle doing anything at all.

LEFT GRIP
@@ -134,6 +135,9 @@ var DEFAULTS = {L4:'leftMouse',L5:'none',R4:'leftMouse',R5:'none'}; // ---- State ---- var bindings = {L4:'leftMouse',L5:'none',R4:'leftMouse',R5:'none'}; var listening = null; +// Display names for key bindings, keyed by binding id. Supplied by C++ from the +// active keyboard layout — the catalog below can't cover them. +var keyLabels = {}; var flash = null; var flashTimer = null; var applyTimer = null; @@ -189,9 +193,12 @@ if(window.chrome&&window.chrome.webview){ var msg=JSON.parse(e.data); if(msg.type==='init'){ bindings=msg.bindings; + keyLabels=msg.labels||{}; renderAll(); } else if(msg.type==='buttonCaptured'){ - if(listening&&byId[msg.button]) doBind(listening,msg.button); + if(!listening) return; + if(msg.label) keyLabels[msg.button]=msg.label; + doBind(listening,msg.button); } }); } @@ -240,6 +247,16 @@ function applyBindings(){ postMsg({type:'apply',bindings:bindings}); } // ---- Render helpers ---- +// Resolves a binding id to something renderable. Keys aren't in the static +// catalog, so they're built on the fly from the label C++ sent. +function inputInfo(id){ + if(byId[id]) return byId[id]; + if(id&&id.indexOf('key:')===0){ + var lbl=keyLabels[id]||'Key'; + return {id:id,glyph:(lbl.length<=3?lbl:'KEY'),label:lbl,bg:'#4a3524',fg:'#f2d5ab'}; + } + return byId['none']; +} function chipHTML(t,cls){ return '
'+t.glyph+'
'; } @@ -249,11 +266,11 @@ function renderRow(def){ var isL=listening===def.id, isF=flash===def.id; el.className='row'+(isL?' listening':isF?' flash':''); var curBind=bindings[def.id]; - var tgt=byId[curBind]||byId['none']; + var tgt=inputInfo(curBind); var rightHTML; if(isL){ rightHTML='
'+ - 'Press a button...'+ + 'Press a button or key...'+ ''+ ''+ '
'; @@ -307,7 +324,14 @@ document.getElementById('titlebar').addEventListener('mousedown',function(e){ }); document.addEventListener('keydown',function(e){ - if(e.key==='Escape') cancelListening(); + // Escape stays the cancel affordance, so it is deliberately not bindable. + if(e.key==='Escape'){cancelListening();return;} + if(!listening||e.repeat) return; + // Swallow the key so it can't also drive the page (Tab moving focus, Space + // clicking the focused button) while a row is listening. + e.preventDefault(); + e.stopPropagation(); + postMsg({type:'keyCaptured',code:e.code}); }); renderAll(); @@ -418,18 +442,10 @@ LRESULT RemapWindow::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { return hit; } - case WM_BUTTON_CAPTURED: { + case WM_BUTTON_CAPTURED: // Marshalled from the read thread via PostMessage, packed into WPARAM. - const std::string id = - BackButtonBinding::Unpack(static_cast(wp)).Id(); - if (m_webview) { - std::wstring json = L"{\"type\":\"buttonCaptured\",\"button\":\""; - json += std::wstring(id.begin(), id.end()); - json += L"\"}"; - m_webview->PostWebMessageAsString(json.c_str()); - } + PostCapturedBinding(BackButtonBinding::Unpack(static_cast(wp))); return 0; - } case WM_CLOSE: ShowWindow(hwnd, SW_HIDE); @@ -633,6 +649,32 @@ void RemapWindow::OnControllerReady(ICoreWebView2Controller* ctrl) { // --------------------------------------------------------------------------- // Minimal JSON string extractor — finds "key":"value" in a flat JSON object. +// Key names come from the keyboard layout, so they can contain characters that +// would otherwise terminate or corrupt the JSON string we build by hand — the +// backslash key is the obvious one. +static std::wstring JsonEscape(const std::wstring& s) { + std::wstring out; + out.reserve(s.size() + 8); + for (wchar_t c : s) { + switch (c) { + case L'"': out += L"\\\""; break; + case L'\\': out += L"\\\\"; break; + default: + if (c >= 0x20) out += c; // drop control characters + break; + } + } + return out; +} + +// Display label for a binding, or empty when the page's static catalog already +// has a name for it. Only keys need one — their names come from the layout. +static std::wstring BindingLabel(const BackButtonBinding& binding) { + return binding.kind == BackButtonBinding::Kind::Key + ? KeyDisplayName(binding.code) + : std::wstring(); +} + static std::string JsonStr(const std::string& json, const std::string& key) { std::string needle = "\"" + key + "\":\""; size_t pos = json.find(needle); @@ -672,6 +714,16 @@ void RemapWindow::OnWebMessage(const std::wstring& raw) { }); } + } else if (type == "keyCaptured") { + // Arrives from the page's keydown handler, already on the UI thread. + // Unmappable keys are ignored so the row keeps listening rather than + // binding to nothing. + const uint16_t vk = VkFromJsCode(JsonStr(msg, "code")); + if (vk != 0) { + if (m_mgr) m_mgr->StopButtonCapture(); + PostCapturedBinding(BackButtonBinding::FromKey(vk)); + } + } else if (type == "stopListening") { if (m_mgr) m_mgr->StopButtonCapture(); @@ -695,6 +747,14 @@ void RemapWindow::PostToWebView(const std::wstring& jsonStr) { if (m_webview) m_webview->PostWebMessageAsString(jsonStr.c_str()); } +void RemapWindow::PostCapturedBinding(const BackButtonBinding& binding) { + const std::string id = binding.Id(); + std::wstring json = L"{\"type\":\"buttonCaptured\",\"button\":\""; + json += std::wstring(id.begin(), id.end()); + json += L"\",\"label\":\"" + JsonEscape(BindingLabel(binding)) + L"\"}"; + PostToWebView(json); +} + void RemapWindow::SendInitState() { if (!m_webview) return; @@ -704,12 +764,25 @@ void RemapWindow::SendInitState() { return std::wstring(s.begin(), s.end()); }; + // Key bindings need a label the page cannot derive on its own — their names + // come from the keyboard layout. Sent as an id→name map so a paddle pair + // bound to the same key contributes one entry. + std::wstring labels; + for (const auto* b : { &m_config.l4, &m_config.l5, &m_config.r4, &m_config.r5 }) { + const std::wstring label = BindingLabel(*b); + if (label.empty()) continue; + const std::wstring entry = L"\"" + wid(*b) + L"\":\"" + JsonEscape(label) + L"\""; + if (labels.find(entry) != std::wstring::npos) continue; + if (!labels.empty()) labels += L","; + labels += entry; + } + std::wstring json = L"{\"type\":\"init\",\"bindings\":{" L"\"L4\":\"" + wid(m_config.l4) + L"\"," L"\"L5\":\"" + wid(m_config.l5) + L"\"," L"\"R4\":\"" + wid(m_config.r4) + L"\"," L"\"R5\":\"" + wid(m_config.r5) + L"\"" - L"}}"; + L"},\"labels\":{" + labels + L"}}"; PostToWebView(json); } diff --git a/src/app/RemapWindow.h b/src/app/RemapWindow.h index 0de2e08..3a9908b 100644 --- a/src/app/RemapWindow.h +++ b/src/app/RemapWindow.h @@ -34,6 +34,10 @@ private: void PostToWebView(const std::wstring& jsonStr); void SendInitState(); + // Tells the page a binding was captured. The page applies it to whichever + // row is listening — it owns that state, not us. + void PostCapturedBinding(const BackButtonBinding& binding); + // Called from the read thread via PostMessage — marshals a captured button // back to the UI thread so we can call PostWebMessageAsString safely. static constexpr UINT WM_BUTTON_CAPTURED = WM_APP + 1;