diff --git a/electron-builder.config.ts b/electron-builder.config.ts index c3ce473..20e5a5b 100644 --- a/electron-builder.config.ts +++ b/electron-builder.config.ts @@ -22,7 +22,7 @@ export default { directories: { output: 'dist', }, - files: ['package.json', 'out/**/*', 'node_modules/node-pty/**/*'], + files: ['package.json', 'out/**/*', 'node_modules/node-pty/**/*', 'node_modules/wintab-pen-bridge/**/*'], extraResources: [ { from: 'assets/bin', diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 4da0050..7e377e2 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ entry: resolve('src/main/index.ts'), }, rollupOptions: { - external: ['node-pty'], + external: ['node-pty', 'wintab-pen-bridge'], }, }, }, diff --git a/native/wintab-pen-bridge/binding.gyp b/native/wintab-pen-bridge/binding.gyp new file mode 100644 index 0000000..769e761 --- /dev/null +++ b/native/wintab-pen-bridge/binding.gyp @@ -0,0 +1,24 @@ +{ + "targets": [ + { + "target_name": "wintab_pen_bridge", + "sources": [ + "src/wintab_pen_bridge.cc" + ], + "defines": [ + "NAPI_VERSION=8", + "UNICODE", + "_UNICODE", + "WIN32_LEAN_AND_MEAN", + "NOMINMAX" + ], + "msvs_settings": { + "VCCLCompilerTool": { + "AdditionalOptions": [ + "/std:c++20" + ] + } + } + } + ] +} diff --git a/native/wintab-pen-bridge/index.js b/native/wintab-pen-bridge/index.js new file mode 100644 index 0000000..cdd3491 --- /dev/null +++ b/native/wintab-pen-bridge/index.js @@ -0,0 +1,4 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +'use strict'; + +module.exports = require('./build/Release/wintab_pen_bridge.node'); diff --git a/native/wintab-pen-bridge/package.json b/native/wintab-pen-bridge/package.json new file mode 100644 index 0000000..ac34e0f --- /dev/null +++ b/native/wintab-pen-bridge/package.json @@ -0,0 +1,14 @@ +{ + "name": "wintab-pen-bridge", + "version": "0.0.1", + "description": "Windows-only WinTab pen bridge for Invoke Launcher", + "main": "index.js", + "gypfile": true, + "private": true, + "os": [ + "win32" + ], + "scripts": { + "install": "node-gyp rebuild" + } +} diff --git a/native/wintab-pen-bridge/src/wintab_pen_bridge.cc b/native/wintab-pen-bridge/src/wintab_pen_bridge.cc new file mode 100644 index 0000000..66f499d --- /dev/null +++ b/native/wintab-pen-bridge/src/wintab_pen_bridge.cc @@ -0,0 +1,869 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using WTPKT = UINT; +using HCTX = HANDLE; + +struct FIX32 { + WORD frac; + short whole; +}; + +struct AXIS { + LONG axMin; + LONG axMax; + UINT axUnits; + FIX32 axResolution; +}; + +struct LOGCONTEXTW { + WCHAR lcName[40]; + UINT lcOptions; + UINT lcStatus; + UINT lcLocks; + UINT lcMsgBase; + UINT lcDevice; + UINT lcPktRate; + WTPKT lcPktData; + WTPKT lcPktMode; + WTPKT lcMoveMask; + DWORD lcBtnDnMask; + DWORD lcBtnUpMask; + LONG lcInOrgX; + LONG lcInOrgY; + LONG lcInOrgZ; + LONG lcInExtX; + LONG lcInExtY; + LONG lcInExtZ; + LONG lcOutOrgX; + LONG lcOutOrgY; + LONG lcOutOrgZ; + LONG lcOutExtX; + LONG lcOutExtY; + LONG lcOutExtZ; + FIX32 lcSensX; + FIX32 lcSensY; + FIX32 lcSensZ; + BOOL lcSysMode; + int lcSysOrgX; + int lcSysOrgY; + int lcSysExtX; + int lcSysExtY; + FIX32 lcSysSensX; + FIX32 lcSysSensY; +}; + +struct PacketData { + HCTX pkContext; + UINT pkStatus; + DWORD pkButtons; + LONG pkX; + LONG pkY; + UINT pkNormalPressure; +}; + +constexpr UINT WTI_INTERFACE = 1; +constexpr UINT WTI_DEFSYSCTX = 4; +constexpr UINT WTI_DEVICES = 100; +constexpr UINT DVC_NPRESSURE = 15; + +constexpr UINT CXO_MESSAGES = 0x0004; +constexpr UINT CXO_SYSTEM = 0x0001; + +constexpr UINT WT_DEFBASE = 0x7FF0; +constexpr UINT WT_PACKET = 0; +constexpr DWORD TIP_CONTACT_BUTTON_MASK = 0x1U; +constexpr DWORD NON_TIP_BUTTON_MASK = ~TIP_CONTACT_BUTTON_MASK; + +// WinTab can report tiny non-zero hover pressure values. Use hysteresis so +// pressure noise does not turn into synthetic pen clicks. +constexpr double CONTACT_START_PRESSURE = 0.01; +constexpr double CONTACT_END_PRESSURE = 0.005; +constexpr double CONTACT_CONFIRM_PRESSURE = 0.03; +constexpr ULONGLONG CONTACT_CONFIRMATION_MS = 12; +constexpr uint32_t CONTACT_CONFIRMATION_PACKET_COUNT = 2; +constexpr ULONGLONG RELEASE_CONFIRMATION_MS = 12; +constexpr uint32_t RELEASE_CONFIRMATION_PACKET_COUNT = 2; +constexpr ULONGLONG NON_TIP_BUTTON_PRESSURE_FREEZE_MS = 32; +constexpr ULONGLONG NON_TIP_BUTTON_COORDINATE_STABILIZATION_MS = 48; + +constexpr WTPKT PK_CONTEXT = 0x0001; +constexpr WTPKT PK_STATUS = 0x0002; +constexpr WTPKT PK_BUTTONS = 0x0040; +constexpr WTPKT PK_X = 0x0080; +constexpr WTPKT PK_Y = 0x0100; +constexpr WTPKT PK_NORMAL_PRESSURE = 0x0400; + +using WTInfoW_t = UINT(APIENTRY *)(UINT, UINT, LPVOID); +using WTOpenW_t = HCTX(APIENTRY *)(HWND, LOGCONTEXTW *, BOOL); +using WTClose_t = BOOL(APIENTRY *)(HCTX); +using WTPacketsGet_t = int(APIENTRY *)(HCTX, int, LPVOID); +using WTEnable_t = BOOL(APIENTRY *)(HCTX, BOOL); +using WTOverlap_t = BOOL(APIENTRY *)(HCTX, BOOL); + +struct WinTabApi { + HMODULE module = nullptr; + WTInfoW_t WTInfoW = nullptr; + WTOpenW_t WTOpenW = nullptr; + WTClose_t WTClose = nullptr; + WTPacketsGet_t WTPacketsGet = nullptr; + WTEnable_t WTEnable = nullptr; + WTOverlap_t WTOverlap = nullptr; +}; + +struct PenEvent { + enum class Kind { Down, Move, Up }; + + Kind kind; + LONG screenX; + LONG screenY; + double pressure; + DWORD buttons; +}; + +struct BridgeState { + WinTabApi api; + HWND hwnd = nullptr; + HCTX context = nullptr; + UINT msgBase = WT_DEFBASE; + LONG pressureMin = 0; + LONG pressureMax = 1023; + bool systemContextRelative = false; + LONG systemOrgX = 0; + LONG systemOrgY = 0; + LONG systemExtX = 0; + LONG systemExtY = 0; + bool packetAxisXResolved = false; + bool packetAxisYResolved = false; + bool packetFlipX = false; + bool packetFlipY = false; + LONG lastRawPacketX = 0; + LONG lastRawPacketY = 0; + LONG lastCursorCalibrationX = 0; + LONG lastCursorCalibrationY = 0; + bool hasLastCalibrationSample = false; + int packetAxisXScore = 0; + int packetAxisYScore = 0; + bool attached = false; + bool contactActive = false; + bool pendingContact = false; + bool pendingRelease = false; + ULONGLONG suppressPrimaryMouseUntil = 0; + LONG lastScreenX = 0; + LONG lastScreenY = 0; + double lastPressure = 0.0; + DWORD lastButtons = 0; + DWORD lastRawButtons = 0; + ULONGLONG freezePressureUntil = 0; + ULONGLONG useCursorCoordinatesUntil = 0; + LONG pendingScreenX = 0; + LONG pendingScreenY = 0; + double pendingPressure = 0.0; + DWORD pendingButtons = 0; + ULONGLONG pendingSince = 0; + uint32_t pendingSampleCount = 0; + double pendingMaxPressure = 0.0; + ULONGLONG releaseSince = 0; + uint32_t releaseSampleCount = 0; + std::wstring lastError; + std::deque queuedEvents; + std::unordered_map hookedWindows; +}; + +BridgeState g_bridge; + +void ProcessPacket(const PacketData &packet); + +void SetLastErrorMessage(const std::wstring &message) { + g_bridge.lastError = message; +} + +bool EnsureWinTabLoaded() { + if (g_bridge.api.module != nullptr) { + return true; + } + + HMODULE module = LoadLibraryW(L"Wintab32.dll"); + if (module == nullptr) { + SetLastErrorMessage(L"Failed to load Wintab32.dll"); + return false; + } + + auto loadProc = [module](auto &target, const char *name) -> bool { + target = reinterpret_cast>(GetProcAddress(module, name)); + return target != nullptr; + }; + + if (!loadProc(g_bridge.api.WTInfoW, "WTInfoW") || !loadProc(g_bridge.api.WTOpenW, "WTOpenW") || + !loadProc(g_bridge.api.WTClose, "WTClose") || !loadProc(g_bridge.api.WTPacketsGet, "WTPacketsGet") || + !loadProc(g_bridge.api.WTEnable, "WTEnable") || !loadProc(g_bridge.api.WTOverlap, "WTOverlap")) { + FreeLibrary(module); + SetLastErrorMessage(L"Failed to load one or more WinTab entry points"); + return false; + } + + g_bridge.api.module = module; + return true; +} + +double NormalizePressure(UINT pressure) { + const auto range = static_cast(std::max(1, g_bridge.pressureMax - g_bridge.pressureMin)); + const auto normalized = (static_cast(pressure) - static_cast(g_bridge.pressureMin)) / range; + return std::clamp(normalized, 0.0, 1.0); +} + +bool SampleChanged(LONG screenX, LONG screenY, double pressure, DWORD buttons) { + return screenX != g_bridge.lastScreenX || screenY != g_bridge.lastScreenY || + std::fabs(pressure - g_bridge.lastPressure) > 0.0001 || buttons != g_bridge.lastButtons; +} + +void UpdateLastSample(LONG screenX, LONG screenY, double pressure, DWORD buttons) { + g_bridge.lastScreenX = screenX; + g_bridge.lastScreenY = screenY; + g_bridge.lastPressure = pressure; + g_bridge.lastButtons = buttons; +} + +void ClearPendingContact() { + g_bridge.pendingContact = false; + g_bridge.pendingScreenX = 0; + g_bridge.pendingScreenY = 0; + g_bridge.pendingPressure = 0.0; + g_bridge.pendingButtons = 0; + g_bridge.pendingSince = 0; + g_bridge.pendingSampleCount = 0; + g_bridge.pendingMaxPressure = 0.0; +} + +void ClearPendingRelease() { + g_bridge.pendingRelease = false; + g_bridge.releaseSince = 0; + g_bridge.releaseSampleCount = 0; +} + +void QueuePenEvent(PenEvent::Kind kind, LONG screenX, LONG screenY, double pressure, DWORD buttons) { + g_bridge.queuedEvents.push_back(PenEvent{kind, screenX, screenY, pressure, buttons}); +} + +POINT ResolveCursorScreenPoint(const PacketData &packet) { + POINT point{packet.pkX, packet.pkY}; + if (GetCursorPos(&point)) { + return point; + } + return point; +} + +bool IsPlausibleScreenPoint(const POINT &point) { + const LONG left = GetSystemMetrics(SM_XVIRTUALSCREEN); + const LONG top = GetSystemMetrics(SM_YVIRTUALSCREEN); + const LONG right = left + GetSystemMetrics(SM_CXVIRTUALSCREEN); + const LONG bottom = top + GetSystemMetrics(SM_CYVIRTUALSCREEN); + constexpr LONG tolerance = 256; + + return point.x >= left - tolerance && point.x <= right + tolerance && point.y >= top - tolerance && + point.y <= bottom + tolerance; +} + +LONG MirrorAxisInSystemSpace(const LONG value, const LONG origin, const LONG extent) { + const auto absoluteExtent = std::llabs(static_cast(extent)); + if (absoluteExtent <= 0) { + return value; + } + + return static_cast(static_cast(origin) + absoluteExtent - + (static_cast(value) - static_cast(origin))); +} + +void UpdatePacketAxisCalibration(const PacketData &packet, const POINT &cursorPoint) { + if (g_bridge.systemContextRelative) { + return; + } + + if (!g_bridge.hasLastCalibrationSample) { + g_bridge.lastRawPacketX = packet.pkX; + g_bridge.lastRawPacketY = packet.pkY; + g_bridge.lastCursorCalibrationX = cursorPoint.x; + g_bridge.lastCursorCalibrationY = cursorPoint.y; + g_bridge.hasLastCalibrationSample = true; + return; + } + + const LONG packetDeltaX = packet.pkX - g_bridge.lastRawPacketX; + const LONG packetDeltaY = packet.pkY - g_bridge.lastRawPacketY; + const LONG cursorDeltaX = cursorPoint.x - g_bridge.lastCursorCalibrationX; + const LONG cursorDeltaY = cursorPoint.y - g_bridge.lastCursorCalibrationY; + + constexpr LONG minimumDelta = 2; + + if (!g_bridge.packetAxisXResolved && std::llabs(static_cast(packetDeltaX)) >= minimumDelta && + std::llabs(static_cast(cursorDeltaX)) >= minimumDelta) { + g_bridge.packetAxisXScore += (packetDeltaX > 0) == (cursorDeltaX > 0) ? 1 : -1; + if (std::abs(g_bridge.packetAxisXScore) >= 3) { + g_bridge.packetAxisXResolved = true; + g_bridge.packetFlipX = g_bridge.packetAxisXScore < 0; + } + } + + if (!g_bridge.packetAxisYResolved && std::llabs(static_cast(packetDeltaY)) >= minimumDelta && + std::llabs(static_cast(cursorDeltaY)) >= minimumDelta) { + g_bridge.packetAxisYScore += (packetDeltaY > 0) == (cursorDeltaY > 0) ? 1 : -1; + if (std::abs(g_bridge.packetAxisYScore) >= 3) { + g_bridge.packetAxisYResolved = true; + g_bridge.packetFlipY = g_bridge.packetAxisYScore < 0; + } + } + + g_bridge.lastRawPacketX = packet.pkX; + g_bridge.lastRawPacketY = packet.pkY; + g_bridge.lastCursorCalibrationX = cursorPoint.x; + g_bridge.lastCursorCalibrationY = cursorPoint.y; +} + +POINT ResolvePacketScreenPoint(const PacketData &packet) { + const POINT cursorPoint = ResolveCursorScreenPoint(packet); + const auto now = GetTickCount64(); + + if (now <= g_bridge.useCursorCoordinatesUntil) { + g_bridge.lastRawPacketX = packet.pkX; + g_bridge.lastRawPacketY = packet.pkY; + g_bridge.lastCursorCalibrationX = cursorPoint.x; + g_bridge.lastCursorCalibrationY = cursorPoint.y; + g_bridge.hasLastCalibrationSample = true; + return cursorPoint; + } + + UpdatePacketAxisCalibration(packet, cursorPoint); + + if (g_bridge.systemContextRelative) { + return cursorPoint; + } + + POINT point{packet.pkX, packet.pkY}; + + if (g_bridge.packetAxisXResolved && g_bridge.packetFlipX) { + point.x = MirrorAxisInSystemSpace(point.x, g_bridge.systemOrgX, g_bridge.systemExtX); + } + + if (g_bridge.packetAxisYResolved && g_bridge.packetFlipY) { + point.y = MirrorAxisInSystemSpace(point.y, g_bridge.systemOrgY, g_bridge.systemExtY); + } + + if (IsPlausibleScreenPoint(point)) { + return point; + } + + return cursorPoint; +} + +bool IsPointerMessage(const UINT message) { + switch (message) { + case WM_POINTERDOWN: + case WM_POINTERUP: + case WM_POINTERUPDATE: + case WM_POINTERENTER: + case WM_POINTERLEAVE: + case WM_POINTERACTIVATE: + case WM_POINTERCAPTURECHANGED: + case WM_POINTERWHEEL: + case WM_POINTERHWHEEL: + return true; + default: + return false; + } +} + +bool ShouldSuppressPenPointerMessage(const UINT message, const WPARAM wParam) { + if (!IsPointerMessage(message)) { + return false; + } + + const UINT32 pointerId = GET_POINTERID_WPARAM(wParam); + POINTER_INPUT_TYPE pointerType = PT_POINTER; + if (!GetPointerType(pointerId, &pointerType)) { + return false; + } + + return pointerType == PT_PEN; +} + +bool ShouldSuppressPrimaryMousePathNow() { + return g_bridge.contactActive || g_bridge.pendingContact || GetTickCount64() <= g_bridge.suppressPrimaryMouseUntil; +} + +bool ShouldSuppressPrimaryMouseMessage(const UINT message) { + if (!ShouldSuppressPrimaryMousePathNow()) { + return false; + } + + switch (message) { + case WM_LBUTTONDOWN: + case WM_LBUTTONUP: + case WM_LBUTTONDBLCLK: + case WM_MOUSEMOVE: + return true; + default: + return false; + } +} + +void ProcessQueuedPackets() { + if (g_bridge.context == nullptr) { + return; + } + + PacketData packets[32]; + const int packetCount = g_bridge.api.WTPacketsGet(g_bridge.context, 32, packets); + for (int index = 0; index < packetCount; ++index) { + ProcessPacket(packets[index]); + } +} + +void ProcessPacket(const PacketData &packet) { + const DWORD rawButtons = packet.pkButtons; + const DWORD penButtons = rawButtons & TIP_CONTACT_BUTTON_MASK; + const bool nonTipButtonStateChanged = ((rawButtons ^ g_bridge.lastRawButtons) & NON_TIP_BUTTON_MASK) != 0; + const auto now = GetTickCount64(); + auto pressure = NormalizePressure(packet.pkNormalPressure); + const bool tipContact = (penButtons & TIP_CONTACT_BUTTON_MASK) != 0; + if (g_bridge.contactActive && nonTipButtonStateChanged) { + g_bridge.freezePressureUntil = now + NON_TIP_BUTTON_PRESSURE_FREEZE_MS; + } + if (nonTipButtonStateChanged || (rawButtons & NON_TIP_BUTTON_MASK) != 0) { + g_bridge.useCursorCoordinatesUntil = now + NON_TIP_BUTTON_COORDINATE_STABILIZATION_MS; + g_bridge.hasLastCalibrationSample = false; + } + if (g_bridge.contactActive && now <= g_bridge.freezePressureUntil) { + pressure = g_bridge.lastPressure; + } + const bool pressureContact = + g_bridge.contactActive ? pressure > CONTACT_END_PRESSURE : pressure >= CONTACT_START_PRESSURE; + const bool contact = tipContact || pressureContact; + const POINT cursorPoint = ResolvePacketScreenPoint(packet); + g_bridge.lastRawButtons = rawButtons; + + if (!g_bridge.contactActive && !g_bridge.pendingContact && contact) { + g_bridge.pendingContact = true; + g_bridge.pendingSince = now; + g_bridge.pendingScreenX = cursorPoint.x; + g_bridge.pendingScreenY = cursorPoint.y; + g_bridge.pendingPressure = pressure; + g_bridge.pendingButtons = penButtons; + g_bridge.pendingSampleCount = 1; + g_bridge.pendingMaxPressure = pressure; + return; + } + + if (!g_bridge.contactActive && g_bridge.pendingContact) { + if (!contact) { + ClearPendingContact(); + return; + } + + g_bridge.pendingScreenX = cursorPoint.x; + g_bridge.pendingScreenY = cursorPoint.y; + g_bridge.pendingPressure = pressure; + g_bridge.pendingButtons = penButtons; + g_bridge.pendingSampleCount += 1; + g_bridge.pendingMaxPressure = std::max(g_bridge.pendingMaxPressure, pressure); + + const bool confirmedByTip = tipContact; + const bool confirmedByPressure = g_bridge.pendingSampleCount >= CONTACT_CONFIRMATION_PACKET_COUNT && + g_bridge.pendingMaxPressure >= CONTACT_CONFIRM_PRESSURE && + now - g_bridge.pendingSince >= CONTACT_CONFIRMATION_MS; + const bool confirmed = confirmedByTip || confirmedByPressure; + if (!confirmed) { + return; + } + + g_bridge.contactActive = true; + g_bridge.pendingContact = false; + g_bridge.suppressPrimaryMouseUntil = 0; + UpdateLastSample( + g_bridge.pendingScreenX, g_bridge.pendingScreenY, g_bridge.pendingPressure, g_bridge.pendingButtons); + QueuePenEvent( + PenEvent::Kind::Down, g_bridge.pendingScreenX, g_bridge.pendingScreenY, g_bridge.pendingPressure, + g_bridge.pendingButtons); + ClearPendingContact(); + + if (SampleChanged(cursorPoint.x, cursorPoint.y, pressure, penButtons)) { + UpdateLastSample(cursorPoint.x, cursorPoint.y, pressure, penButtons); + QueuePenEvent(PenEvent::Kind::Move, cursorPoint.x, cursorPoint.y, pressure, penButtons); + } + return; + } + + if (g_bridge.contactActive && contact) { + ClearPendingRelease(); + if (SampleChanged(cursorPoint.x, cursorPoint.y, pressure, penButtons)) { + UpdateLastSample(cursorPoint.x, cursorPoint.y, pressure, penButtons); + QueuePenEvent(PenEvent::Kind::Move, cursorPoint.x, cursorPoint.y, pressure, penButtons); + } + return; + } + + if (g_bridge.contactActive && !contact) { + if (!g_bridge.pendingRelease) { + g_bridge.pendingRelease = true; + g_bridge.releaseSince = now; + g_bridge.releaseSampleCount = 1; + return; + } + + g_bridge.releaseSampleCount += 1; + const bool confirmed = + now - g_bridge.releaseSince >= RELEASE_CONFIRMATION_MS || + g_bridge.releaseSampleCount >= RELEASE_CONFIRMATION_PACKET_COUNT; + if (!confirmed) { + return; + } + + g_bridge.contactActive = false; + ClearPendingRelease(); + g_bridge.suppressPrimaryMouseUntil = GetTickCount64() + 200; + UpdateLastSample(cursorPoint.x, cursorPoint.y, 0.0, penButtons); + QueuePenEvent(PenEvent::Kind::Up, cursorPoint.x, cursorPoint.y, 0.0, penButtons); + } +} + +bool HookWindow(HWND hwnd); + +BOOL CALLBACK HookChildWindowProc(HWND hwnd, LPARAM) { + HookWindow(hwnd); + return TRUE; +} + +void HookWindowTree(HWND hwnd) { + if (hwnd == nullptr || !IsWindow(hwnd)) { + return; + } + + HookWindow(hwnd); + EnumChildWindows(hwnd, HookChildWindowProc, 0); +} + +LRESULT CallOriginalWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { + const auto iterator = g_bridge.hookedWindows.find(hwnd); + if (iterator != g_bridge.hookedWindows.end() && iterator->second != nullptr) { + return CallWindowProcW(iterator->second, hwnd, message, wParam, lParam); + } + + return DefWindowProcW(hwnd, message, wParam, lParam); +} + +LRESULT CALLBACK HookWndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { + if (g_bridge.attached) { + if (message == g_bridge.msgBase + WT_PACKET || IsPointerMessage(message) || ShouldSuppressPrimaryMouseMessage(message)) { + ProcessQueuedPackets(); + } + + if (message == WM_PARENTNOTIFY && LOWORD(wParam) == WM_CREATE) { + const auto childHwnd = reinterpret_cast(lParam); + HookWindowTree(childHwnd); + } + + if (ShouldSuppressPenPointerMessage(message, wParam) || ShouldSuppressPrimaryMouseMessage(message)) { + return 0; + } + } + + const LRESULT result = CallOriginalWindowProc(hwnd, message, wParam, lParam); + + if (message == WM_NCDESTROY) { + g_bridge.hookedWindows.erase(hwnd); + if (hwnd == g_bridge.hwnd) { + g_bridge.hwnd = nullptr; + } + } + + return result; +} + +bool HookWindow(HWND hwnd) { + if (hwnd == nullptr || !IsWindow(hwnd)) { + return false; + } + + if (g_bridge.hookedWindows.contains(hwnd)) { + return true; + } + + SetLastError(0); + const auto previousProc = reinterpret_cast( + SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast(HookWndProc))); + if (previousProc == nullptr && GetLastError() != 0) { + return false; + } + + g_bridge.hookedWindows.emplace(hwnd, previousProc); + return true; +} + +void DetachInternal() { + if (g_bridge.context != nullptr) { + g_bridge.api.WTClose(g_bridge.context); + g_bridge.context = nullptr; + } + + std::vector> hookedWindows(g_bridge.hookedWindows.begin(), g_bridge.hookedWindows.end()); + for (const auto &[hwnd, originalProc] : hookedWindows) { + if (hwnd != nullptr && originalProc != nullptr && IsWindow(hwnd)) { + SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast(originalProc)); + } + } + g_bridge.hookedWindows.clear(); + + g_bridge.hwnd = nullptr; + g_bridge.attached = false; + g_bridge.contactActive = false; + ClearPendingContact(); + ClearPendingRelease(); + g_bridge.suppressPrimaryMouseUntil = 0; + g_bridge.lastPressure = 0.0; + g_bridge.lastButtons = 0; + g_bridge.lastRawButtons = 0; + g_bridge.freezePressureUntil = 0; + g_bridge.useCursorCoordinatesUntil = 0; + g_bridge.packetAxisXResolved = false; + g_bridge.packetAxisYResolved = false; + g_bridge.packetFlipX = false; + g_bridge.packetFlipY = false; + g_bridge.hasLastCalibrationSample = false; + g_bridge.packetAxisXScore = 0; + g_bridge.packetAxisYScore = 0; + g_bridge.queuedEvents.clear(); +} + +bool AttachInternal(HWND hwnd) { + if (hwnd == nullptr) { + SetLastErrorMessage(L"Invalid window handle"); + return false; + } + + if (!EnsureWinTabLoaded()) { + return false; + } + + if (g_bridge.attached) { + DetachInternal(); + } + + LOGCONTEXTW context{}; + const UINT size = g_bridge.api.WTInfoW(WTI_DEFSYSCTX, 0, &context); + if (size == 0) { + SetLastErrorMessage(L"WTInfoW(WTI_DEFSYSCTX) failed"); + return false; + } + + context.lcOptions |= CXO_SYSTEM | CXO_MESSAGES; + context.lcSysMode = FALSE; + context.lcMsgBase = WT_DEFBASE; + context.lcPktData = PK_CONTEXT | PK_STATUS | PK_BUTTONS | PK_X | PK_Y | PK_NORMAL_PRESSURE; + context.lcPktMode = 0; + context.lcMoveMask = context.lcPktData; + context.lcBtnDnMask = 0xFFFFFFFFu; + context.lcBtnUpMask = 0xFFFFFFFFu; + + AXIS pressureAxis{}; + if (g_bridge.api.WTInfoW(WTI_DEVICES + context.lcDevice, DVC_NPRESSURE, &pressureAxis) != 0) { + g_bridge.pressureMin = pressureAxis.axMin; + g_bridge.pressureMax = pressureAxis.axMax; + } else { + g_bridge.pressureMin = 0; + g_bridge.pressureMax = 1023; + } + + const HCTX hctx = g_bridge.api.WTOpenW(hwnd, &context, TRUE); + if (hctx == nullptr) { + SetLastErrorMessage(L"WTOpenW failed"); + return false; + } + + HookWindowTree(hwnd); + if (!g_bridge.hookedWindows.contains(hwnd)) { + g_bridge.api.WTClose(hctx); + SetLastErrorMessage(L"Failed to subclass the target window"); + return false; + } + + g_bridge.hwnd = hwnd; + g_bridge.context = hctx; + g_bridge.msgBase = context.lcMsgBase; + g_bridge.systemContextRelative = context.lcSysMode != FALSE; + g_bridge.systemOrgX = context.lcSysOrgX; + g_bridge.systemOrgY = context.lcSysOrgY; + g_bridge.systemExtX = context.lcSysExtX; + g_bridge.systemExtY = context.lcSysExtY; + g_bridge.packetAxisXResolved = false; + g_bridge.packetAxisYResolved = false; + g_bridge.packetFlipX = false; + g_bridge.packetFlipY = false; + g_bridge.hasLastCalibrationSample = false; + g_bridge.packetAxisXScore = 0; + g_bridge.packetAxisYScore = 0; + g_bridge.attached = true; + g_bridge.contactActive = false; + ClearPendingContact(); + ClearPendingRelease(); + g_bridge.suppressPrimaryMouseUntil = 0; + g_bridge.lastPressure = 0.0; + g_bridge.lastButtons = 0; + g_bridge.lastRawButtons = 0; + g_bridge.freezePressureUntil = 0; + g_bridge.useCursorCoordinatesUntil = 0; + g_bridge.queuedEvents.clear(); + + g_bridge.api.WTEnable(g_bridge.context, TRUE); + g_bridge.api.WTOverlap(g_bridge.context, TRUE); + SetLastErrorMessage(L""); + return true; +} + +napi_value CreateBoolean(napi_env env, bool value) { + napi_value result; + napi_get_boolean(env, value, &result); + return result; +} + +napi_value CreateInt32(napi_env env, int32_t value) { + napi_value result; + napi_create_int32(env, value, &result); + return result; +} + +napi_value CreateDouble(napi_env env, double value) { + napi_value result; + napi_create_double(env, value, &result); + return result; +} + +napi_value CreateString(napi_env env, const std::wstring &value) { + napi_value result; + napi_create_string_utf16(env, reinterpret_cast(value.c_str()), value.size(), &result); + return result; +} + +napi_value IsSupported(napi_env env, napi_callback_info info) { + (void)info; + return CreateBoolean(env, EnsureWinTabLoaded()); +} + +napi_value Attach(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + + if (argc != 1) { + napi_throw_type_error(env, nullptr, "attach() expects a native window handle Buffer"); + return nullptr; + } + + bool isBuffer = false; + napi_is_buffer(env, args[0], &isBuffer); + if (!isBuffer) { + napi_throw_type_error(env, nullptr, "attach() expects a native window handle Buffer"); + return nullptr; + } + + void *data = nullptr; + size_t length = 0; + napi_get_buffer_info(env, args[0], &data, &length); + if (length < sizeof(void *)) { + napi_throw_type_error(env, nullptr, "Native window handle Buffer is too small"); + return nullptr; + } + + auto hwnd = *reinterpret_cast(data); + const bool attached = AttachInternal(hwnd); + + napi_value result; + napi_create_object(env, &result); + napi_set_named_property(env, result, "attached", CreateBoolean(env, attached)); + napi_set_named_property(env, result, "contactActive", CreateBoolean(env, g_bridge.contactActive)); + napi_set_named_property(env, result, "pressureMin", CreateInt32(env, g_bridge.pressureMin)); + napi_set_named_property(env, result, "pressureMax", CreateInt32(env, g_bridge.pressureMax)); + napi_set_named_property(env, result, "lastError", CreateString(env, g_bridge.lastError)); + return result; +} + +napi_value Detach(napi_env env, napi_callback_info info) { + (void)info; + DetachInternal(); + return nullptr; +} + +napi_value DrainEvents(napi_env env, napi_callback_info info) { + (void)info; + + napi_value result; + napi_create_array_with_length(env, g_bridge.queuedEvents.size(), &result); + + uint32_t index = 0; + while (!g_bridge.queuedEvents.empty()) { + const auto event = g_bridge.queuedEvents.front(); + g_bridge.queuedEvents.pop_front(); + + napi_value item; + napi_create_object(env, &item); + + const char *kind = "move"; + if (event.kind == PenEvent::Kind::Down) { + kind = "down"; + } else if (event.kind == PenEvent::Kind::Up) { + kind = "up"; + } + + napi_value kindValue; + napi_create_string_utf8(env, kind, NAPI_AUTO_LENGTH, &kindValue); + napi_set_named_property(env, item, "kind", kindValue); + napi_set_named_property(env, item, "screenX", CreateInt32(env, event.screenX)); + napi_set_named_property(env, item, "screenY", CreateInt32(env, event.screenY)); + napi_set_named_property(env, item, "pressure", CreateDouble(env, event.pressure)); + napi_set_named_property(env, item, "buttons", CreateInt32(env, static_cast(event.buttons))); + napi_set_element(env, result, index++, item); + } + + return result; +} + +napi_value GetStatus(napi_env env, napi_callback_info info) { + (void)info; + napi_value result; + napi_create_object(env, &result); + napi_set_named_property(env, result, "attached", CreateBoolean(env, g_bridge.attached)); + napi_set_named_property(env, result, "contactActive", CreateBoolean(env, g_bridge.contactActive)); + napi_set_named_property(env, result, "pressureMin", CreateInt32(env, g_bridge.pressureMin)); + napi_set_named_property(env, result, "pressureMax", CreateInt32(env, g_bridge.pressureMax)); + napi_set_named_property(env, result, "lastError", CreateString(env, g_bridge.lastError)); + return result; +} + +napi_value Init(napi_env env, napi_value exports) { + napi_property_descriptor descriptors[] = { + {"isSupported", nullptr, IsSupported, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"attach", nullptr, Attach, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"detach", nullptr, Detach, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"drainEvents", nullptr, DrainEvents, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"getStatus", nullptr, GetStatus, nullptr, nullptr, nullptr, napi_default, nullptr}, + }; + + napi_define_properties(env, exports, sizeof(descriptors) / sizeof(descriptors[0]), descriptors); + return exports; +} + +} // namespace + +NAPI_MODULE(NODE_GYP_MODULE_NAME, Init) + + diff --git a/package-lock.json b/package-lock.json index 14cfb79..558f2f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "@eslint/compat": "^1.3.1", "node-pty": "^1.1.0-beta27" }, + "optionalDependencies": { + "wintab-pen-bridge": "file:./native/wintab-pen-bridge" + }, "devDependencies": { "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/typed-ipc": "^1.0.2", @@ -97,6 +100,14 @@ "@emotion/react": "^11.13.5" } }, + "native/wintab-pen-bridge": { + "version": "0.0.1", + "hasInstallScript": true, + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -16115,6 +16126,11 @@ "node": ">=4" } }, + "node_modules/wintab-pen-bridge": { + "optional": true, + "resolved": "native/wintab-pen-bridge", + "link": true + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index b0b403c..ccd5389 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "test": "npm rebuild node-pty && vitest", "test:ui": "npm rebuild node-pty && vitest --coverage --ui", "test:no-watch": "npm rebuild node-pty && vitest --no-watch", - "rebuild": "electron-rebuild -f -w node-pty", + "rebuild": "node -e \"const { spawnSync } = require('node:child_process'); const path = require('node:path'); const modules = process.platform === 'win32' ? 'node-pty,wintab-pen-bridge' : 'node-pty'; const command = path.resolve('node_modules', '.bin', process.platform === 'win32' ? 'electron-rebuild.cmd' : 'electron-rebuild'); const result = process.platform === 'win32' ? spawnSync(command, ['-f', '-w', modules], { stdio: 'inherit', shell: true }) : spawnSync(command, ['-f', '-w', modules], { stdio: 'inherit' }); if (result.error) { console.error(result.error); process.exit(1); } process.exit(result.status ?? 1);\"", "postinstall": "npm run rebuild", "react-devtools": "react-devtools", "version": "npm version --message 'chore: bump version to v%s'" @@ -123,6 +123,9 @@ "@eslint/compat": "^1.3.1", "node-pty": "^1.1.0-beta27" }, + "optionalDependencies": { + "wintab-pen-bridge": "file:./native/wintab-pen-bridge" + }, "uv": { "linux": { "url": "https://github.com/astral-sh/uv/releases/download/0.6.12/uv-x86_64-unknown-linux-gnu.tar.gz", diff --git a/src/main/invoke-manager.ts b/src/main/invoke-manager.ts index bcf30dc..87c29d4 100644 --- a/src/main/invoke-manager.ts +++ b/src/main/invoke-manager.ts @@ -4,7 +4,7 @@ import { BrowserWindow, ipcMain, shell } from 'electron'; import type Store from 'electron-store'; import fs from 'fs/promises'; import ip from 'ip'; -import { join } from 'path'; +import path, { join } from 'path'; import { shellEnvSync } from 'shell-env'; import { CommandRunner } from '@/lib/command-runner'; @@ -23,6 +23,7 @@ import type { } from '@/shared/types'; import type { InstallManager } from './install-manager'; +import { InvokeWindowWinTabBridge } from './invoke-window-wintab-bridge'; export class InvokeManager { private status: WithTimestamp; @@ -37,6 +38,7 @@ export class InvokeManager { private commandRunner: CommandRunner; private cols: number | undefined; private rows: number | undefined; + private windowWinTabBridge: InvokeWindowWinTabBridge | null; constructor(arg: { store: Store; @@ -60,6 +62,7 @@ export class InvokeManager { this.commandRunner = new CommandRunner(); this.cols = undefined; this.rows = undefined; + this.windowWinTabBridge = null; } getStatus = (): WithTimestamp => { @@ -228,11 +231,15 @@ export class InvokeManager { minHeight: 600, webPreferences: { devTools: true, + preload: path.join(__dirname, '../preload/index.js'), + contextIsolation: true, + nodeIntegration: false, backgroundThrottling: false, // Prevent memory spikes from throttling when window loses focus additionalArguments: [ '--enable-gpu-rasterization', // Offload canvas to GPU '--enable-zero-copy', // Reduce memory copies '--enable-accelerated-2d-canvas', // GPU acceleration for 2D canvas + '--invoke-hosted-window', ], }, autoHideMenuBar: true, @@ -242,6 +249,7 @@ export class InvokeManager { }); this.window = window; + this.windowWinTabBridge = new InvokeWindowWinTabBridge(window, this.log); const winProps = this.store.get('appWindowProps'); manageWindowSize( @@ -259,6 +267,8 @@ export class InvokeManager { }); window.on('close', () => { + this.windowWinTabBridge?.detach(); + this.windowWinTabBridge = null; this.exitInvoke(); }); @@ -291,6 +301,8 @@ export class InvokeManager { // Close the crashed window (it may still be visible but unresponsive) if (this.window && !this.window.isDestroyed()) { + this.windowWinTabBridge?.detach(); + this.windowWinTabBridge = null; this.window.destroy(); } @@ -311,6 +323,12 @@ export class InvokeManager { } }); + window.webContents.on('before-mouse-event', (event, mouse) => { + if (this.windowWinTabBridge?.shouldSuppressPrimaryMouse(mouse)) { + event.preventDefault(); + } + }); + window.webContents.setWindowOpenHandler((handlerDetails) => { // If the URL is the same as the main URL, allow it to open in an electron window. This is for things like // opening images in a new tab @@ -331,6 +349,9 @@ export class InvokeManager { }); const localUrl = url.replace('0.0.0.0', '127.0.0.1'); + window.webContents.on('did-finish-load', () => { + this.windowWinTabBridge?.attach(); + }); window.webContents.loadURL(localUrl); }; @@ -373,6 +394,8 @@ export class InvokeManager { return; } if (!this.window.isDestroyed()) { + this.windowWinTabBridge?.detach(); + this.windowWinTabBridge = null; this.window.destroy(); } this.window = null; diff --git a/src/main/invoke-window-wintab-bridge.ts b/src/main/invoke-window-wintab-bridge.ts new file mode 100644 index 0000000..56fd494 --- /dev/null +++ b/src/main/invoke-window-wintab-bridge.ts @@ -0,0 +1,204 @@ +import type { BrowserWindow, MouseInputEvent } from 'electron'; +import { screen } from 'electron'; + +import type { SimpleLogger } from '@/lib/simple-logger'; + +type NativeAttachResult = { + attached: boolean; + contactActive: boolean; + pressureMin: number; + pressureMax: number; + lastError: string; +}; + +type NativePenEvent = { + kind: 'down' | 'move' | 'up'; + screenX: number; + screenY: number; + pressure: number; + buttons: number; +}; + +type NativeAddon = { + isSupported: () => boolean; + attach: (hwndBuffer: Buffer) => NativeAttachResult; + detach: () => void; + drainEvents: () => NativePenEvent[]; + getStatus: () => NativeAttachResult; +}; + +type BridgedPenEvent = NativePenEvent & { + clientX: number; + clientY: number; +}; + +const IPC_STATUS_CHANNEL = 'invoke-window:wintab-status'; +const IPC_EVENT_CHANNEL = 'invoke-window:wintab-pen-event'; +const SUPPRESS_PRIMARY_MOUSE_GRACE_MS = 200; + +export class InvokeWindowWinTabBridge { + private readonly window: BrowserWindow; + private readonly log: SimpleLogger; + private addon: NativeAddon | null = null; + private pollTimer: NodeJS.Timeout | null = null; + private attached = false; + private penContactActive = false; + private suppressPrimaryMouseUntil = 0; + private lastPenActivityAt = 0; + + constructor(window: BrowserWindow, log: SimpleLogger) { + this.window = window; + this.log = log; + } + + attach = (): void => { + if (process.platform !== 'win32') { + this.sendStatus(false, 'WinTab bridge is only available on Windows'); + return; + } + + const addon = this.loadAddon(); + if (!addon) { + this.sendStatus(false, 'Failed to load WinTab bridge native addon'); + return; + } + + if (!addon.isSupported()) { + const status = addon.getStatus(); + this.sendStatus(false, status.lastError || 'WinTab is not supported by the current driver'); + return; + } + + const result = addon.attach(this.window.getNativeWindowHandle()); + if (!result.attached) { + this.sendStatus(false, result.lastError || 'Failed to attach WinTab bridge'); + return; + } + + this.addon = addon; + this.attached = true; + this.penContactActive = result.contactActive; + this.lastPenActivityAt = Date.now(); + this.sendStatus(true, 'WinTab attached'); + + this.pollTimer = setInterval(this.drainAndForwardEvents, 1); + }; + + detach = (): void => { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + + if (this.addon && this.attached) { + this.addon.detach(); + } + + this.attached = false; + this.addon = null; + this.penContactActive = false; + this.suppressPrimaryMouseUntil = 0; + this.lastPenActivityAt = 0; + }; + + shouldSuppressPrimaryMouse = (mouse: MouseInputEvent): boolean => { + if (!this.attached || !this.addon) { + return false; + } + + if (mouse.button && mouse.button !== 'left') { + return false; + } + + if (mouse.type === 'contextMenu' || mouse.type === 'mouseWheel') { + return false; + } + + const status = this.addon.getStatus(); + const now = Date.now(); + + if (status.contactActive) { + this.penContactActive = true; + this.lastPenActivityAt = now; + } + + if (!this.penContactActive && now > this.suppressPrimaryMouseUntil) { + return false; + } + + if ( + !status.contactActive && + now > this.suppressPrimaryMouseUntil && + now - this.lastPenActivityAt > SUPPRESS_PRIMARY_MOUSE_GRACE_MS + ) { + this.penContactActive = false; + return false; + } + + return true; + }; + + private loadAddon = (): NativeAddon | null => { + if (this.addon) { + return this.addon; + } + + try { + const addon = require('wintab-pen-bridge') as NativeAddon; + this.addon = addon; + return addon; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.log.warn(`Failed to require wintab-pen-bridge: ${message}\r\n`); + return null; + } + }; + + private sendStatus = (enabled: boolean, message: string): void => { + if (this.window.isDestroyed()) { + return; + } + this.window.webContents.send(IPC_STATUS_CHANNEL, { enabled, message }); + }; + + private drainAndForwardEvents = (): void => { + if (!this.window || this.window.isDestroyed() || !this.addon) { + return; + } + + const events = this.addon.drainEvents(); + if (events.length === 0) { + return; + } + + const now = Date.now(); + const contentBounds = this.window.getContentBounds(); + for (const event of events) { + this.lastPenActivityAt = now; + if (event.kind === 'down') { + this.penContactActive = true; + } else if (event.kind === 'up') { + this.penContactActive = false; + this.suppressPrimaryMouseUntil = now + SUPPRESS_PRIMARY_MOUSE_GRACE_MS; + } + + const dipPoint = screen.screenToDipPoint({ x: event.screenX, y: event.screenY }); + const clientX = dipPoint.x - contentBounds.x; + const clientY = dipPoint.y - contentBounds.y; + + if (clientX < 0 || clientY < 0 || clientX > contentBounds.width || clientY > contentBounds.height) { + continue; + } + + const bridgedEvent: BridgedPenEvent = { + ...event, + screenX: dipPoint.x, + screenY: dipPoint.y, + clientX, + clientY, + }; + + this.window.webContents.send(IPC_EVENT_CHANNEL, bridgedEvent); + } + }; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index e097560..d452fab 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,3 +1,476 @@ import { exposeElectronAPI } from '@electron-toolkit/preload'; +import { ipcRenderer } from 'electron'; -exposeElectronAPI(); +const IS_INVOKE_HOSTED_WINDOW = process.argv.includes('--invoke-hosted-window'); +const WINTAB_STATUS_CHANNEL = 'invoke-window:wintab-status'; +const WINTAB_EVENT_CHANNEL = 'invoke-window:wintab-pen-event'; +const SYNTHETIC_POINTER_ID = 424242; +const SUPPRESS_PRIMARY_MOUSE_GRACE_MS = 200; + +type WinTabStatusPayload = { + enabled: boolean; + message: string; +}; + +type WinTabPenEventPayload = { + kind: 'down' | 'move' | 'up'; + clientX: number; + clientY: number; + screenX: number; + screenY: number; + pressure: number; + buttons: number; +}; + +if (IS_INVOKE_HOSTED_WINDOW) { + setupInvokeWindowWinTabBridge(); +} else { + exposeElectronAPI(); +} + +function setupInvokeWindowWinTabBridge() { + let bridgeEnabled = false; + let suppressionInstalled = false; + let mouseTrackingInstalled = false; + let syntheticPenSessionActive = false; + let syntheticMouseSessionActive = false; + let suppressPrimaryMouseUntil = 0; + let activeTarget: EventTarget | null = null; + let dispatchingSyntheticMouseEvent = false; + let activeAuxMouseButtons = 0; + + const stopEvent = (event: Event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + }; + + const isSyntheticWinTabPointerEvent = (event: Event) => { + return event instanceof PointerEvent && event.pointerId === SYNTHETIC_POINTER_ID && event.pointerType === 'pen'; + }; + + const hasPrimaryButton = (event: MouseEvent | PointerEvent) => { + if (event.type === 'mouseup' || event.type === 'pointerup') { + return true; + } + + if (event.button === 0) { + return true; + } + + return (event.buttons & 1) === 1; + }; + + const hasNonPrimaryButton = (event: MouseEvent | PointerEvent) => { + if (event.button === 1 || event.button === 2) { + return true; + } + + return (event.buttons & ~1) !== 0; + }; + + const shouldSuppressNativeEvent = (event: Event) => { + if (!bridgeEnabled) { + return false; + } + + if (dispatchingSyntheticMouseEvent) { + return false; + } + + if (isSyntheticWinTabPointerEvent(event)) { + return false; + } + + if (event instanceof PointerEvent && event.pointerType === 'pen') { + return true; + } + + if (syntheticPenSessionActive) { + if (event instanceof PointerEvent && event.pointerType === 'mouse') { + return hasNonPrimaryButton(event); + } + + if (event instanceof MouseEvent) { + return hasNonPrimaryButton(event); + } + } + + const now = performance.now(); + if (!syntheticPenSessionActive && now > suppressPrimaryMouseUntil) { + return false; + } + + if (event instanceof PointerEvent && event.pointerType === 'mouse') { + return hasPrimaryButton(event); + } + + if (event instanceof MouseEvent) { + return hasPrimaryButton(event); + } + + return false; + }; + + const suppressionHandler = (event: Event) => { + if (shouldSuppressNativeEvent(event)) { + stopEvent(event); + } + }; + + const installSuppression = () => { + if (suppressionInstalled) { + return; + } + + suppressionInstalled = true; + const pointerEvents = [ + 'pointerdown', + 'pointermove', + 'pointerup', + 'pointerenter', + 'pointerleave', + 'pointerover', + 'pointerout', + 'pointercancel', + 'mousedown', + 'mousemove', + 'mouseup', + 'click', + 'auxclick', + 'contextmenu', + ]; + + for (const eventType of pointerEvents) { + window.addEventListener(eventType, suppressionHandler, true); + } + }; + + const dispatchSyntheticPointer = (target: EventTarget, eventType: string, payload: WinTabPenEventPayload) => { + if (!(target instanceof Element || target instanceof Document || target instanceof Window)) { + return; + } + + const event = new PointerEvent(eventType, { + pointerId: SYNTHETIC_POINTER_ID, + pointerType: 'pen', + isPrimary: true, + bubbles: true, + cancelable: true, + composed: true, + clientX: payload.clientX, + clientY: payload.clientY, + screenX: payload.screenX, + screenY: payload.screenY, + pressure: payload.kind === 'up' ? 0 : payload.pressure, + button: payload.kind === 'move' ? -1 : 0, + buttons: payload.kind === 'up' ? 0 : 1, + width: 1, + height: 1, + }); + + target.dispatchEvent(event); + }; + + const dispatchSyntheticMouse = ( + target: EventTarget, + eventType: 'mousedown' | 'mousemove' | 'mouseup' | 'click', + payload: WinTabPenEventPayload + ) => { + if (!(target instanceof Element || target instanceof Document || target instanceof Window)) { + return; + } + + const buttons = eventType === 'mouseup' || eventType === 'click' ? 0 : 1; + const event = new MouseEvent(eventType, { + bubbles: true, + cancelable: true, + composed: true, + clientX: payload.clientX, + clientY: payload.clientY, + screenX: payload.screenX, + screenY: payload.screenY, + button: 0, + buttons, + detail: eventType === 'click' ? 1 : 0, + }); + + dispatchingSyntheticMouseEvent = true; + try { + target.dispatchEvent(event); + } finally { + dispatchingSyntheticMouseEvent = false; + } + }; + + const buildPayloadFromMouseEvent = (event: MouseEvent): WinTabPenEventPayload => ({ + kind: 'up', + clientX: event.clientX, + clientY: event.clientY, + screenX: event.screenX, + screenY: event.screenY, + pressure: 0, + buttons: 0, + }); + + const getElementTarget = (target: EventTarget | null): Element | null => { + return target instanceof Element ? target : null; + }; + + const getCurrentHoverTarget = (payload: WinTabPenEventPayload) => { + return document.elementFromPoint(payload.clientX, payload.clientY) ?? activeTarget ?? document.body; + }; + + const isSyntheticMouseTarget = (target: EventTarget | null) => { + const element = getElementTarget(target); + if (!element) { + return false; + } + + if (element instanceof HTMLCanvasElement) { + return false; + } + + return Boolean( + element.closest( + [ + 'button', + 'a[href]', + 'input', + 'select', + 'textarea', + 'label', + 'summary', + '[role="button"]', + '[role="link"]', + '[role="checkbox"]', + '[role="radio"]', + '[role="switch"]', + '[role="tab"]', + '[role="menuitem"]', + '[role="option"]', + '[role="slider"]', + '[contenteditable="true"]', + ].join(',') + ) + ); + }; + + const dismissTransientUi = (payload: WinTabPenEventPayload) => { + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement && activeElement !== document.body) { + activeElement.blur(); + } + + const dismissTarget = document.body ?? document.documentElement; + if (dismissTarget) { + const mouseDown = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + composed: true, + clientX: payload.clientX, + clientY: payload.clientY, + screenX: payload.screenX, + screenY: payload.screenY, + button: 0, + buttons: 1, + detail: 1, + }); + const mouseUp = new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + composed: true, + clientX: payload.clientX, + clientY: payload.clientY, + screenX: payload.screenX, + screenY: payload.screenY, + button: 0, + buttons: 0, + detail: 1, + }); + const click = new MouseEvent('click', { + bubbles: true, + cancelable: true, + composed: true, + clientX: payload.clientX, + clientY: payload.clientY, + screenX: payload.screenX, + screenY: payload.screenY, + button: 0, + buttons: 0, + detail: 1, + }); + + dispatchingSyntheticMouseEvent = true; + try { + dismissTarget.dispatchEvent(mouseDown); + dismissTarget.dispatchEvent(mouseUp); + dismissTarget.dispatchEvent(click); + } finally { + dispatchingSyntheticMouseEvent = false; + } + } + }; + + const resolveClickTarget = (currentTarget: EventTarget | null) => { + const downTarget = getElementTarget(activeTarget); + const upTarget = getElementTarget(currentTarget); + + if (!downTarget) { + return currentTarget; + } + + if (!upTarget) { + return downTarget; + } + + if (downTarget === upTarget || downTarget.contains(upTarget) || upTarget.contains(downTarget)) { + return downTarget; + } + + return null; + }; + + const getDispatchTarget = (payload: WinTabPenEventPayload) => { + if (payload.kind === 'up' && activeTarget) { + return activeTarget; + } + + return getCurrentHoverTarget(payload); + }; + + const endSyntheticPenSession = (payload: WinTabPenEventPayload) => { + if (!syntheticPenSessionActive) { + activeTarget = null; + syntheticMouseSessionActive = false; + return; + } + + const upTarget = getCurrentHoverTarget(payload); + const pointerTarget = activeTarget ?? upTarget; + const clickTarget = syntheticMouseSessionActive ? resolveClickTarget(upTarget) : null; + + dispatchSyntheticPointer(pointerTarget, 'pointerup', payload); + if (syntheticMouseSessionActive) { + dispatchSyntheticMouse(pointerTarget, 'mouseup', payload); + } + if (clickTarget) { + dispatchSyntheticMouse(clickTarget, 'click', payload); + } + + activeTarget = null; + syntheticPenSessionActive = false; + syntheticMouseSessionActive = false; + suppressPrimaryMouseUntil = performance.now() + SUPPRESS_PRIMARY_MOUSE_GRACE_MS; + }; + + const updateAuxMouseState = (event: MouseEvent) => { + activeAuxMouseButtons = event.buttons & ~1; + + if (dispatchingSyntheticMouseEvent || event.button === 0 || !syntheticPenSessionActive) { + return; + } + + endSyntheticPenSession(buildPayloadFromMouseEvent(event)); + }; + + const installMouseButtonTracking = () => { + if (mouseTrackingInstalled) { + return; + } + + mouseTrackingInstalled = true; + const mouseEvents = ['mousedown', 'mousemove', 'mouseup']; + for (const eventType of mouseEvents) { + window.addEventListener( + eventType, + (event) => { + if (!(event instanceof MouseEvent)) { + return; + } + updateAuxMouseState(event); + }, + true + ); + } + + window.addEventListener( + 'blur', + () => { + activeAuxMouseButtons = 0; + }, + true + ); + + document.addEventListener( + 'visibilitychange', + () => { + if (document.visibilityState !== 'visible') { + activeAuxMouseButtons = 0; + } + }, + true + ); + }; + + ipcRenderer.on(WINTAB_STATUS_CHANNEL, (_event, status: WinTabStatusPayload) => { + bridgeEnabled = status.enabled; + if (bridgeEnabled) { + installSuppression(); + installMouseButtonTracking(); + } + }); + + ipcRenderer.on(WINTAB_EVENT_CHANNEL, (_event, payload: WinTabPenEventPayload) => { + if (!bridgeEnabled) { + return; + } + + if (activeAuxMouseButtons !== 0) { + if (payload.kind === 'up') { + activeTarget = null; + syntheticPenSessionActive = false; + syntheticMouseSessionActive = false; + } + return; + } + + const target = getDispatchTarget(payload); + if (!target) { + return; + } + + if (payload.kind === 'down') { + let dispatchTarget = target; + let shouldUseSyntheticMouse = isSyntheticMouseTarget(dispatchTarget); + if (!shouldUseSyntheticMouse) { + dismissTransientUi(payload); + dispatchTarget = getCurrentHoverTarget(payload); + shouldUseSyntheticMouse = isSyntheticMouseTarget(dispatchTarget); + } + + activeTarget = dispatchTarget; + syntheticPenSessionActive = true; + syntheticMouseSessionActive = shouldUseSyntheticMouse; + dispatchSyntheticPointer(dispatchTarget, 'pointerdown', payload); + if (syntheticMouseSessionActive) { + dispatchSyntheticMouse(dispatchTarget, 'mousedown', payload); + } + return; + } + + if (payload.kind === 'move') { + if (!syntheticPenSessionActive) { + return; + } + dispatchSyntheticPointer(activeTarget ?? target, 'pointermove', payload); + if (syntheticMouseSessionActive) { + dispatchSyntheticMouse(getCurrentHoverTarget(payload), 'mousemove', payload); + } + return; + } + + if (payload.kind === 'up') { + endSyntheticPenSession(payload); + } + }); +}