From 5aae4b39aa18be85f7a01eef5bfe5cdeb93a536a Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Fri, 21 Aug 2026 08:50:18 +1200 Subject: [PATCH 1/6] fix(windows): dispatch hotkeys with a thread-safe function Replace AsyncWorker callbacks queued from the polling thread with a per-environment N-API thread-safe function. Add safe teardown and Electron 29/43 physical hotkey regression tests. --- .github/workflows/build.yml | 36 +++- CMakeLists.txt | 5 + source/hook-win.cpp | 308 ++++++++++++++++++++++--------- source/hook.h | 5 + source/module.cpp | 14 +- test/preload_hotkey_win.js | 59 ++++++ test/run_electron_test.js | 84 +++++++++ test/send_input_win.cpp | 50 +++++ test/test_hotkey_teardown_win.js | 164 ++++++++++++++++ test/test_hotkey_win.js | 202 ++++++++++++++++++++ test/test_hotkey_worker_win.js | 178 ++++++++++++++++++ 11 files changed, 1019 insertions(+), 86 deletions(-) create mode 100644 test/preload_hotkey_win.js create mode 100644 test/run_electron_test.js create mode 100644 test/send_input_win.cpp create mode 100644 test/test_hotkey_teardown_win.js create mode 100644 test/test_hotkey_win.js create mode 100644 test/test_hotkey_worker_win.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ed553b6..88d0d62 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,11 +47,43 @@ jobs: - name: Add MSBuild to PATH uses: microsoft/setup-msbuild@v1 - name: Configure - run: cmake -H"${{ github.workspace }}" -B"${{env.BUILD_DIRECTORY}}" -G"Visual Studio 17 2022" -A x64 -DNODEJS_VERSION="${{env.ELECTRON_VERSION}}" -DCMAKE_INSTALL_PREFIX="${{env.INSTALL_PACKAGE_PATH}}" + run: cmake -H"${{ github.workspace }}" -B"${{env.BUILD_DIRECTORY}}" -G"Visual Studio 17 2022" -A x64 -DNODEJS_VERSION="${{env.ELECTRON_VERSION}}" -DNODE_LIBUIOHOOK_BUILD_TESTS=ON -DCMAKE_INSTALL_PREFIX="${{env.INSTALL_PACKAGE_PATH}}" env: INSTALL_PACKAGE_PATH: "${{env.BUILD_DIRECTORY}}/${{env.DISTRIBUTE_DIRECTORY}}/${{env.PACKAGE_DIRECTORY}}" - name: Build run: cmake --build "${{env.BUILD_DIRECTORY}}" --target install --config ${{env.BUILD_CONFIGURATION}} + - name: Install exact Electron 29 test runtime + run: npm install --prefix "${{ runner.temp }}/electron29" --no-save electron@29.4.3 + - name: Test hotkey callbacks with Electron 29 + run: node test/run_electron_test.js "${{ runner.temp }}/electron29/node_modules/electron/dist/electron.exe" test/test_hotkey_win.js build/RelWithDebInfo/node_libuiohook.node build/RelWithDebInfo/node_libuiohook_send_input.exe + env: + EXPECTED_ELECTRON_VERSION: '29.4.3' + - name: Test hotkey teardown with Electron 29 + run: node test/run_electron_test.js "${{ runner.temp }}/electron29/node_modules/electron/dist/electron.exe" test/test_hotkey_teardown_win.js build/RelWithDebInfo/node_libuiohook.node build/RelWithDebInfo/node_libuiohook_send_input.exe + env: + EXPECTED_ELECTRON_VERSION: '29.4.3' + - name: Test worker hotkey callbacks with Electron 29 + run: node test/run_electron_test.js "${{ runner.temp }}/electron29/node_modules/electron/dist/electron.exe" test/test_hotkey_worker_win.js build/RelWithDebInfo/node_libuiohook.node build/RelWithDebInfo/node_libuiohook_send_input.exe + env: + EXPECTED_ELECTRON_VERSION: '29.4.3' + - name: Install Node.js 22 for Electron 43 + uses: actions/setup-node@v4 + with: + node-version: '22.12.0' + - name: Install Electron 43 test runtime + run: npm install --prefix "${{ runner.temp }}/electron43" --no-save electron@43.2.0 + - name: Test hotkey callbacks with Electron 43 + run: node test/run_electron_test.js "${{ runner.temp }}/electron43/node_modules/electron/dist/electron.exe" test/test_hotkey_win.js build/RelWithDebInfo/node_libuiohook.node build/RelWithDebInfo/node_libuiohook_send_input.exe + env: + EXPECTED_ELECTRON_VERSION: '43.2.0' + - name: Test hotkey teardown with Electron 43 + run: node test/run_electron_test.js "${{ runner.temp }}/electron43/node_modules/electron/dist/electron.exe" test/test_hotkey_teardown_win.js build/RelWithDebInfo/node_libuiohook.node build/RelWithDebInfo/node_libuiohook_send_input.exe + env: + EXPECTED_ELECTRON_VERSION: '43.2.0' + - name: Test worker hotkey callbacks with Electron 43 + run: node test/run_electron_test.js "${{ runner.temp }}/electron43/node_modules/electron/dist/electron.exe" test/test_hotkey_worker_win.js build/RelWithDebInfo/node_libuiohook.node build/RelWithDebInfo/node_libuiohook_send_input.exe + env: + EXPECTED_ELECTRON_VERSION: '43.2.0' - name: Put version into package.json if: startsWith(github.ref, 'refs/tags/') run: node ci/bump-version.js "${{ steps.get_version.outputs.VERSION }}" "${{env.PACKAGE_PATH}}" @@ -218,4 +250,4 @@ jobs: - name: Deploy run: aws s3 cp ${{env.TARGET_ARTIFACT}}.tar.gz s3://${{env.RELEASE_BUCKET}} --acl public-read env: - TARGET_ARTIFACT: ${{env.PACKAGE_NAME}}-${{ steps.get_version.outputs.VERSION }}-${{env.OS_TAG}}-${{ matrix.arch }} \ No newline at end of file + TARGET_ARTIFACT: ${{env.PACKAGE_NAME}}-${{ steps.get_version.outputs.VERSION }}-${{env.OS_TAG}}-${{ matrix.arch }} diff --git a/CMakeLists.txt b/CMakeLists.txt index d01eeb7..4a5deb0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -95,6 +95,11 @@ target_compile_definitions( PRIVATE BUILDING_NODE_EXTENSION ) +option(NODE_LIBUIOHOOK_BUILD_TESTS "Build the Windows hotkey test helper" OFF) +if(WIN32 AND NODE_LIBUIOHOOK_BUILD_TESTS) + add_executable(node_libuiohook_send_input "${PROJECT_SOURCE_DIR}/test/send_input_win.cpp") +endif() + set(CompilerFlags CMAKE_CXX_FLAGS CMAKE_CXX_FLAGS_DEBUG diff --git a/source/hook-win.cpp b/source/hook-win.cpp index 879d5b1..5682406 100644 --- a/source/hook-win.cpp +++ b/source/hook-win.cpp @@ -18,29 +18,29 @@ #include "hook.h" -#include -#include +#include +#include +#include #include +#include +#include +#include +#include #include -#include +#include #include -#include #include -class Worker : public Napi::AsyncWorker { -public: - Worker(Napi::Function &callback) : AsyncWorker(callback){}; - virtual ~Worker(){}; +typedef int16_t key_t; - void Execute(){}; - void OnOK() { Callback().Call({}); }; +struct HotKeyCallback { + napi_ref callback = nullptr; + uint64_t generation = 0; }; -typedef int16_t key_t; - struct HotKey { std::vector> keys; - std::unique_ptr cbDown, cbUp; + HotKeyCallback cbDown, cbUp; bool wasDown = false; static uint32_t Stringify(std::vector> keys) @@ -58,9 +58,162 @@ struct ThreadData { std::mutex mtx; std::thread worker; std::map hotkeys; + napi_env env = nullptr; + napi_threadsafe_function dispatcher = nullptr; + uint64_t nextGeneration = 0; + std::atomic shutdown{false}; +}; + +enum class HotKeyEdge { Down, Up }; + +struct HotKeyEvent { + uint32_t key; + HotKeyEdge edge; + uint64_t generation; +}; + +static HotKeyCallback &GetCallback(HotKey &hotkey, HotKeyEdge edge) +{ + return edge == HotKeyEdge::Down ? hotkey.cbDown : hotkey.cbUp; +} + +static void DispatchHotKeyEvent(napi_env env, napi_value, void *context, void *data) +{ + std::unique_ptr event(static_cast(data)); + if (env == nullptr || event == nullptr) + return; + + ThreadData *td = static_cast(context); + napi_value callback = nullptr; + { + std::unique_lock ulock(td->mtx); + auto hotkey = td->hotkeys.find(event->key); + if (hotkey == td->hotkeys.end()) + return; + + HotKeyCallback ®istered = GetCallback(hotkey->second, event->edge); + if (registered.callback == nullptr || registered.generation != event->generation) + return; + + if (napi_get_reference_value(env, registered.callback, &callback) != napi_ok || callback == nullptr) + return; + } + + napi_value receiver; + if (napi_get_undefined(env, &receiver) != napi_ok) + return; + + napi_status status = napi_call_function(env, receiver, callback, 0, nullptr, nullptr); + if (status != napi_ok && status != napi_pending_exception) + napi_throw_error(env, nullptr, "Failed to invoke hotkey callback"); +} + +static void QueueHotKeyEvent(ThreadData *td, uint32_t key, HotKeyEdge edge, const HotKeyCallback &callback) +{ + if (td->dispatcher == nullptr || callback.callback == nullptr) + return; + + HotKeyEvent *event = new (std::nothrow) HotKeyEvent{key, edge, callback.generation}; + if (event == nullptr) + return; + + if (napi_call_threadsafe_function(td->dispatcher, event, napi_tsfn_nonblocking) != napi_ok) + delete event; +} + +static void DeleteCallback(napi_env env, HotKeyCallback &callback) +{ + if (callback.callback != nullptr) { + napi_delete_reference(env, callback.callback); + callback.callback = nullptr; + } + callback.generation = 0; +} + +static void ClearHotkeys(ThreadData *td) +{ + std::unique_lock ulock(td->mtx); + for (auto &hotkey : td->hotkeys) { + DeleteCallback(td->env, hotkey.second.cbDown); + DeleteCallback(td->env, hotkey.second.cbUp); + } + td->hotkeys.clear(); +} + +static bool StopHotkeyThread(ThreadData *td) +{ + if (!td->worker.joinable()) + return false; + + td->shutdown.store(true, std::memory_order_release); + td->worker.join(); + return true; +} + +static void CleanupHotkeyThread(void *data) +{ + ThreadData *td = static_cast(data); + StopHotkeyThread(td); + ClearHotkeys(td); + td->env = nullptr; + + if (td->dispatcher != nullptr) { + napi_threadsafe_function dispatcher = td->dispatcher; + td->dispatcher = nullptr; + napi_release_threadsafe_function(dispatcher, napi_tsfn_abort); + } +} + +static void FinalizeHotkeyThread(napi_env, void *data, void *) +{ + delete static_cast(data); +} + +static ThreadData *ThrowInitializationError(Napi::Env env, const char *message) +{ + Napi::Error::New(env, message).ThrowAsJavaScriptException(); + return nullptr; +} + +ThreadData *InitializeHotkeyThread(Napi::Env env) +{ + ThreadData *td = new (std::nothrow) ThreadData; + if (td == nullptr) + return ThrowInitializationError(env, "Failed to allocate hotkey state"); + + Napi::Function callback = Napi::Function::New(env, [](const Napi::CallbackInfo &) {}); + Napi::String resourceName = Napi::String::New(env, "node-libuiohook hotkey dispatcher"); + td->env = env; + + napi_status status = + napi_create_threadsafe_function(env, callback, nullptr, resourceName, 0, 1, td, FinalizeHotkeyThread, td, DispatchHotKeyEvent, &td->dispatcher); + if (status != napi_ok) { + delete td; + return ThrowInitializationError(env, "Failed to create the hotkey callback dispatcher"); + } - bool shutdown = false; -} gThreadData; + status = napi_unref_threadsafe_function(env, td->dispatcher); + if (status != napi_ok) { + napi_threadsafe_function dispatcher = td->dispatcher; + td->dispatcher = nullptr; + td->env = nullptr; + napi_release_threadsafe_function(dispatcher, napi_tsfn_abort); + return ThrowInitializationError(env, "Failed to unreference the hotkey callback dispatcher"); + } + + // Cleanup hooks run in reverse registration order. Register this after the + // dispatcher so the polling thread is joined before Node tears it down. + status = napi_add_env_cleanup_hook(env, CleanupHotkeyThread, td); + if (status != napi_ok) { + napi_threadsafe_function dispatcher = td->dispatcher; + td->dispatcher = nullptr; + td->env = nullptr; + napi_release_threadsafe_function(dispatcher, napi_tsfn_abort); + return ThrowInitializationError(env, "Failed to register hotkey cleanup"); + } + + return td; +} static bool isKeyDown(key_t k) { @@ -76,7 +229,7 @@ static int32_t HotKeyThread(void *arg) std::unique_lock ulock(td->mtx); } - while (!td->shutdown) { + while (!td->shutdown.load(std::memory_order_acquire)) { // Test each hotkey { std::unique_lock ulock(td->mtx); @@ -97,13 +250,11 @@ static int32_t HotKeyThread(void *arg) } if (allPressed && !hk.second.wasDown) { - if (hk.second.cbDown != nullptr) - hk.second.cbDown->Queue(); + QueueHotKeyEvent(td, hk.first, HotKeyEdge::Down, hk.second.cbDown); hk.second.wasDown = true; } else if (!allPressed && hk.second.wasDown) { - if (hk.second.cbUp != nullptr) - hk.second.cbUp->Queue(); + QueueHotKeyEvent(td, hk.first, HotKeyEdge::Up, hk.second.cbUp); hk.second.wasDown = false; } @@ -139,25 +290,22 @@ template void tokenize(const std::string &str, ContainerT &tok Napi::Value StartHotkeyThreadJS(const Napi::CallbackInfo &info) { - if (gThreadData.worker.joinable()) + ThreadData *td = static_cast(info.Data()); + if (td->worker.joinable()) return Napi::Boolean::New(info.Env(), false); - gThreadData.mtx.lock(); - gThreadData.worker = std::thread(HotKeyThread, &gThreadData); - gThreadData.mtx.unlock(); + std::unique_lock ulock(td->mtx); + for (auto &hotkey : td->hotkeys) + hotkey.second.wasDown = false; + td->shutdown.store(false, std::memory_order_release); + td->worker = std::thread(HotKeyThread, td); return Napi::Boolean::New(info.Env(), true); } Napi::Value StopHotkeyThreadJS(const Napi::CallbackInfo &info) { - if (!gThreadData.worker.joinable()) - return Napi::Boolean::New(info.Env(), false); - - gThreadData.shutdown = true; - gThreadData.worker.join(); - - return Napi::Boolean::New(info.Env(), true); + return Napi::Boolean::New(info.Env(), StopHotkeyThread(static_cast(info.Data()))); } std::vector> StringToKeys(std::string keystr, Napi::Object modifiers) @@ -424,6 +572,7 @@ std::vector> StringToKeys(std::string keystr, Napi::Objec Napi::Value RegisterHotkeyJS(const Napi::CallbackInfo &info) { + ThreadData *td = static_cast(info.Data()); /* interface INodeLibuiohookBinding { * callback: () => void; * eventType: TKeyEventType; @@ -445,53 +594,47 @@ Napi::Value RegisterHotkeyJS(const Napi::CallbackInfo &info) return Napi::Boolean::New(info.Env(), false); uint32_t key = HotKey::Stringify(keys); - if (gThreadData.hotkeys.count(key)) { - auto hk = gThreadData.hotkeys.find(key); - - // Lock mutex for modifications - - if (eventString == "registerKeydown") { - if (!hk->second.cbDown) { - // Lock mutex for modifications - std::unique_lock ulock(gThreadData.mtx); - hk->second.cbDown = std::make_unique(binds.Get("callback").As()); - hk->second.cbDown->SuppressDestruct(); - } else { - return Napi::Boolean::New(info.Env(), false); - } - } else if (eventString == "registerKeyup") { - if (!hk->second.cbUp) { - // Lock mutex for modifications - std::unique_lock ulock(gThreadData.mtx); - hk->second.cbUp = std::make_unique(binds.Get("callback").As()); - hk->second.cbUp->SuppressDestruct(); - } else { - return Napi::Boolean::New(info.Env(), false); - } - } + HotKeyEdge edge; + if (eventString == "registerKeydown") { + edge = HotKeyEdge::Down; + } else if (eventString == "registerKeyup") { + edge = HotKeyEdge::Up; } else { - HotKey hk; - hk.keys = std::move(keys); - hk.wasDown = false; - - if (eventString == "registerKeydown") { - hk.cbDown = std::make_unique(binds.Get("callback").As()); - hk.cbDown->SuppressDestruct(); - } else if (eventString == "registerKeyup") { - hk.cbUp = std::make_unique(binds.Get("callback").As()); - hk.cbUp->SuppressDestruct(); - } + return Napi::Boolean::New(info.Env(), false); + } - // Lock mutex for modifications - std::unique_lock ulock(gThreadData.mtx); - gThreadData.hotkeys.insert_or_assign(key, std::move(hk)); + Napi::Function callback = binds.Get("callback").As(); + std::unique_lock ulock(td->mtx); + auto hotkey = td->hotkeys.find(key); + if (hotkey == td->hotkeys.end()) { + HotKey registeredHotkey; + registeredHotkey.keys = std::move(keys); + hotkey = td->hotkeys.insert_or_assign(key, std::move(registeredHotkey)).first; } + HotKeyCallback ®istered = GetCallback(hotkey->second, edge); + if (registered.callback != nullptr) + return Napi::Boolean::New(info.Env(), false); + + napi_status status = napi_create_reference(info.Env(), callback, 1, ®istered.callback); + if (status != napi_ok) { + registered.callback = nullptr; + if (hotkey->second.cbDown.callback == nullptr && hotkey->second.cbUp.callback == nullptr) + td->hotkeys.erase(hotkey); + Napi::Error::New(info.Env(), "Failed to retain hotkey callback").ThrowAsJavaScriptException(); + return Napi::Boolean::New(info.Env(), false); + } + + registered.generation = ++td->nextGeneration; + if (registered.generation == 0) + registered.generation = ++td->nextGeneration; + return Napi::Boolean::New(info.Env(), true); } Napi::Value UnregisterHotkeyJS(const Napi::CallbackInfo &info) { + ThreadData *td = static_cast(info.Data()); Napi::Object binds = info[0].ToObject(); std::vector> keys = StringToKeys(binds.Get("key").ToString().Utf8Value(), binds.Get("modifiers").ToObject()); std::string eventString = binds.Get("eventType").ToString().Utf8Value(); @@ -500,39 +643,38 @@ Napi::Value UnregisterHotkeyJS(const Napi::CallbackInfo &info) return Napi::Boolean::New(info.Env(), false); uint32_t key = HotKey::Stringify(keys); - if (!gThreadData.hotkeys.count(key)) { + std::unique_lock ulock(td->mtx); + auto hk = td->hotkeys.find(key); + if (hk == td->hotkeys.end()) { std::cout << "Cannot find key " << key << std::endl; return Napi::Boolean::New(info.Env(), false); } - // Lock mutex for modifications - std::unique_lock ulock(gThreadData.mtx); - - auto hk = gThreadData.hotkeys.find(key); if (eventString == "registerKeydown") { - if (hk->second.cbDown) { - hk->second.cbDown = nullptr; + if (hk->second.cbDown.callback != nullptr) { + DeleteCallback(info.Env(), hk->second.cbDown); } else { return Napi::Boolean::New(info.Env(), false); } } else if (eventString == "registerKeyup") { - if (hk->second.cbUp) { - hk->second.cbUp = nullptr; + if (hk->second.cbUp.callback != nullptr) { + DeleteCallback(info.Env(), hk->second.cbUp); } else { return Napi::Boolean::New(info.Env(), false); } + } else { + return Napi::Boolean::New(info.Env(), false); } // If both callbacks were removed, don't bother keeping the object around. - if ((hk->second.cbUp == nullptr) && (hk->second.cbDown == nullptr)) { - gThreadData.hotkeys.erase(key); + if (hk->second.cbUp.callback == nullptr && hk->second.cbDown.callback == nullptr) { + td->hotkeys.erase(key); } return Napi::Boolean::New(info.Env(), true); } Napi::Value UnregisterHotkeysJS(const Napi::CallbackInfo &info) { - std::unique_lock ulock(gThreadData.mtx); - gThreadData.hotkeys.clear(); + ClearHotkeys(static_cast(info.Data())); return info.Env().Undefined(); -} \ No newline at end of file +} diff --git a/source/hook.h b/source/hook.h index 0123f4f..b7fd3f8 100644 --- a/source/hook.h +++ b/source/hook.h @@ -25,3 +25,8 @@ Napi::Value StopHotkeyThreadJS(const Napi::CallbackInfo &info); Napi::Value RegisterHotkeyJS(const Napi::CallbackInfo &info); Napi::Value UnregisterHotkeyJS(const Napi::CallbackInfo &info); Napi::Value UnregisterHotkeysJS(const Napi::CallbackInfo &info); + +#ifdef _WIN32 +struct ThreadData; +ThreadData *InitializeHotkeyThread(Napi::Env env); +#endif diff --git a/source/module.cpp b/source/module.cpp index 376cb3b..9e60652 100644 --- a/source/module.cpp +++ b/source/module.cpp @@ -17,11 +17,23 @@ void Init(Napi::Env env, Napi::Object exports) { +#ifdef _WIN32 + ThreadData *threadData = InitializeHotkeyThread(env); + if (threadData == nullptr) + return; + + exports.Set(Napi::String::New(env, "startHook"), Napi::Function::New(env, StartHotkeyThreadJS, "startHook", threadData)); + exports.Set(Napi::String::New(env, "stopHook"), Napi::Function::New(env, StopHotkeyThreadJS, "stopHook", threadData)); + exports.Set(Napi::String::New(env, "registerCallback"), Napi::Function::New(env, RegisterHotkeyJS, "registerCallback", threadData)); + exports.Set(Napi::String::New(env, "unregisterCallback"), Napi::Function::New(env, UnregisterHotkeyJS, "unregisterCallback", threadData)); + exports.Set(Napi::String::New(env, "unregisterAllCallbacks"), Napi::Function::New(env, UnregisterHotkeysJS, "unregisterAllCallbacks", threadData)); +#else exports.Set(Napi::String::New(env, "startHook"), Napi::Function::New(env, StartHotkeyThreadJS)); exports.Set(Napi::String::New(env, "stopHook"), Napi::Function::New(env, StopHotkeyThreadJS)); exports.Set(Napi::String::New(env, "registerCallback"), Napi::Function::New(env, RegisterHotkeyJS)); exports.Set(Napi::String::New(env, "unregisterCallback"), Napi::Function::New(env, UnregisterHotkeyJS)); exports.Set(Napi::String::New(env, "unregisterAllCallbacks"), Napi::Function::New(env, UnregisterHotkeysJS)); +#endif } Napi::Object main_node(Napi::Env env, Napi::Object exports) @@ -30,4 +42,4 @@ Napi::Object main_node(Napi::Env env, Napi::Object exports) return exports; } -NODE_API_MODULE(uiohookModule, main_node) \ No newline at end of file +NODE_API_MODULE(uiohookModule, main_node) diff --git a/test/preload_hotkey_win.js b/test/preload_hotkey_win.js new file mode 100644 index 0000000..6ede3f9 --- /dev/null +++ b/test/preload_hotkey_win.js @@ -0,0 +1,59 @@ +const { ipcRenderer } = require('electron'); + +const addonArgument = process.argv.find(argument => argument.startsWith('--hotkey-addon=')); +if (!addonArgument) throw new Error('Missing --hotkey-addon argument'); + +const libuiohook = require(addonArgument.slice('--hotkey-addon='.length)); +let hookStarted = false; + +function binding(eventType, generation) { + return { + callback: () => ipcRenderer.send('hotkey-callback', { eventType, generation }), + key: 'F24', + eventType, + modifiers: { alt: false, ctrl: false, shift: false, meta: false }, + }; +} + +ipcRenderer.on('hotkey-command', (_event, command) => { + try { + let result; + if (command.action === 'start-and-register') { + if (hookStarted) throw new Error('Hook is already started'); + if (!libuiohook.startHook()) throw new Error('Failed to start hook'); + hookStarted = true; + if (!libuiohook.registerCallback(binding('registerKeydown', command.generation))) { + throw new Error('Failed to register keydown callback'); + } + if (!libuiohook.registerCallback(binding('registerKeyup', command.generation))) { + throw new Error('Failed to register keyup callback'); + } + result = true; + } else if (command.action === 'unregister-all') { + libuiohook.unregisterAllCallbacks(); + result = true; + } else if (command.action === 'stop') { + result = libuiohook.stopHook(); + hookStarted = false; + } else if (command.action === 'cleanup') { + libuiohook.unregisterAllCallbacks(); + result = hookStarted ? libuiohook.stopHook() : true; + hookStarted = false; + } else { + throw new Error(`Unknown action: ${command.action}`); + } + + ipcRenderer.send('hotkey-command-result', { id: command.id, result }); + } catch (error) { + ipcRenderer.send('hotkey-command-result', { + id: command.id, + error: error instanceof Error ? error.stack : String(error), + }); + } +}); + +ipcRenderer.send('hotkey-ready', { + electron: process.versions.electron, + node: process.versions.node, + napi: process.versions.napi, +}); diff --git a/test/run_electron_test.js b/test/run_electron_test.js new file mode 100644 index 0000000..07bfeea --- /dev/null +++ b/test/run_electron_test.js @@ -0,0 +1,84 @@ +const { spawn } = require('child_process'); +const path = require('path'); + +if (process.argv.length < 4) { + console.error('Usage: node run_electron_test.js [args...]'); + process.exit(2); +} + +const electronPath = path.resolve(process.argv[2]); +const testArguments = process.argv.slice(3); +let output = ''; +let spawnError; +let timedOut = false; +let forceExitTimeout; + +const child = spawn(electronPath, testArguments, { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, +}); + +child.stdout.on('data', data => { + output += data.toString(); + process.stdout.write(data); +}); +child.stderr.on('data', data => process.stderr.write(data)); +child.once('error', error => { + spawnError = error; +}); + +const timeout = setTimeout(() => { + timedOut = true; + if (process.platform === 'win32') { + const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true, + }); + killer.once('error', () => child.kill()); + } else { + child.kill('SIGKILL'); + } + + forceExitTimeout = setTimeout(() => { + console.error(JSON.stringify({ status: 'FAIL', error: 'Electron process tree did not exit' })); + process.exit(1); + }, 5000); +}, 45000); + +child.once('close', (code, signal) => { + clearTimeout(timeout); + clearTimeout(forceExitTimeout); + + const passRecords = output + .split(/\r?\n/) + .map(line => line.trim()) + .map(line => { + try { + return JSON.parse(line); + } catch (_error) { + return undefined; + } + }) + .filter(record => record?.status === 'PASS'); + + const expectedElectron = process.env.EXPECTED_ELECTRON_VERSION; + const versionMatches = + !expectedElectron || + (passRecords.length === 1 && passRecords[0].electron === expectedElectron); + + if (spawnError || timedOut || code !== 0 || passRecords.length !== 1 || !versionMatches) { + console.error( + JSON.stringify({ + status: 'FAIL', + error: spawnError ? spawnError.stack : undefined, + timedOut, + exitCode: code, + signal, + passRecords: passRecords.length, + expectedElectron, + actualElectron: passRecords.length === 1 ? passRecords[0].electron : undefined, + }), + ); + process.exitCode = 1; + } +}); diff --git a/test/send_input_win.cpp b/test/send_input_win.cpp new file mode 100644 index 0000000..b351a28 --- /dev/null +++ b/test/send_input_win.cpp @@ -0,0 +1,50 @@ +#include + +#include + +static bool SendF24(INPUT &input, bool keyUp) +{ + input.ki.dwFlags = keyUp ? KEYEVENTF_KEYUP : 0; + return SendInput(1, &input, sizeof(input)) == 1; +} + +int main(int argc, char **argv) +{ + const int repetitions = argc > 1 ? std::atoi(argv[1]) : 1; + const DWORD holdMilliseconds = argc > 2 ? static_cast(std::atoi(argv[2])) : 15; + if (repetitions < 0 || holdMilliseconds == 0) + return 2; + + INPUT input = {}; + input.type = INPUT_KEYBOARD; + input.ki.wVk = VK_F24; + int result = 0; + + // Normalize state left behind by an interrupted prior run before generating + // any test input. A duplicate key-up does not create another transition. + if (!SendF24(input, true)) + result = 5; + Sleep(holdMilliseconds); + + for (int index = 0; result == 0 && index < repetitions; ++index) { + if (!SendF24(input, false)) { + result = 3; + break; + } + + Sleep(holdMilliseconds); + if (!SendF24(input, true)) { + result = 4; + break; + } + + Sleep(holdMilliseconds); + } + + // Best effort even after a failed key-down/key-up, so a failed test does not + // leave F24 pressed for later jobs or a developer's desktop session. + if (!SendF24(input, true) && result == 0) + result = 6; + + return result; +} diff --git a/test/test_hotkey_teardown_win.js b/test/test_hotkey_teardown_win.js new file mode 100644 index 0000000..8f98ece --- /dev/null +++ b/test/test_hotkey_teardown_win.js @@ -0,0 +1,164 @@ +const { app, BrowserWindow, ipcMain } = require('electron'); +const { spawn } = require('child_process'); +const path = require('path'); + +const addonPath = path.resolve(process.argv[2]); +const inputHelperPath = path.resolve(process.argv[3]); +let inputProcess; +let rendererVersions; +let window; +let finished = false; +let inputExited = false; +let rendererExited = false; +let rendererExitRequested = false; +let rendererPid; + +const timeout = setTimeout(() => fail(new Error('Hotkey teardown test timed out')), 20000); +app.disableHardwareAcceleration(); + +app.on('window-all-closed', () => { + // Keep the main process alive until the input helper and cleanup assertions finish. +}); + +function fail(error) { + if (finished) return; + finished = true; + clearTimeout(timeout); + if (inputProcess && !inputProcess.killed) inputProcess.kill(); + console.error( + JSON.stringify({ status: 'FAIL', error: error instanceof Error ? error.stack : String(error) }), + ); + app.exit(1); +} + +function pass() { + if (finished) return; + finished = true; + clearTimeout(timeout); + console.log( + JSON.stringify({ + status: 'PASS', + electron: rendererVersions.electron, + node: rendererVersions.node, + napi: rendererVersions.napi, + }), + ); + app.exit(0); +} + +function maybePass() { + if (inputExited && rendererExited) pass(); +} + +function waitForProcessExit(pid, milliseconds = 5000) { + const deadline = Date.now() + milliseconds; + return new Promise((resolve, reject) => { + const poll = () => { + try { + process.kill(pid, 0); + } catch (error) { + if (error.code === 'ESRCH') { + resolve(); + return; + } + if (error.code !== 'EPERM') { + reject(error); + return; + } + } + + if (Date.now() >= deadline) { + reject(new Error(`Renderer process ${pid} did not exit`)); + return; + } + setTimeout(poll, 25); + }; + poll(); + }); +} + +function runInput(repetitions) { + return new Promise((resolve, reject) => { + inputProcess = spawn(inputHelperPath, [String(repetitions), '75'], { + stdio: 'inherit', + windowsHide: true, + }); + inputProcess.once('error', reject); + inputProcess.once('exit', code => { + if (code === 0) resolve(); + else reject(new Error(`Input helper exited with code ${code}`)); + }); + }); +} + +ipcMain.once('hotkey-ready', (_event, versions) => { + rendererVersions = versions; + runInput(0) + .then(() => { + window.webContents.send('hotkey-command', { + id: 1, + action: 'start-and-register', + generation: 1, + }); + }) + .catch(fail); +}); + +ipcMain.once('hotkey-command-result', (_event, response) => { + if (response.error) { + fail(new Error(response.error)); + return; + } + + runInput(25) + .then(() => { + inputExited = true; + maybePass(); + }) + .catch(fail); +}); + +ipcMain.once('hotkey-callback', () => { + // Do not unregister or stop. Destroying the renderer must invoke the addon's + // environment cleanup before Node tears down its thread-safe dispatcher. + const webContents = window.webContents; + rendererPid = webContents.getOSProcessId(); + rendererExitRequested = true; + webContents.once('destroyed', () => { + waitForProcessExit(rendererPid) + .then(() => { + // A crash notification can be queued just behind process termination. + // Let it run before the successful teardown gate is opened. + setTimeout(() => { + rendererExited = true; + maybePass(); + }, 1000); + }) + .catch(fail); + }); + window.destroy(); +}); + +app.whenReady().then(() => { + window = new BrowserWindow({ + show: false, + webPreferences: { + preload: path.join(__dirname, 'preload_hotkey_win.js'), + nodeIntegration: true, + contextIsolation: false, + sandbox: false, + additionalArguments: [`--hotkey-addon=${addonPath}`], + }, + }); + window.webContents.on('render-process-gone', (_event, details) => { + if (!rendererExitRequested) { + fail(new Error(`Renderer exited before teardown: ${details.reason}`)); + return; + } + if (details.reason !== 'clean-exit' && details.reason !== 'killed') { + fail(new Error(`Renderer exited: ${details.reason}`)); + return; + } + }); + window.loadFile(path.join(__dirname, 'index.html')); +}); diff --git a/test/test_hotkey_win.js b/test/test_hotkey_win.js new file mode 100644 index 0000000..fc6d38e --- /dev/null +++ b/test/test_hotkey_win.js @@ -0,0 +1,202 @@ +const { app, BrowserWindow, ipcMain } = require('electron'); +const { spawn } = require('child_process'); +const path = require('path'); + +const addonPath = path.resolve(process.argv[2]); +const inputHelperPath = path.resolve(process.argv[3]); +const callbacks = []; +const pendingCommands = new Map(); +let commandId = 0; +let rendererVersions; +let window; +let finished = false; +let rendererExitRequested = false; + +const timeout = setTimeout(() => fail(new Error('Hotkey test timed out')), 30000); +app.disableHardwareAcceleration(); + +app.on('window-all-closed', () => { + // Keep the main process alive until the renderer process has actually exited. +}); + +function delay(milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} + +function waitForProcessExit(pid, milliseconds = 5000) { + const deadline = Date.now() + milliseconds; + return new Promise((resolve, reject) => { + const poll = () => { + try { + process.kill(pid, 0); + } catch (error) { + if (error.code === 'ESRCH') { + resolve(); + return; + } + if (error.code !== 'EPERM') { + reject(error); + return; + } + } + + if (Date.now() >= deadline) { + reject(new Error(`Renderer process ${pid} did not exit`)); + return; + } + setTimeout(poll, 25); + }; + poll(); + }); +} + +function fail(error) { + if (finished) return; + finished = true; + clearTimeout(timeout); + console.error( + JSON.stringify({ status: 'FAIL', error: error instanceof Error ? error.stack : String(error) }), + ); + app.exit(1); +} + +function sendCommand(action, generation) { + return new Promise((resolve, reject) => { + const id = ++commandId; + pendingCommands.set(id, { resolve, reject }); + window.webContents.send('hotkey-command', { id, action, generation }); + }); +} + +function runInput(repetitions) { + return new Promise((resolve, reject) => { + const child = spawn(inputHelperPath, [String(repetitions), '75'], { + stdio: 'inherit', + windowsHide: true, + }); + child.once('error', reject); + child.once('exit', code => { + if (code === 0) resolve(); + else reject(new Error(`Input helper exited with code ${code}`)); + }); + }); +} + +function waitForCallbacks(expectedCount) { + return new Promise((resolve, reject) => { + const callbackTimeout = setTimeout( + () => reject(new Error(`Expected ${expectedCount} callbacks, received ${callbacks.length}`)), + 10000, + ); + const check = () => { + if (callbacks.length < expectedCount) return; + clearTimeout(callbackTimeout); + ipcMain.removeListener('hotkey-callback', check); + resolve(); + }; + ipcMain.on('hotkey-callback', check); + check(); + }); +} + +function assertCallbackSequence(events, generation, repetitions) { + if (events.length !== repetitions * 2) { + throw new Error(`Expected ${repetitions * 2} events, received ${events.length}`); + } + + events.forEach((event, index) => { + const expectedType = index % 2 === 0 ? 'registerKeydown' : 'registerKeyup'; + if (event.eventType !== expectedType || event.generation !== generation) { + throw new Error(`Unexpected callback at index ${index}: ${JSON.stringify(event)}`); + } + }); +} + +async function runTest() { + // A prior interrupted test can leave synthetic F24 state behind. Release it + // before the polling thread starts so normalization cannot become an event. + await runInput(0); + await sendCommand('start-and-register', 1); + const firstStart = callbacks.length; + await Promise.all([runInput(25), waitForCallbacks(firstStart + 50)]); + assertCallbackSequence(callbacks.slice(firstStart), 1, 25); + + await sendCommand('unregister-all'); + const unregisteredCount = callbacks.length; + await runInput(3); + await delay(1000); + if (callbacks.length !== unregisteredCount) { + throw new Error('A callback ran after unregisterAllCallbacks'); + } + + if (!(await sendCommand('stop'))) throw new Error('Failed to stop hook'); + await sendCommand('start-and-register', 2); + const secondStart = callbacks.length; + await Promise.all([runInput(5), waitForCallbacks(secondStart + 10)]); + assertCallbackSequence(callbacks.slice(secondStart), 2, 5); + await sendCommand('cleanup'); + + const webContents = window.webContents; + const rendererPid = webContents.getOSProcessId(); + const webContentsDestroyed = new Promise(resolve => webContents.once('destroyed', resolve)); + rendererExitRequested = true; + window.destroy(); + await webContentsDestroyed; + await waitForProcessExit(rendererPid); + // Electron does not emit render-process-gone for every intentional window + // destruction. Give any queued crash notification a chance to fail the test + // before declaring successful environment teardown. + await delay(1000); + if (finished) return; + + finished = true; + clearTimeout(timeout); + console.log( + JSON.stringify({ + status: 'PASS', + callbacks: callbacks.length, + electron: rendererVersions.electron, + node: rendererVersions.node, + napi: rendererVersions.napi, + }), + ); + app.exit(0); +} + +ipcMain.on('hotkey-callback', (_event, callback) => callbacks.push(callback)); +ipcMain.on('hotkey-command-result', (_event, response) => { + const pending = pendingCommands.get(response.id); + if (!pending) return; + pendingCommands.delete(response.id); + if (response.error) pending.reject(new Error(response.error)); + else pending.resolve(response.result); +}); + +ipcMain.once('hotkey-ready', (_event, versions) => { + rendererVersions = versions; + runTest().catch(fail); +}); + +app.whenReady().then(() => { + window = new BrowserWindow({ + show: false, + webPreferences: { + preload: path.join(__dirname, 'preload_hotkey_win.js'), + nodeIntegration: true, + contextIsolation: false, + sandbox: false, + additionalArguments: [`--hotkey-addon=${addonPath}`], + }, + }); + window.webContents.on('render-process-gone', (_event, details) => { + if (!rendererExitRequested) { + fail(new Error(`Renderer exited before cleanup: ${details.reason}`)); + return; + } + if (details.reason !== 'clean-exit' && details.reason !== 'killed') { + fail(new Error(`Renderer exited: ${details.reason}`)); + return; + } + }); + window.loadFile(path.join(__dirname, 'index.html')); +}); diff --git a/test/test_hotkey_worker_win.js b/test/test_hotkey_worker_win.js new file mode 100644 index 0000000..dbfbf10 --- /dev/null +++ b/test/test_hotkey_worker_win.js @@ -0,0 +1,178 @@ +const { isMainThread, parentPort, Worker, workerData } = require('worker_threads'); + +function stringifyError(error) { + return error instanceof Error ? error.stack : String(error); +} + +if (!isMainThread) { + const libuiohook = require(workerData.addonPath); + let hookStarted = false; + + function binding(eventType) { + return { + callback: () => parentPort.postMessage({ type: 'callback', eventType }), + key: 'F24', + eventType, + modifiers: { alt: false, ctrl: false, shift: false, meta: false }, + }; + } + + function cleanup() { + libuiohook.unregisterAllCallbacks(); + if (hookStarted && !libuiohook.stopHook()) throw new Error('Failed to stop hook'); + hookStarted = false; + } + + // Keep the worker environment alive until the parent terminates it. The + // active native polling thread must then be joined by the environment hook. + parentPort.on('message', () => {}); + + try { + if (!libuiohook.startHook()) throw new Error('Failed to start hook'); + hookStarted = true; + if (!libuiohook.registerCallback(binding('registerKeydown'))) { + throw new Error('Failed to register keydown callback'); + } + if (!libuiohook.registerCallback(binding('registerKeyup'))) { + throw new Error('Failed to register keyup callback'); + } + parentPort.postMessage({ + type: 'ready', + versions: { + electron: process.versions.electron, + node: process.versions.node, + napi: process.versions.napi, + }, + }); + } catch (error) { + try { + cleanup(); + } catch (_cleanupError) { + // Preserve the setup error, which is the actionable failure. + } + parentPort.postMessage({ type: 'error', error: stringifyError(error) }); + } +} else { + const { app } = require('electron'); + const { spawn } = require('child_process'); + const path = require('path'); + + const addonPath = path.resolve(process.argv[2]); + const inputHelperPath = path.resolve(process.argv[3]); + const callbacks = []; + let inputProcess; + let worker; + let workerVersions; + let finished = false; + let terminatingWorker = false; + let resolveReady; + let callbackWaiter; + + const ready = new Promise(resolve => { + resolveReady = resolve; + }); + const timeout = setTimeout(() => fail(new Error('Worker hotkey test timed out')), 30000); + app.disableHardwareAcceleration(); + + function fail(error) { + if (finished) return; + finished = true; + clearTimeout(timeout); + if (inputProcess && !inputProcess.killed) inputProcess.kill(); + if (worker) worker.terminate(); + console.error(JSON.stringify({ status: 'FAIL', error: stringifyError(error) })); + app.exit(1); + } + + function runInput(repetitions) { + return new Promise((resolve, reject) => { + inputProcess = spawn(inputHelperPath, [String(repetitions), '75'], { + stdio: 'inherit', + windowsHide: true, + }); + inputProcess.once('error', reject); + inputProcess.once('exit', code => { + if (code === 0) resolve(); + else reject(new Error(`Input helper exited with code ${code}`)); + }); + }); + } + + function waitForCallbacks(expectedCount) { + if (callbacks.length >= expectedCount) return Promise.resolve(); + return new Promise((resolve, reject) => { + const callbackTimeout = setTimeout( + () => reject(new Error(`Expected ${expectedCount} callbacks, received ${callbacks.length}`)), + 10000, + ); + callbackWaiter = () => { + if (callbacks.length < expectedCount) return; + clearTimeout(callbackTimeout); + callbackWaiter = undefined; + resolve(); + }; + }); + } + + function assertCallbackSequence(repetitions) { + if (callbacks.length !== repetitions * 2) { + throw new Error(`Expected ${repetitions * 2} events, received ${callbacks.length}`); + } + callbacks.forEach((eventType, index) => { + const expectedType = index % 2 === 0 ? 'registerKeydown' : 'registerKeyup'; + if (eventType !== expectedType) { + throw new Error(`Unexpected callback at index ${index}: ${eventType}`); + } + }); + } + + async function runTest() { + // Loading in the main environment first verifies that a second Node + // environment receives its own safe callback dispatcher. + require(addonPath); + // Normalize F24 before the worker starts its polling thread. This isolates + // the test from a prior process that may have been killed mid-keypress. + await runInput(0); + worker = new Worker(__filename, { workerData: { addonPath } }); + worker.on('message', message => { + if (message.type === 'ready') { + workerVersions = message.versions; + resolveReady(); + } else if (message.type === 'callback') { + callbacks.push(message.eventType); + if (callbackWaiter) callbackWaiter(); + } else if (message.type === 'error') { + fail(new Error(message.error)); + } + }); + worker.once('error', fail); + worker.once('exit', code => { + if (!terminatingWorker) fail(new Error(`Worker exited unexpectedly with code ${code}`)); + }); + + await ready; + const repetitions = 10; + await Promise.all([runInput(repetitions), waitForCallbacks(repetitions * 2)]); + assertCallbackSequence(repetitions); + + terminatingWorker = true; + // Leave callbacks registered and the polling thread active. terminate() + // must not resolve until the addon's environment cleanup has joined it. + await worker.terminate(); + + finished = true; + clearTimeout(timeout); + console.log( + JSON.stringify({ + status: 'PASS', + callbacks: callbacks.length, + electron: workerVersions.electron, + node: workerVersions.node, + napi: workerVersions.napi, + }), + ); + app.exit(0); + } + + app.whenReady().then(() => runTest().catch(fail)); +} From 744cab63e8988aa629ab37168c4d54d32eed613a Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Fri, 21 Aug 2026 09:32:52 +1200 Subject: [PATCH 2/6] Build fix --- .github/workflows/build.yml | 2 +- CMakeLists.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 88d0d62..c181516 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ permissions: jobs: build: name: 'Build a package' - runs-on: windows-latest + runs-on: windows-2022 steps: - uses: actions/checkout@v3 - name: Show GitHub context diff --git a/CMakeLists.txt b/CMakeLists.txt index 4a5deb0..8a6bffb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.0) +cmake_minimum_required(VERSION 3.14) project(node_libuiohook) if(APPLE) From bebfee6529bd533eac06a73dadb3d443c5286a8f Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Fri, 21 Aug 2026 10:33:24 +1200 Subject: [PATCH 3/6] ci: install Electron 43 test binary --- .github/workflows/build.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c181516..fcd24c7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -71,7 +71,9 @@ jobs: with: node-version: '22.12.0' - name: Install Electron 43 test runtime - run: npm install --prefix "${{ runner.temp }}/electron43" --no-save electron@43.2.0 + run: | + npm install --prefix "${{ runner.temp }}/electron43" --no-save electron@43.2.0 + npx --prefix "${{ runner.temp }}/electron43" install-electron --no - name: Test hotkey callbacks with Electron 43 run: node test/run_electron_test.js "${{ runner.temp }}/electron43/node_modules/electron/dist/electron.exe" test/test_hotkey_win.js build/RelWithDebInfo/node_libuiohook.node build/RelWithDebInfo/node_libuiohook_send_input.exe env: From 17f173ef7c2d88b84e314ae18e6a6d0427f2c178 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Fri, 21 Aug 2026 11:03:40 +1200 Subject: [PATCH 4/6] Minor comments update --- .github/workflows/build.yml | 1 + CMakeLists.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fcd24c7..423d4a9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,6 +52,7 @@ jobs: INSTALL_PACKAGE_PATH: "${{env.BUILD_DIRECTORY}}/${{env.DISTRIBUTE_DIRECTORY}}/${{env.PACKAGE_DIRECTORY}}" - name: Build run: cmake --build "${{env.BUILD_DIRECTORY}}" --target install --config ${{env.BUILD_CONFIGURATION}} + # TODO: Remove the Electron 29 compatibility checks as soon as the Electron 43 migration is complete. - name: Install exact Electron 29 test runtime run: npm install --prefix "${{ runner.temp }}/electron29" --no-save electron@29.4.3 - name: Test hotkey callbacks with Electron 29 diff --git a/CMakeLists.txt b/CMakeLists.txt index 8a6bffb..86aaa1f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,6 +22,7 @@ set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_SOURCE_DIR}/cmake") SET(NODEJS_URL "https://artifacts.electronjs.org/headers/dist" CACHE STRING "Node.JS URL") SET(NODEJS_NAME "iojs" CACHE STRING "Node.JS Name") +# TODO: Remove the Electron 29 build target and package dependency as soon as the Electron 43 migration is complete. SET(NODEJS_VERSION "v29.4.3" CACHE STRING "Node.JS Version") include(NodeJS) From 583943b89c5bf2d2910203062c277dc490fd378c Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Fri, 21 Aug 2026 11:32:39 +1200 Subject: [PATCH 5/6] Added test runner to package.json --- .gitignore | 2 + package.json | 7 ++- test/run_hotkey_tests.js | 97 ++++++++++++++++++++++++++++++++ test/test_hotkey_teardown_win.js | 2 +- test/test_hotkey_win.js | 2 +- test/test_hotkey_worker_win.js | 2 +- 6 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 test/run_hotkey_tests.js diff --git a/.gitignore b/.gitignore index dd87e2d..416ca9d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules build +.yarn/ +.yarnrc.yml diff --git a/package.json b/package.json index 6581680..e07fbf4 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,16 @@ }, "license": "GPL-3.0-or-later", "main": "main.js", + "scripts": { + "test": "node test/run_hotkey_tests.js" + }, "devDependencies": { "colors": "^1.4.0", + "electron": "^29.4.3", "fs": "^0.0.1-security", "path": "^0.12.7", "shelljs": "^0.8.5", - "electron": "^29.4.3", - "underscore":"1.13.4" + "underscore": "1.13.4" }, "dependencies": { "node-addon-api": "^7.1.1" diff --git a/test/run_hotkey_tests.js b/test/run_hotkey_tests.js new file mode 100644 index 0000000..91efc8d --- /dev/null +++ b/test/run_hotkey_tests.js @@ -0,0 +1,97 @@ +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +if (process.platform !== 'win32') { + console.error('The native hotkey regression tests currently require Windows.'); + process.exit(2); +} + +const projectRoot = path.resolve(__dirname, '..'); +const configuration = process.env.NODE_LIBUIOHOOK_BUILD_CONFIGURATION || 'RelWithDebInfo'; +const buildDirectory = process.env.NODE_LIBUIOHOOK_BUILD_DIR + ? path.resolve(process.env.NODE_LIBUIOHOOK_BUILD_DIR) + : path.join(projectRoot, 'build', configuration); + +function findElectron() { + if (process.env.ELECTRON_PATH) return path.resolve(process.env.ELECTRON_PATH); + + try { + return require('electron'); + } catch (error) { + throw new Error( + `Unable to find Electron. Run the package install first or set ELECTRON_PATH.\n${error.message}`, + ); + } +} + +function requireFile(filePath, description) { + if (!fs.existsSync(filePath)) { + throw new Error( + `Missing ${description}: ${filePath}\n` + + 'Configure with NODE_LIBUIOHOOK_BUILD_TESTS=ON and build the project first.', + ); + } +} + +function readElectronVersion(electronPath) { + if (process.env.EXPECTED_ELECTRON_VERSION) return process.env.EXPECTED_ELECTRON_VERSION; + + const versionFile = path.join(path.dirname(electronPath), 'version'); + return fs.existsSync(versionFile) ? fs.readFileSync(versionFile, 'utf8').trim() : undefined; +} + +let electronPath; +try { + electronPath = findElectron(); +} catch (error) { + console.error(error.message); + process.exit(2); +} + +const addonPath = path.join(buildDirectory, 'node_libuiohook.node'); +const inputHelperPath = path.join(buildDirectory, 'node_libuiohook_send_input.exe'); +const harnessPath = path.join(__dirname, 'run_electron_test.js'); +const tests = [ + 'test_hotkey_win.js', + 'test_hotkey_teardown_win.js', + 'test_hotkey_worker_win.js', +]; + +try { + requireFile(electronPath, 'Electron executable'); + requireFile(addonPath, 'native addon'); + requireFile(inputHelperPath, 'SendInput helper'); +} catch (error) { + console.error(error.message); + process.exit(2); +} + +const expectedElectronVersion = readElectronVersion(electronPath); + +for (const test of tests) { + console.log(`\nRunning ${test}...`); + const result = spawnSync( + process.execPath, + [harnessPath, electronPath, path.join(__dirname, test), addonPath, inputHelperPath], + { + cwd: projectRoot, + env: { + ...process.env, + ...(expectedElectronVersion + ? { EXPECTED_ELECTRON_VERSION: expectedElectronVersion } + : {}), + }, + stdio: 'inherit', + windowsHide: true, + }, + ); + + if (result.error) { + console.error(result.error.stack || result.error.message); + process.exit(1); + } + if (result.status !== 0) process.exit(result.status || 1); +} + +console.log(`\nAll ${tests.length} native hotkey regression tests passed.`); diff --git a/test/test_hotkey_teardown_win.js b/test/test_hotkey_teardown_win.js index 8f98ece..a161feb 100644 --- a/test/test_hotkey_teardown_win.js +++ b/test/test_hotkey_teardown_win.js @@ -79,7 +79,7 @@ function waitForProcessExit(pid, milliseconds = 5000) { function runInput(repetitions) { return new Promise((resolve, reject) => { - inputProcess = spawn(inputHelperPath, [String(repetitions), '75'], { + inputProcess = spawn(inputHelperPath, [String(repetitions), '100'], { stdio: 'inherit', windowsHide: true, }); diff --git a/test/test_hotkey_win.js b/test/test_hotkey_win.js index fc6d38e..5e384ff 100644 --- a/test/test_hotkey_win.js +++ b/test/test_hotkey_win.js @@ -70,7 +70,7 @@ function sendCommand(action, generation) { function runInput(repetitions) { return new Promise((resolve, reject) => { - const child = spawn(inputHelperPath, [String(repetitions), '75'], { + const child = spawn(inputHelperPath, [String(repetitions), '100'], { stdio: 'inherit', windowsHide: true, }); diff --git a/test/test_hotkey_worker_win.js b/test/test_hotkey_worker_win.js index dbfbf10..8a0350e 100644 --- a/test/test_hotkey_worker_win.js +++ b/test/test_hotkey_worker_win.js @@ -86,7 +86,7 @@ if (!isMainThread) { function runInput(repetitions) { return new Promise((resolve, reject) => { - inputProcess = spawn(inputHelperPath, [String(repetitions), '75'], { + inputProcess = spawn(inputHelperPath, [String(repetitions), '100'], { stdio: 'inherit', windowsHide: true, }); From 022c4ead4c2e97de0fd6d10df866443da0c92200 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Fri, 21 Aug 2026 11:54:30 +1200 Subject: [PATCH 6/6] Discard queued hotkey callbacks after stop --- source/hook-win.cpp | 18 +++++++++++++++++- test/preload_hotkey_win.js | 25 +++++++++++++++++++++++++ test/test_hotkey_win.js | 20 +++++++++++++++++++- test/test_hotkey_worker_win.js | 7 +++++++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/source/hook-win.cpp b/source/hook-win.cpp index 5682406..4fba28f 100644 --- a/source/hook-win.cpp +++ b/source/hook-win.cpp @@ -61,6 +61,10 @@ struct ThreadData { napi_env env = nullptr; napi_threadsafe_function dispatcher = nullptr; uint64_t nextGeneration = 0; + uint64_t nextRunEpoch = 0; + // Zero means that no polling run is active. Each start gets a distinct + // epoch because queued TSFN events can outlive the producer thread. + uint64_t activeRunEpoch = 0; std::atomic shutdown{false}; }; @@ -70,6 +74,7 @@ struct HotKeyEvent { uint32_t key; HotKeyEdge edge; uint64_t generation; + uint64_t runEpoch; }; static HotKeyCallback &GetCallback(HotKey &hotkey, HotKeyEdge edge) @@ -87,6 +92,9 @@ static void DispatchHotKeyEvent(napi_env env, napi_value, void *context, void *d napi_value callback = nullptr; { std::unique_lock ulock(td->mtx); + if (td->activeRunEpoch == 0 || td->activeRunEpoch != event->runEpoch) + return; + auto hotkey = td->hotkeys.find(event->key); if (hotkey == td->hotkeys.end()) return; @@ -113,7 +121,7 @@ static void QueueHotKeyEvent(ThreadData *td, uint32_t key, HotKeyEdge edge, cons if (td->dispatcher == nullptr || callback.callback == nullptr) return; - HotKeyEvent *event = new (std::nothrow) HotKeyEvent{key, edge, callback.generation}; + HotKeyEvent *event = new (std::nothrow) HotKeyEvent{key, edge, callback.generation, td->activeRunEpoch}; if (event == nullptr) return; @@ -147,6 +155,11 @@ static bool StopHotkeyThread(ThreadData *td) td->shutdown.store(true, std::memory_order_release); td->worker.join(); + + // Joining prevents new events but does not drain work already queued in the + // TSFN. Mark the run inactive so stopHook() is also a callback boundary. + std::unique_lock ulock(td->mtx); + td->activeRunEpoch = 0; return true; } @@ -297,6 +310,9 @@ Napi::Value StartHotkeyThreadJS(const Napi::CallbackInfo &info) std::unique_lock ulock(td->mtx); for (auto &hotkey : td->hotkeys) hotkey.second.wasDown = false; + td->activeRunEpoch = ++td->nextRunEpoch; + if (td->activeRunEpoch == 0) + td->activeRunEpoch = ++td->nextRunEpoch; td->shutdown.store(false, std::memory_order_release); td->worker = std::thread(HotKeyThread, td); diff --git a/test/preload_hotkey_win.js b/test/preload_hotkey_win.js index 6ede3f9..3acb43d 100644 --- a/test/preload_hotkey_win.js +++ b/test/preload_hotkey_win.js @@ -1,9 +1,14 @@ const { ipcRenderer } = require('electron'); +const { spawnSync } = require('child_process'); const addonArgument = process.argv.find(argument => argument.startsWith('--hotkey-addon=')); if (!addonArgument) throw new Error('Missing --hotkey-addon argument'); +const inputHelperArgument = process.argv.find(argument => + argument.startsWith('--hotkey-input-helper='), +); const libuiohook = require(addonArgument.slice('--hotkey-addon='.length)); +const inputHelperPath = inputHelperArgument?.slice('--hotkey-input-helper='.length); let hookStarted = false; function binding(eventType, generation) { @@ -35,6 +40,26 @@ ipcRenderer.on('hotkey-command', (_event, command) => { } else if (command.action === 'stop') { result = libuiohook.stopHook(); hookStarted = false; + } else if (command.action === 'queue-before-stop-and-restart') { + if (!hookStarted) throw new Error('Hook is not started'); + if (!inputHelperPath) throw new Error('Missing --hotkey-input-helper argument'); + + // spawnSync keeps this renderer's JS thread blocked while the native + // polling thread queues both edges. Stop and restart before yielding so + // the test can verify that queued work does not cross the run boundary. + const input = spawnSync(inputHelperPath, ['1', '100'], { + stdio: 'inherit', + windowsHide: true, + }); + if (input.error) throw input.error; + if (input.status !== 0) { + throw new Error(`Input helper exited with code ${input.status}`); + } + if (!libuiohook.stopHook()) throw new Error('Failed to stop hook'); + hookStarted = false; + if (!libuiohook.startHook()) throw new Error('Failed to restart hook'); + hookStarted = true; + result = true; } else if (command.action === 'cleanup') { libuiohook.unregisterAllCallbacks(); result = hookStarted ? libuiohook.stopHook() : true; diff --git a/test/test_hotkey_win.js b/test/test_hotkey_win.js index 5e384ff..113f161 100644 --- a/test/test_hotkey_win.js +++ b/test/test_hotkey_win.js @@ -117,10 +117,24 @@ async function runTest() { // before the polling thread starts so normalization cannot become an event. await runInput(0); await sendCommand('start-and-register', 1); + // startHook() creates the polling thread but cannot guarantee when Windows + // will first schedule it. Keep thread-start latency out of the edge checks. + await delay(500); const firstStart = callbacks.length; await Promise.all([runInput(25), waitForCallbacks(firstStart + 50)]); assertCallbackSequence(callbacks.slice(firstStart), 1, 25); + const stoppedRunEnd = callbacks.length; + await sendCommand('queue-before-stop-and-restart'); + await delay(500); + if (callbacks.length !== stoppedRunEnd) { + throw new Error('A queued callback crossed a stop/start boundary'); + } + + const restartedRunStart = callbacks.length; + await Promise.all([runInput(1), waitForCallbacks(restartedRunStart + 2)]); + assertCallbackSequence(callbacks.slice(restartedRunStart), 1, 1); + await sendCommand('unregister-all'); const unregisteredCount = callbacks.length; await runInput(3); @@ -131,6 +145,7 @@ async function runTest() { if (!(await sendCommand('stop'))) throw new Error('Failed to stop hook'); await sendCommand('start-and-register', 2); + await delay(500); const secondStart = callbacks.length; await Promise.all([runInput(5), waitForCallbacks(secondStart + 10)]); assertCallbackSequence(callbacks.slice(secondStart), 2, 5); @@ -185,7 +200,10 @@ app.whenReady().then(() => { nodeIntegration: true, contextIsolation: false, sandbox: false, - additionalArguments: [`--hotkey-addon=${addonPath}`], + additionalArguments: [ + `--hotkey-addon=${addonPath}`, + `--hotkey-input-helper=${inputHelperPath}`, + ], }, }); window.webContents.on('render-process-gone', (_event, details) => { diff --git a/test/test_hotkey_worker_win.js b/test/test_hotkey_worker_win.js index 8a0350e..04ade1f 100644 --- a/test/test_hotkey_worker_win.js +++ b/test/test_hotkey_worker_win.js @@ -4,6 +4,10 @@ function stringifyError(error) { return error instanceof Error ? error.stack : String(error); } +function delay(milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} + if (!isMainThread) { const libuiohook = require(workerData.addonPath); let hookStarted = false; @@ -151,6 +155,9 @@ if (!isMainThread) { }); await ready; + // startHook() returns after creating the native polling thread, not after + // Windows has scheduled its first iteration. + await delay(500); const repetitions = 10; await Promise.all([runInput(repetitions), waitForCallbacks(repetitions * 2)]); assertCallbackSequence(repetitions);