Electron iohook编译方案及iohook的Bug修复

1,617 阅读10分钟

一、iohook介绍

iohook是适用于Electron和Node.js的全局本地键盘和鼠标监听器。

该库通过捕获系统级的消息事件来监听系统键盘和鼠标输入。

1.1 关于Windows的系统消息

1.2 关于Windows的键盘和鼠标输入

二、ABI介绍

在Electron应用中,原生Node.js模块由Electron支持,但由于Electron具有与给定Node.js不同的 应用二进制接口 (ABI)(由于使用Chromium的 BoringSL 而不是 OpenSSL 等 差异),您使用的原生Node.js模块需要为Electron重新编译。 否则,当您尝试运行您的应用程序时, 将会遇到以下的错误:

Error: The module '/path/to/native/module.node'
was compiled against a different Node.js version using
NODE_MODULE_VERSION $XYZ. This version of Node.js requires
NODE_MODULE_VERSION $ABC. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or `npm install`).

2.1 Electron的ABI版本

通过node-abi库(npm库)进行获取。

2.2 Node.js的ABI版本

通过node-abi库(npm库)进行获取,或者通过process.versions.modules获取。

也就是说,如果原生Node.js模块是直接应用到纯Node.js应用中的,则遵循Node.js的ABI版本即可。但是,如果原生Node.js模块是应用到Electron应用中的,那么需要遵循Electron的ABI版本。因此,在使用原生Node.js模块前,需要根据应用宿主环境的不同来决定编译成哪种ABI版本的C++ Addon。

三、针对Electron的编译方案

由于iohook官方的github仓库很难编译出适用于Electron 22.3.4版本的C++ Addon,于是改用下面的两个库进行编译。

3.1 Mystk/iohook库

仓库地址:github.com/MystK/iohoo…

该仓库是fork的iohook仓库,能编译出4.0.0版本到22.0.0版本的Electron C++ Addon。

3.1.1 编译ia32架构

$ cd D:/case-study
$ mkdir electron-iohook-ia32-demo
$ cd electron-iohook-ia32-demo
$ npm init -y # 初始化编译项目,在该步骤中,修改package.json,新增如下配置:
"iohook": {
    "targets": [
      "node-93",
      "electron-110"
    ],
    "platforms": [
      "win32"
    ],
    "arches": [
      "x64",
      "ia32"
    ]
}
$ mkdir node_modules
$ cd node_modules
$ git clone https://github.com/MystK/iohook.git # 克隆仓库
$ cd iohook
$ nvm use 16.17.1 32 # 切换编译环境为ia32
$ yarn # 给仓库安装编译所需的依赖
$ export npm_config_target=22.3.4 # 设置环境变量
$ export npm_config_arch=ia32
$ export npm_config_target_arch=ia32
$ export npm_config_disturl=https://electronjs.org/headers
$ export npm_config_runtime=electron
$ export npm_config_build_from_source=true
$ npm run build # 执行编译脚本

3.1.2 编译x64架构

$ cd D:/case-study
$ mkdir electron-iohook-x64-demo
$ cd electron-iohook-x64-demo
$ npm init -y # 初始化编译项目,在该步骤中,修改package.json,新增如下配置:
"iohook": {
    "targets": [
      "node-93",
      "electron-110"
    ],
    "platforms": [
      "win32"
    ],
    "arches": [
      "x64",
      "ia32"
    ]
}
$ mkdir node_modules
$ cd node_modules
$ git clone https://github.com/MystK/iohook.git # 克隆仓库
$ cd iohook
$ nvm use 16.17.1 64 # 切换编译环境为x64
$ yarn # 给仓库安装编译所需的依赖
$ export npm_config_target=22.3.4 # 设置环境变量
$ export npm_config_arch=x64
$ export npm_config_target_arch=x64
$ export npm_config_disturl=https://electronjs.org/headers
$ export npm_config_runtime=electron
$ export npm_config_build_from_source=true
$ npm run build # 执行编译脚本

3.1.3 编译产物以及使用方式

至此,已完成iohook的全部编译工作,包含ia32和x64两种架构的Electron C++ Addon,分别在如下的两个项目中:

● D:/case-study/electron-iohook-ia32-demo

● D:/case-study/electron-iohook-x64-demo

编译的产物在均在各自项目的node_modules/iohook/build/release目录里,包含iohook.node和uiohook.lib两个文件。使用时,需要通过node_modules/iohook/index.js进行引用。在node_modules/iohook/index.js代码中有一处bug需要注意,就是拼接modulePath变量的地方,这里使用了process.arch进行拼接,注意,在windows平台下,不论当前的编译环境是ia32或者是x64,该值均返回ia32,这里是不正确的。因此需要对这里进行改造,鉴于此情况,我把iohook编译后的产物单独封装成了一个小的插件进行使用,目录如下:

1.png

2.png 上述的demo示例是根据x64的情况进行封装的。那么在有的项目里,需要同时兼容32位和64位的系统该怎么办?此时应该在项目里封装iohook-x64和iohook-x86插件,根据当前的arch架构,来决定引用哪一个插件即可。关于在node中如何精确判断arch,上面已经说过了,不要使用process.arch进行判断,可以自己在网上搜索一下如何在node环境中精确判断arch架构(推荐使用systeminformation库)。

3.2 arifnpm/iohook库

仓库地址:github.com/arifnpm/ioh…

该仓库是fork的iohook仓库,并编译出来了多个版本的Addon可供直接使用。

四、针对Node.js的编译方案

针对纯Node.js环境(非Electron项目)的编译非常简单,过程如下:

$ cd D:/case-study
$ mkdir node-iohook-ia32-demo
$ cd node-iohook-ia32-demo
$ npm init -y
$ mkdir node_modules
$ cd node_modules
$ git clone https://github.com/wilix-team/iohook.git
$ cd iohook
$ nvm use 16.17.1 ia32
$ cp build_def/win32/binding.gyp ./bingding.gyp
$ cp build_def/win32/uiohook.gyp ./uiohook.gyp
$ npm run build

以该种方式编译出来的产物在build/release/目录中。

五、修复iohook中存在的两个Bug

编译好的iohook原生模块,在使用时,发现了两处Bug,分别阐述如下。

5.1 arch的问题

该问题是在生产和开发环境中无法准确判断arch的问题,已在3.1中做了说明,这里不再赘述。

5.2 多次注册同一个快捷键的问题

在阐述该问题前,先来介绍一下自己的项目。我负责的项目是使用Electron开发的一个桌面客户端程序,它的本质是借Electron的壳搭建了一个简易版的浏览器,通过BrowserWindow实例的loadURL方法加载远程的前端项目。该项目的整体架构方案是单实例多窗口的多进程架构模型,本质就是一个主进程实例可以开多个浏览器窗口。为了安全性考虑,在renderer进程中屏蔽了node集成,所有的桌面客户端的能力均通过在preload中使用contextBridge.exposeInMainWorld的方式向renderer进程中暴露特定的API接口的形式提供。

于是,在这里就可以看到,我们的iohook在主进程中使用时,面对的是多个renderer进程的前端页面,那么每一个renderer进程对应的前端页面在调用preload暴露的接口(在主进程中注册iohook快捷键)后,如果该快捷键被触发了,那么所有的renderer进程对应的前端页面均应该能收到快捷键被触发的消息,但是实际上不是这样的,而是只有部分renderer进程对应的前端页面能接收到快捷键被触发的消息,而且是随机触发的,不固定。比如有A、B、C三个renderer进程对应的页面,都注册了同一个Ctrl+N快捷键,那么Ctrl+N快捷键被触发时,正常情况下A、B、C三个renderer进行对应的页面均应该能收到Ctrl+N快捷键被触发的消息,但是实际的现象并不是这样执行的,而是随机派发的消息,比如这次触发Ctrl+N快捷键,那么只有A、B接收到了消息,C没有接收到消息,下次再触发Ctrl+N快捷键时,可能只有B、C接收到了消息,A没有接收到消息。

这种情况就很匪夷所思了,肯定是不满足我们的使用场景的,于是开始阅读iohook源代码,终于在node_modules/iohook/index.js入口文件中发现了Bug,摘取有问题的源代码如下:

// 修复前的源代码
/**
   * Local shortcut event handler
   * @param event Event object
   * @private
   */
  _handleShortcut(event) {
    if (this.active === false) {
      return;
    }

    // Keep track of shortcuts that are currently active
    let activatedShortcuts = this.activatedShortcuts;

    if (event.type === 'keydown') {
      this.shortcuts.forEach((shortcut) => {
        if (shortcut[event[this.eventProperty]] !== undefined) {
          // Mark this key as currently being pressed
          shortcut[event[this.eventProperty]] = true;

          let keysTmpArray = [];
          let callme = true;

          // Iterate through each keyboard key in this shortcut
          Object.keys(shortcut).forEach((key) => {
            if (key === 'callback' || key === 'releaseCallback' || key === 'id')
              return;

            // If one of the keys aren't pressed...
            if (shortcut[key] === false) {
              // Don't call the callback and empty our temp tracking array
              callme = false;
              keysTmpArray.splice(0, keysTmpArray.length);

              return;
            }

            // Otherwise, this key is being pressed.
            // Add it to the array of keyboard keys we will send as an argument
            // to our callback
            keysTmpArray.push(key);
          });
          if (callme) {
            shortcut.callback(keysTmpArray);

            // Add this shortcut from our activate shortcuts array if not
            // already activated
            if (activatedShortcuts.indexOf(shortcut) === -1) {
              activatedShortcuts.push(shortcut);
            }
          }
        }
      });
    } else if (event.type === 'keyup') {
      // Mark this key as currently not being pressed in all of our shortcuts
      this.shortcuts.forEach((shortcut) => {
        if (shortcut[event[this.eventProperty]] !== undefined) {
          shortcut[event[this.eventProperty]] = false;
        }
      });

      // Check if any of our currently pressed shortcuts have been released
      // "released" means that all of the keys that the shortcut defines are no
      // longer being pressed
      this.activatedShortcuts.forEach((shortcut) => {
        if (shortcut[event[this.eventProperty]] === undefined) return;

        let shortcutReleased = true;
        let keysTmpArray = [];
        Object.keys(shortcut).forEach((key) => {
          if (key === 'callback' || key === 'releaseCallback' || key === 'id')
            return;
          keysTmpArray.push(key);

          // If any key is true, and thus still pressed, the shortcut is still
          // being held
          if (shortcut[key]) {
            shortcutReleased = false;
          }
        });

        if (shortcutReleased) {
          // Call the released function handler
          if (shortcut.releaseCallback) {
            shortcut.releaseCallback(keysTmpArray);
          }

          // Remove this shortcut from our activate shortcuts array
          const index = this.activatedShortcuts.indexOf(shortcut);
          if (index !== -1) this.activatedShortcuts.splice(index, 1);
        }
      });
    }
  }

问题出现在第88行和89行,可以看到这里是对this.activatedShortcuts数组调用splice方法进行删除元素的操作,但是仔细看第64行代码,发现该删除逻辑是在this.activatedShortcuts.forEach的forEach循环中执行的。那么问题就很明显了,用一个数组进行循环操作,但是在每一次循环操作里又删除了数组里的元素,那么这肯定是不对的,数组还没循环完呢,数组里的元素就被删除了,改变了数组的长度,那么数组循环的次数肯定就会少了,于是会出现上面举例中说的问题,就是A、B、C三个renderer对应的前端页面,并不能全都接收到Ctrl+N快捷键被触发的消息。于是对源代码做如下的改造:

// 修复后的代码
/**
   * Local shortcut event handler
   * @param event Event object
   * @private
   */
  _handleShortcut(event: any) {
    if (this.active === false) {
      return;
    }

    // Keep track of shortcuts that are currently active
    const activatedShortcuts = this.activatedShortcuts;

    if (event.type === 'keydown') {
      this.shortcuts.forEach((shortcut) => {
        if (shortcut[event[this.eventProperty]] !== undefined) {
          // Mark this key as currently being pressed
          shortcut[event[this.eventProperty]] = true;

          const keysTmpArray: any[] = [];
          let callme = true;

          // Iterate through each keyboard key in this shortcut
          Object.keys(shortcut).forEach((key) => {
            if (key === 'callback' || key === 'releaseCallback' || key === 'id')
              {return;}

            // If one of the keys aren't pressed...
            if (shortcut[key] === false) {
              // Don't call the callback and empty our temp tracking array
              callme = false;
              keysTmpArray.splice(0, keysTmpArray.length);

              return;
            }

            // Otherwise, this key is being pressed.
            // Add it to the array of keyboard keys we will send as an argument to our callback
            keysTmpArray.push(key);
          });
          if (callme) {
            if (shortcut.callback) {
              shortcut.callback(keysTmpArray);
            }

            // Add this shortcut from our activate shortcuts array if not
            // already activated
            if (activatedShortcuts.indexOf(shortcut) === -1) {
              activatedShortcuts.push(shortcut);
            }
          }
        }
      });
    } else if (event.type === 'keyup') {
      // Mark this key as currently not being pressed in all of our shortcuts
      this.shortcuts.forEach((shortcut) => {
        if (shortcut[event[this.eventProperty]] !== undefined) {
          shortcut[event[this.eventProperty]] = false;
        }
      });

      // Check if any of our currently pressed shortcuts have been released
      // "released" means that all of the keys that the shortcut defines are no
      // longer being pressed
      const ids: number[] = []
      this.activatedShortcuts.forEach((shortcut: any) => {
        
        if (shortcut[event[this.eventProperty]] === undefined) {return;}

        let shortcutReleased = true;
        const keysTmpArray: any[] = [];
        Object.keys(shortcut).forEach((key) => {
          if (key === 'callback' || key === 'releaseCallback' || key === 'id')
            {return;}
          keysTmpArray.push(key);

          // If any key is true, and thus still pressed, the shortcut is still being held
          if (shortcut[key]) {
            shortcutReleased = false;
          }
        });

        if (shortcutReleased) {
          // Call the released function handler
     
          if (shortcut.releaseCallback) {
            shortcut.releaseCallback(keysTmpArray);
          }

          // Remove this shortcut from our activate shortcuts array
          // const index = this.activatedShortcuts.indexOf(shortcut);
          // if (index !== -1) {this.activatedShortcuts.splice(index, 1);}
          ids.push(shortcut.id as number)
        }
      });

      // Remove this shortcut from our activate shortcuts array
      ids.length && ids.forEach((id: number) => {
        let ind
        this.activatedShortcuts.forEach((sc: any, index: number) => {
          if (sc.id == id) {
            ind = index
          }
        })
        this.activatedShortcuts.splice(ind, 1)
      })
    }
  }

看修复后的的代码,在65行增加了const ids: number[] = []语句,用于记录处理后的快捷键的id,同时注释了第91行(对应源码的88行)和92行代码(对应源码的89行),并在98行至106行新增了如下的代码:

ids.length && ids.forEach((id: number) => {
    let ind
    this.activatedShortcuts.forEach((sc: any, index: number) => {
      if (sc.id == id) {
        ind = index
      }
    })
    this.activatedShortcuts.splice(ind, 1)
})

这段代码就是替换源码中88行和89行的逻辑,用于从激活快捷方式数组(this.activatedShortcuts)中删除对应的快捷方式。也就是说,等this.activatedShortcuts数组循环完了,再对它里面的元素执行删除操作,这样就没有任何问题。经过修复后,A、B、C三个renderer对应的前端页面,就都能接收到Ctrl+N快捷键被触发的消息了。

六、被隐藏的node-gyp

为什么要说是被隐藏的node-gyp,因为在三、四中讲到的编译方式,全都没有直接涉及到node-gyp命令行的使用,所以这里要特别介绍一下node-gyp。node-gyp是C++ Addon的编译工具,要想把自己编写的C或C++代码编译成Node Addon插件,就必须使用node-gyp进行编译。node-gyp的使用方式请参考node官方文档的介绍nodejs.org/docs/latest…nodejs.org/docs/latest…

关于node-gyp的核心内容,有以下四点:

● 在项目根目录中必须要要有一个binding.gyp的编译入口文件。

● 使用node-gyp configure命令生成编译配置文件。

● 使用node-gyp build命令进行编译。

● 编译后的文件在build/release/目录中。

上述第三、第四节中讲到的npm run build命令,调用的是node_modules/iohook/build.js脚本,build.js脚本进行编译时使用的就是node-gyp工具,可以自行阅读node_modules/iohook/build.js源码了解详细情况。

七、参考链接

● Electron官网关于Node原生模块的编译介绍

Electron官网关于Node原生模块的编译介绍

● Node ABI介绍

node-abi官网

● node-gyp介绍