3

This is a simple Windows C++ keylogger example

When I ran it in Visual Studio, HookCallback is called correctly.

I want to do the same thing using node-addon-api in Node.js but I don't want to log key presses in a file, I want to send the keycode values to the JavaScript world using a callback.

Here's my repository. This is what I'm doing...

JavaScript

const addon = require("bindings")("push_to_talk");

addon.start((keyCode) => {
  console.log("key is pressed:", keyCode);
});

console.log("testing...");

Native

#include <Windows.h>
#include <napi.h>
#include <time.h>
#include <cstdio>
#include <fstream>
#include <iostream>
#include <sstream>

// Declare the TSFN
Napi::ThreadSafeFunction tsfn;

// Create a native callback function to be invoked by the TSFN
auto callback = [](Napi::Env env, Napi::Function jsCallback, int* value) {
    // Call the JS callback
    jsCallback.Call({Napi::Number::New(env, *value)});

    // We're finished with the data.
    delete value;
};

// variable to store the HANDLE to the hook. Don't declare it anywhere else then globally
// or you will get problems since every function uses this variable.
HHOOK _hook;

// This struct contains the data received by the hook callback. As you see in the callback function
// it contains the thing you will need: vkCode = virtual key code.
KBDLLHOOKSTRUCT kbdStruct;

// Trigger the JS callback when a key is pressed
void Start(const Napi::CallbackInfo& info) {
    std::cout << "Start is called" << std::endl;

    Napi::Env env = info.Env();

    // Create a ThreadSafeFunction
    tsfn = Napi::ThreadSafeFunction::New(
      env,
      info[0].As<Napi::Function>(),  // JavaScript function called asynchronously
      "Keyboard Events",             // Name
      0,                             // Unlimited queue
      1                              // Only one thread will use this initially
    );
}

// This is the callback function. Consider it the event that is raised when, in this case,
// a key is pressed.
LRESULT __stdcall HookCallback(int nCode, WPARAM wParam, LPARAM lParam) {
    std::cout << "HookCallback is called" << std::endl;

    if (nCode >= 0) {
        // the action is valid: HC_ACTION.
        if (wParam == WM_KEYDOWN) {
            // lParam is the pointer to the struct containing the data needed, so cast and assign it
            // to kdbStruct.
            kbdStruct = *((KBDLLHOOKSTRUCT*)lParam);

            // Send (kbdStruct.vkCode) to JS world via "start" function callback parameter
            int* value = new int(kbdStruct.vkCode);
            napi_status status = tsfn.BlockingCall(value, callback);
            if (status != napi_ok) {
                std::cout << "BlockingCall is not ok" << std::endl;
            }
        }
    }

    // call the next hook in the hook chain. This is nessecary or your hook chain will break and the
    // hook stops
    return CallNextHookEx(_hook, nCode, wParam, lParam);
}

void SetHook() {
    std::cout << "SetHook is called" << std::endl;

    // Set the hook and set it to use the callback function above
    // WH_KEYBOARD_LL means it will set a low level keyboard hook. More information about it at
    // MSDN. The last 2 parameters are NULL, 0 because the callback function is in the same thread
    // and window as the function that sets and releases the hook.
    if (!(_hook = SetWindowsHookEx(WH_KEYBOARD_LL, HookCallback, NULL, 0))) {
        LPCSTR a = "Failed to install hook!";
        LPCSTR b = "Error";
        MessageBox(NULL, a, b, MB_ICONERROR);
    }
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
    exports.Set(Napi::String::New(env, "start"), Napi::Function::New(env, Start));

    // set the hook
    SetHook();

    return exports;
}

NODE_API_MODULE(push_to_talk, Init)

However, in my case HookCallback is never called (HookCallback is called message is never printed) and when I click on the keyboard, the clicks are slowed down and I suffer from a very noticeable lag for some reason.

Update: According to LowLevelKeyboardProc documentation: "This hook is called in the context of the thread that installed it. The call is made by sending a message to the thread that installed the hook. Therefore, the thread that installed the hook must have a message loop."

I've tried to call GetMessage in a loop like this

Napi::Object Init(Napi::Env env, Napi::Object exports) {
    exports.Set(Napi::String::New(env, "start"), Napi::Function::New(env, Start));

    // set the hook
    SetHook();

    MSG msg;
    BOOL bRet;
    while ((bRet = GetMessage(&msg, NULL, 0, 0)) != 0) {
        if (bRet == -1) {
            // handle the error and possibly exit
        } else {
            TranslateMessage(&msg);
            DispatchMessage(&msg);
        }
    }

    return exports;
}

But this blocks the JavaScript thread. Also, when a keyboard button is pressed now, the debug message HookCallback is called is actually printed but then a crash happens at this line while ((bRet = GetMessage(&msg, NULL, 0, 0)) != 0)...

$ node .
SetHook is called
HookCallback is called
C:\Windows\SYSTEM32\cmd.exe - node  .[10888]: c:\ws\src\node_api.cc:1078: Assertion `(func) != nullptr' failed.
 1: 77201783 RegisterLogonProcess+3427
 2: 77DC537D KiUserCallbackDispatcher+77
 3: 607007B9 Init+521 [c:\users\aabuhijleh\desktop\projects\testing\push-to-talk\src\push-to-talk.cc]:L93
aabuhijleh
  • 2,214
  • 9
  • 25
  • 1
    Try with `OutputDebugString` and DebugView instead of std::cout. – JeffRSon Feb 08 '21 at 14:38
  • 1
    [LowLevelKeyboardProc](https://learn.microsoft.com/en-us/previous-versions/windows/desktop/legacy/ms644985(v=vs.85)): *"This hook is called in the context of the thread that installed it. The call is made by sending a message to the thread that installed the hook. **Therefore, the thread that installed the hook must have a message loop.**"* – IInspectable Feb 08 '21 at 15:54
  • @IInspectable I see thanks. Are there any examples of how to do that using `node-addon-api`? – aabuhijleh Feb 08 '21 at 16:19
  • Try to call [`GetMessage`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getmessage#return-value) in the loop, like the sample in the link. – Drake Wu Feb 09 '21 at 05:27
  • @DrakeWu-MSFT That blocks the node thread. No more JavaScript is executed. `HookCallback is called` is printed but a crash happens... – aabuhijleh Feb 09 '21 at 07:06
  • I have updated the question to include [my project repsotierty](https://github.com/aabuhijleh/push-to-talk) and how I tried to create call `GetMessage` – aabuhijleh Feb 09 '21 at 07:43
  • 1
    You must not block the NodeJS thread, hence you should create a dedicated thread for the message pump. Of course you need to check out, how the Node-Api allows to report events from this seperate thread. – JeffRSon Feb 09 '21 at 11:43
  • If you need hokey functionality, you can use [RegisterHotKey](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerhotkey). If that doesn't work (e.g. if you want to observe keyboard input like Shift or Ctrl keys only), consider using [Raw Input](https://learn.microsoft.com/en-us/windows/win32/inputdev/raw-input) instead. It can handle input asynchronously and thus doesn't add to overall input latency like low-level hooks. The latter are called *synchronously* for every input event, and every hook adds latency. – IInspectable Feb 10 '21 at 11:12
  • @IInspectable, I need to create some kind of "push to play" effect. For example, I need to do something only when the user is holding down the "spacebar" and I need to stop when the "spacebar" is released. It can be any button really, not just the spacebar. So I think I need these low level hooks to know when a key is down and when it gets up. – aabuhijleh Feb 10 '21 at 11:22
  • 1
    Raw Input gives you exactly the same information, without contributing to input processing latency. – IInspectable Feb 10 '21 at 11:28
  • Thanks. I will look into this! – aabuhijleh Feb 10 '21 at 11:39

1 Answers1

1

I was able to get it working as seen here by creating a message loop in a separate thread

// Trigger the JS callback when a key is pressed
void Start(const Napi::CallbackInfo& info) {
    std::cout << "Start is called" << std::endl;

    Napi::Env env = info.Env();

    // Create a ThreadSafeFunction
    tsfn = Napi::ThreadSafeFunction::New(
      env,
      info[0].As<Napi::Function>(),  // JavaScript function called asynchronously
      "Keyboard Events",             // Name
      0,                             // Unlimited queue
      1,                             // Only one thread will use this initially
      [](Napi::Env) {                // Finalizer used to clean threads up
          nativeThread.join();
      });

    nativeThread = std::thread([] {
        // This is the callback function. Consider it the event that is raised when, in this case,
        // a key is pressed.
        static auto HookCallback = [](int nCode, WPARAM wParam, LPARAM lParam) -> LRESULT {
            if (nCode >= 0) {
                // the action is valid: HC_ACTION.
                if (wParam == WM_KEYDOWN) {
                    // lParam is the pointer to the struct containing the data needed, so cast and
                    // assign it to kdbStruct.
                    kbdStruct = *((KBDLLHOOKSTRUCT*)lParam);

                    // Send (kbdStruct.vkCode) to JS world via "start" function callback parameter
                    int* value = new int(kbdStruct.vkCode);
                    napi_status status = tsfn.BlockingCall(value, callback);
                    if (status != napi_ok) {
                        std::cout << "BlockingCall is not ok" << std::endl;
                    }
                }
            }

            // call the next hook in the hook chain. This is nessecary or your hook chain will
            // break and the hook stops
            return CallNextHookEx(_hook, nCode, wParam, lParam);
        };

        // Set the hook and set it to use the callback function above
        // WH_KEYBOARD_LL means it will set a low level keyboard hook. More information about it at
        // MSDN. The last 2 parameters are NULL, 0 because the callback function is in the same
        // thread and window as the function that sets and releases the hook.
        if (!(_hook = SetWindowsHookEx(WH_KEYBOARD_LL, HookCallback, NULL, 0))) {
            LPCSTR a = "Failed to install hook!";
            LPCSTR b = "Error";
            MessageBox(NULL, a, b, MB_ICONERROR);
        }

        // Create a message loop
        MSG msg;
        BOOL bRet;
        while ((bRet = GetMessage(&msg, NULL, 0, 0)) != 0) {
            if (bRet == -1) {
                // handle the error and possibly exit
            } else {
                TranslateMessage(&msg);
                DispatchMessage(&msg);
            }
        }
    });
}

Now the JavaScript callback is correctly called when there's a keyboard press.

aabuhijleh
  • 2,214
  • 9
  • 25