一、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库
该仓库是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编译后的产物单独封装成了一个小的插件进行使用,目录如下:
上述的demo示例是根据x64的情况进行封装的。那么在有的项目里,需要同时兼容32位和64位的系统该怎么办?此时应该在项目里封装iohook-x64和iohook-x86插件,根据当前的arch架构,来决定引用哪一个插件即可。关于在node中如何精确判断arch,上面已经说过了,不要使用process.arch进行判断,可以自己在网上搜索一下如何在node环境中精确判断arch架构(推荐使用systeminformation库)。
3.2 arifnpm/iohook库
该仓库是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源码了解详细情况。