C++ElectronNode.js

Hotkey Node Module

In diesem Post erläutere ich, wie ich ein ein natives Hotkey Modul für einen Discord Soundboard Bot entwickelt habe.

Ich habe sehr lange darüber nachgedacht ob ich das hier der Öffentlichkeit zugänglich machen sollte, da ich mir dessen bewusst bin das man auch viel Unsinn damit anstellen kann.

Allerdings entstand es aus einen Projekt, das mir durch eine sehr schwere Zeit half und wo sehr, sehr viel Herzblut drin steckt. Ich würde es gerne der Nachwelt erhalten und habe mich entschieden in Teilen zu erläutern wie ich vorgegangen bin.

Eine kurze Einführung

Ich habe an einem Soundboard - Bot für Discord gearbeitet. Er sollte sich in Voice Channel verbinden und dann mittels klicken auf Buttons lokale MP3 Dateien streamen. Den Bot habe ich mit Node.js und Electron realisiert.

Ich glaube das war mein aller erster Gehversuch mit Electron und ich hatte vorher aus gesundheitlichen Gründen sehr lange nicht mehr programmiert. Dementsprechend sah der Code auch aus.

Mir erschien es Sinnvoll die Buttons auch per Hotkey auslösen zu können. Das Feature war nicht angefragt und wurde vermutlich auch nicht genutzt. Ich wollte das einfach machen, weil ich es als äußerst interessant empfand.

Relativ schnell wurde mir klar das Electron so etwas nicht standardmäßig bietet und ich da selbst etwas schreiben muss.


Das Module

Die Entwicklung des Bots zog sich über gut 2 Jahre und in der Zeit hat sich sehr viel an der C++ API für Node getan. Zudem war ich bestrebt die Electron Version aktuell zu halten und damit musste ich auch das Module mehrmals anpassen. Mit den neusten Entwicklungen sollte das allerdings ein Ende haben.

Hotkey Klasse

Die Klasse sollte die übergebenen Werte der Hotkeys halten. Die Hotkey Structure besteht eigentlich nur aus zwei Integer Variablen. Wenn ich das heute machen würde, dann vermutlich nur mit der struct Hotkey und den Vector.

./src/hotkeys.hpp
#include <string>

struct Hotkey {
  short modifier;
  short key;
};

class Hotkeys {
  private:
    std::vector<Hotkey> keys;
  public:
    Hotkeys() {}
    ~Hotkeys() {}

  void Add(int hotkey, int mod) {
    Hotkey k;
    k.key = hotkey;
    k.modifier = mod;

    keys.push_back(k);
  }

  void Remove(int i) {
    keys.erase(keys.begin()+i);
  }

  Hotkey Get(int i) {
    return keys.at(i);
  }

  int Length() {
    return keys.size();
  }
};

KeyWorker Klasse

Die KeyWorker Klasse ist ein Child von Napi::AsyncWorker und implementiert die nötigen Methoden zum Ausführen und beenden des Worker Threads.

An der Execute() Methode kann man sehr schön sehen warum man einen Worker Thread benötigt. Es wird in einem Loop permanent geprüft ob ein Tastendruck einem Hotkey entspricht. Das würde natürlich den Event Loop blockieren sollte man das ohne Threading lösen. Das Modul musste nur unter Windows kompilieren von daher reichte es die Win API Funktion GetKeyState() zu nutzen.

Die while() Schleife läuft so lange wie run = true ist und die Stop() Methode setzt lediglich run = false. Somit kann man von außen das Beenden des Workers triggern.

Falls ein Hotkey gedrückt wurde, wird der Worker beendet und eine Callback Funktion aufgerufen. Die Parameter sind die Charcodes für Modifier (Shift | Alt | Ctrl | no-mod) und Hotkey. Der Wert für no-mod entspricht 0.

./src/keyworker.hpp
#pragma once
#include "napi.h"
#include "hotkeys.hpp"
#include <windows.h>

using namespace Napi;

class KeyWorker : public AsyncWorker {
  public:
    KeyWorker(Function& callback, Hotkeys* keys) : AsyncWorker(callback), keys(keys), key(-1), mod(0), run(true) {};
    ~KeyWorker() {};

    void Execute() {
      while(run) {
        for(int i = 0; i < keys->Length(); i++) {
          if(keys->Get(i).modifier == 0 || GetKeyState(keys->Get(i).modifier) < 0) {
            if(GetKeyState(keys->Get(i).key) < 0) {
              key = (int) keys->Get(i).key;
              mod = (int) keys->Get(i).modifier;
              run = false;
            }
          }
        }
      }
  };

  void OnOK() {
    HandleScope scope(Env());
    Callback().Call({Number::New(Env(), key), Number::New(Env(), mod)});
  };

  void Stop() {
    run = false;
  }

  private:
    Hotkeys* keys;
    int key;
    int mod;
    bool run;
};

Node Integration

Es werden zwei Funktionen für JavaScript zugänglich gemacht. Stop() beendet den Worker und Listen() startet ihn.

./src/keyevents.cc
#include "keyworker.hpp"

KeyWorker* worker;

void Stop(const CallbackInfo& info) {
  worker->Stop();
}

Value Listen(const CallbackInfo& info) {
  Napi::Env env = info.Env();

  if(info.Length() < 3) {
    Napi::TypeError::New(env, "Wrong number of arguments").ThrowAsJavaScriptException();
    return env.Null();
  }

  Function cb = info[2].As<Function>();
  Array keys = info[0].As<Array>();
  Array mods = info[1].As<Array>();

  Hotkeys* hk = new Hotkeys();

  for(unsigned int i = 0; i < keys.Length(); i++) {
    Value key = keys[i];
    Value mod = mods[i];

    hk->Add((int) key.As<Number>(), (int) mod.As<Number>());
  }

  worker = new KeyWorker(cb, hk);
  worker->Queue();
  return info.Env().Undefined();
}

Object Init(Env env, Object exports) {
  exports["Listen"] = Function::New(env, Listen, std::string("Listen"));
  exports["Stop"] = Function::New(env, Stop, std::string("Stop"));
  return exports;
}

NODE_API_MODULE(addon, Init)

Mit dem hier beschriebenen Code kann man eine Hotkey kombination abfragen und dann beendet sich der Worker. Die JavaScript Callback Funktion ruft die Hotkey entsprechende Callback Funktion auf und startet mit der Listen() Funktion den Worker erneut. Vorab müssen die Hotkeys noch speziell sortiert werden.