前言
大多数应用中都有 自定义快捷键 的能力,方便用户设置自己熟悉的操作,高效的作业。
在 Electron 的 MainProcess
模块中 提供了这样一个 api- globalShortcut
, 可以在操作系统中注册 / 注销全局快捷键,以下是简单的使用。
js
const { app, globalShortcut } = require('electron')
app.whenReady().then(() => {
// 注册一个'CommandOrControl+X' 快捷键监听器
const ret = globalShortcut.register('CommandOrControl+X', () => {
console.log('CommandOrControl+X is pressed')
})
// 检查快捷键是否注册成功
console.log(globalShortcut.isRegistered('CommandOrControl+X'))
})
app.on('will-quit', () => {
// 注销快捷键
globalShortcut.unregister('CommandOrControl+X'
// 注销所有快捷键
globalShortcut.unregisterAll()
})
但是!在 Electron 开发的应用中,globalShortcut
注册的快捷键,会阻塞此按键在其他应用中的使用,例如:某语音软件中,设置开启麦克风的快捷键为 Ctrl+A
,则在其他软件或应用中,Ctrl+A
(全选)的功能将失效,这时我们需要用C语言自己写一个全局监听键盘事件的脚本,结合 nodejs 的一个包 ffi-napi
( 调用c/c++写的动态链接库(dll) )
剖析需求
假设我们提供麦克风(Microphone)、扬声器(Speaker)两个快捷键的设置,刚才说了 ffi-napi 加载 C语言编写的 dll 文件即可开启全局键盘监听,当设置快捷键的时候,分为 设置状态 和 使用状态。
在设置态时,需要检查当前设置是否已占用,当保存设置后,即可关闭设置状态,进入使用状态。所以总结需求大概有以下几点是必须的
- 创建一个 kbhook 类,在类中初始化 ffi-napi 加载 dll 文件(你不需要知道dll文件的具体代码),只需要知道dll的能力是,当你按下键盘时,将立刻返回
{ type: 'keydown', code: 'A' }
这样的消息 (即你按下了哪个键,按下还是抬起了) - 类内应该维护一个状态,
isSetting: true
, 表示现在正在设置(如果在设置,我们需要检查快捷键是否已经被注册);还是在使用,(如果使用态,我们需要调用快捷键对应的事件) - 维护一个键盘的全量表(keyboards ),
{ 'A': false, 'Ctrl': false ... }
,以记录每次按下的是哪个键 - 还需要维护一个记录快捷键和事件的映射表,keymap :
{ 'Ctrl+K': Function, ... }
,用来记录 注册的快捷键 和 对应的是事件 - 还需要维护一个
settingCallback
属性,当我们设置快捷键的时候,在主进程中,需要先注册一个回掉函数进入 kbhook 类的内部,在我们每次键入的时刻接收在包内检测后的反馈结果
以下是 kbhook 类的属性与方法,及基本执行流程
实现 kbhook 类
1. 先写出必要的属性
ts
import ffi from 'ffi-napi'
import { _keyborads } from './const'
export class KBHook {
#isSetting: boolan = false
#keyboards: [key:string]:boolean = { ..._keyborads }
#keymaps:[key:string]:Function = {}
#settingCallback: (err: boolean, ret: string) => void | undefined = undefined
#ffi
}
- isSetting: 用户当前是在设置,还是在使用(快捷键)
- keyboards: 你键盘上所有的按键表
- keymaps: 你设置的 快捷键:事件 的映射表
- settingCallback: 启动前我先注册一个回调进去,接收检查信息
- ffi: 存 ffi-napi 实例
2. 构造函数
ts
constructor(dllPath = '../kbhook.dll') {
try {
this.#initialFFI(dllPath)
this.#establishMonitoring()
} catch(err) {
...
}
}
- initialFFI: 实例化 ffi-napi
- establishMonitoring: 建立(开启)键盘监听
ts
#initialFFI(dllPath) {
this.#ffi = ffi.Library(dllPath, {
InstallHook: ['void', []],
UninstallHook: ['void', []],
SetCallback: ['void', ['pointer']]
})
this.#ffi.InstallHook()
}
ts
#establishMonitoring() {
this.#ffi.SetCallback(this.#buildCallback((type, code) => {
console.log(type, code)
}))
}
这里的 this.#ffi.SetCallback
接收的是一个 ffi.Callback
,注意不是 this.#ffi
,是 import ffi from 'ffi-napi'
, ffi-napi
包本身的 Callback
方法。那么我们知道 this.#buildCallback
这个方法一定返回了 ffi.Callback
,我们来补全这个方法。
ts
#buildCallback(callback: (type: string, code:string) => void) {
const fficallback = ffi.Callback(
'void',
['string', 'string'],
(type:string, code:string) => {
callback(type, code)
})
return fficallback;
}
我们成功返回了 ffi.Callback
的同时,也把 dll能力
返回的消息,通过预先传入的回调,传了回去,这时当我们随意按下键盘时,控制台将实时打印出 type: keydown / keyup, code: 按键
这样的信息
3. buildCallback 的回调
我们在回到 buildCallback 的回调中,消息从 dll 传回来了,我们是不是应该根据当前的isSetting 状态
,去调用 设置的方法
或 使用的方法
- type : 返回的是
keydown
或keyup
- code : 返回的是用户按下的键 例如:
Ctrl
ts
this.#ffi.SetCallback(this.#buildCallback((type, code) => {
// 如果你摁下的按键,压根不在我维护的键盘表里,我直接return掉
if (this.#keyboards[code] == void 0) return
// 设置一个当前是 keydown 的变量
const isKeydown = type === 'keydown'
// 防止用户按住不松手(节流),键盘表中该键已经为true,同时,键盘已经按下了
// 那么打断,不重复调用
if(this.keyboards[code] && isKeydown) return
// 以上的意外情况都排除完了,进入正题
// 当前是设置状态
if (this.#isSetting) {
// 记录下键盘的事件
this.#keyboards[code] = isKeydown
// 只有你按下了,我才去检查
if(isKeydown) {
const { err, ret } = this.#whenSetChecking() // 下面我们要补全 whenSetChecking
// 如果有从应用端注册进来的回调,则把消息传回去
if (this.#settingCallback !== void 0) {
this.#settingCallback(err, ret)
}
}
} else {
// 实际使用时,按下 和 抬起可能都有对应的事件
// 摁下去了,记录
if (isKeydown) {
this.#keyboards[code] = isKeydown
}
// 拎出该 快捷键 对应的事件
const ev = this.#matchShortcut()
// 如果该事件存在
if (ev !== void 0) {
// 如果当前是按下,则调用按下对应的 focus 事件
if (isKeydown) {
ev.focus !== void 0 && ev.focus()
} else {
// 如果当前是抬起,则调用按下对应的 blur 事件
ev.blur !== void 0 && ev.blur()
}
}
// 抬起了,也要更新记录
if (!isKeydown) {
this.#keyboards[code] = isKeydown
}
}
}))
OK,大体流程已经写出来了,目前有2个大方法是需要补全的,设置时的 whenSetChecking
,使用时的 matchShortcut
4. whenSetChecking - 设置时检查是否有效
ts
#whenSetChecking() {
// 如果按下的是组合键 例如: Ctrl+P、Ctrl+Shift+L,需要拼接起来
const keyStr = this.#joinKeyCombine() // 后面此方法会补全
const err:string | undefined = undefined
// 快捷键映射表中已经注册过此快捷键了
if (this.#keyMaps[keyStr] !== void 0) {
err = '按键冲突'
}
return {
err,
ret: keyStr
}
}
5. matchShortcut - 当使用时,取出快捷键对应的事件
ts
#matchShortcut() {
const keyStr = this.#joinKeyCombine()
// 直接拿事件,这里不确定keyMaps 里有没有 keyStr这个快捷键,也不确定有没有事件
// 这里不严格校验,在外层去校验,宽松模式,直接取事件
return this.#keyMaps[keyStr]
}
6. joinKeyCombine - 拼接快捷键组合
我们在 设置模式 和 使用模式,都预先调用了 joinKeyCombine
这个拼接快捷键的方法,假设你设置的是组合键, Ctrl+X
、H+E+L+L+O
😂,我们需要把 keyboards
表里所有为 true
的键 都拼接起来,成为最终的结果
ts
// 分两步走, 1. 创建一个临时内存,把为true的放入 2.返回拼接的结果
#joinKeyCombine():string {
const keyArr:Array<string> = [];
for (const key in this.#keyboards) {
if (this.#keyboards[key]) {
keyArr.push(key)
}
}
// 自然是以 '+' 号拼接
return keyArr.join('+')
}
内部的属性 和 方法已经写的差不多了, 下面我们来完成对外暴露的API, 供外部调用的方法
7. SetKeyMap - 设置好快捷键,点保存时触发
外部使用的方法我们要大写开头,此方法接收3个参数(快捷键
、 摁下事件
、 抬起事件
)
ts
// 此时的 keyStr 已经经历过 设置状态时的 检查 和 拼接
// 所以返回的是 拼接后的 快捷键 keyStr
SetKeyMap(
keyStr: string,
focus: Function | undefined = void 0,
blur: Function | undefined = void 0
) {
this.#keyMaps[keyStr] = { focus, blur }
}
有设置,就有删除
ts
UnSetKeyMap(keyStr: string) {
delete this.#keyMaps[keyStr]
}
8.StartSettingMode - 开启设置模式
我们一直说,这个 hook 内部维护着两个状态, 设置态
, 还是 使用态
ts
// 这里的 callback 可不是渲染进程中直接注册进来的方法
// 是在主进程中注册进来的(调用preload中,渲染进程绑定的方法)方法
StartSettingMode(callback: (err: undefined | string, ret: string) => void) {
this.#isSetting = true
this.#settingCallback = callback
}
有开启,就有关闭
ts
StopSettingMode() {
this.#isSetting = false
this.#settingCallback = void 0
}
在 Electron 应用中使用 KBHook
我们知道在 Electron 中是分为 主进程 ipcMain
和 渲染进程 ipcRenderer
,两条不相交的平行线,出于安全考虑,渲染进程是不能直接访问主进程的API,为了实现主进程和渲染进程的通信,Electron 提供了 【桥】- contentBridge.exportInMainWorld
,主进程在实例化 BrowserWindow
的时候,在 webPreferences
里加载 preload
, 暴露 Electron.API
供渲染进程在 window.api.somefn
使用
创建 KBHook 在 Electron 中的调用层 pre-kbhook
为什么再抽象出一层 kbhook 的调用层
- 在
constructor
中根据开发 或 生产环境,传入不同的 dll 文件路径 - 维护一个状态
events
, 保存快捷键应用 与 事件触发 的指针(事件触发器是主进程 触发 渲染进程的 emit) - 在
init
的时候,注册 KBHook 中 startSettingMode 、stopSettingMode 、setKeymap 方法的事件监听
ts
// 注册应用内使用快捷键的功能
const events = {
// 麦克风事件
'triggleMicphoneOnOff': {
focus: () => ipcMain.emit('main.window.triggleMicphoneOnOff'),
},
// 扬声器事件
'triggleSpeakerOnOff': {
foucus: () => ipcMain.emit('main.window.triggleSpeakerOnOff')
},
// 摁下说话,松开结束
'pressSpeak': {
foucus: () => ipcMain.emit('main.window.pressSpeakFocus'),
blur: () => ipcMain.emit('main.window.pressSpeakBlur')
}
}
class PreKBHook {
#sdk: KBHook
constructor() {
let dllPath: string;
if (app.isPackaged) {
// 生产环境,使用 app.asar.unpacked 下的 DLL 文件
dllPath = path.join(process.resourcesPath, 'app.asar.unpacked', 'resources', 'kbhook.dll')
} else {
// 开发环境,使用根目录下的 resources 文件夹内的 kbhook.dll 文件
dllPath = path.join(app.getAppPath(), 'resources', 'kbhook.dll')
}
this.#sdk = new KBHook(dllPath)
}
init() {
...
}
}
我们知道 KBHook 对外提供了3个调用方法,我们在主进程中(调用层 - KBHookInstance.init()
方法中),分别注册 3个方法的处理事件
ts
class PreKBHook {
#sdk: KBHook
init() {
// 当渲染进程 设置快捷键保存时 触发该方法
ipcMain.on('kbhook.setKeyMap', (_, id, key) => {
// id
// 麦克风id:(triggleMinphoneOnOff)
// 扬声器id:(triggleSpeakerOnOff)
// 还是摁下说话id:(pressSpeak)
// key 是 快捷键 ctrl+U
const ev = events[id]
// 如果没有事件不继续了
if (!ev) return
this.#sdk.setKeyMap(key, ev.focus, ev.blur)
})
// 当渲染进程 点击 【设置快捷键】 时触发该方法
ipcMain.on('kbhook.startSettingMode', () => {
// 注册一个回调进去接消息,接到了发给桥,桥发给渲染进程
this.#sdk.startSettingMode((err, ret) => {
main.webContent.send('kbhook.setSettingModeCallback', err, ret)
})
})
// 当渲染进程 设置快捷键完毕 时触发该方法
ipcMain.on('kbhook.stopSettingMode', () => {
this.#sdk.stopSettingMode()
})
}
}
OK!目前为止,KBHook 、 调用层 PreKBHook 都就位了,我们该去 主进程 绑定监听事件了,上面我们在 PreKBHook
里有一个 events
,维护着: 应用id
--> 主进程emit
的映射表,那我们要去实现所有的 emit事件
,在主进程中我们需要绑定一下事件
ts
// win = new Browserwindow()
// 当摁下麦克风快捷键时,执行的事件(ipcMain.emit('main.window.triggleMicphoneOnOff'))将触发此回调
ipcMain.on('main.window.triggleMicphoneOnOff', () => {
// 此回调将去触发【桥】中对应的 渲染进程方法
win.webContents.send('main.window.triggleMicphoneOnOff')
})
ipcMain.on('main.window.triggleSpeakerOnOff', () => {
win.webContents.send('main.window.triggleSpeakerOnOff')
})
ipcMain.on('main.window.pressSpeakerFocus', () => {
win.webContents.send('main.window.pressSpeakerFocus')
})
ipcMain.on('main.window.pressSpeakerBlur', () => {
win.webContents.send('main.window.pressSpeakerBlur')
})
Preload 中补全渲染进程的方法
下面我们在 preload
中注册(供渲染进程注册回调 和 接受主进程触发的)方法
ts
// 在开启设置模式之前,先注册一个回调进来,准备对接从主进程传递过来的实时检查反馈
KBHookSetSettingModeCallback: (callback:(err: string | undefined, ret: string) => void): void => {
ipcRenderer.on('kbhook.setSettingModeCallback', (_, err, ret) => {
callback(err, ret)
})
}
// 我们会在前端组件(Vue、React)内提前调用 onMicphoneOnOff() 注册 callback
onMicphoneOnOff: (callback: () => void): void => {
// 当接收到 来自主进程 的 send(`main.window.triggleMicphoneOnOff`)
// 将执行 callback()
// 打开(或关闭)麦克风的具体 UI 操作
ipcRenderder.on('main.window.triggleMicphoneOnOff', () => {
callback()
})
}
onSpeakerOnOff: (callback: () => void): void => {
ipcRenderer.on('window.main.triggleSpeakerOnOff', () => {
callback()
})
},
onPressSpeakFocus: (callback: () => void): void => {
ipcRenderer.on('window.main.pressSpeakFocus', () => {
callback()
})
},
onPressSpeakBlur: (callback: () => void): void => {
ipcRenderer.on('window.main.pressSpeakBlur', () => {
callback()
})
}
以上是渲染进程 等待 主进程 主动传递消息过来的 方法,
但当我们点【开始设置】、【保存设置】,及 【关闭设置模式】时,要从渲染进程 主动发消息 给主进程,我们来补全 preload 中的 方法
ts
// 开启设置模式
KBHookStartSettingMode: ():void => {
ipcRenderer.send('kbhook.startSettingMode')
}
// 设置快捷键
KBHookSetKeyMap: (id:string, key:string):void => {
ipcRenderer.send('kbhook.setKeyMap', id, key)
}
// 关闭设置模式
KBHookStopSettingMode: ():void => {
ipcRenderer.send('kbhook.stopSettingMode')
}
OK!渲染进程 主动向 主进程 发消息的方法已经写好了,接下来,去UI组件内部,注册这些方法的回调
UI 组件内注册回调
首先,在 使用态
的前提下,当我们键入键盘,匹配到正确的 快捷键
时, 具体到 麦克风、扬声器、按下说话等应用,在 UI组件内的具体操作,我们还没有注册,下面 👇 完成 ✅ 它
我们知道在【桥】中的方法,可以暴露给渲染进程的 window
,所以可以使用 window.api.somefn
这样的语法,注册回调事件
在某 UI 组件内( react or vue )
ts
// 当主进程通知渲染进程,用户摁下的是 麦克风快捷键,快执行麦克风对应的方法
window.api.onMicphoneOnOff(() => {
// 组件内 上下文中的函数
aboutMicphoneEvents()
})
window.api.onSpeakerOnOff(() => {
// 扬声器 🔉
aboutSpeakerEvents()
})
// 按下说话 等...
OK,在点击【设置】时,注册回调,执行 startSettingMode
开启设置态
ts
function setShortcut() {
window.api.KBHookSetSettingModeCallback((err, ret) => {
// 如果检查反馈错误,同时排除当前注册项,那么提示 按键冲突
if (err && ret !== store.state[`props.currentSetItemId`]) {
message.error(err)
}
// 否则存全局
this.state.[`props.currentSetItemId`] = ret
})
// 开启检查模式
window.api.KBHookStartSettingMode()
}
设置好了,点保存,触发 KBHookSetKeyMap 的事件
ts
// 保存
function saveShortcut() {
window.api.KBHookSetKeyMap(props.currentSetItemId, store.state[props.currentSetItemId])
// 关闭设置模式
window.api.KBHookStopSettingMode()
}
// 重置
function resetKey() {
store.state[`${props.currentSetItemId}`] = ''
window.api.KBHookSetKeyMap(props.currentSetItemId, '')
}
🔚 以上就是全部内容,完整代码在 kbhook-github 或 kbhook-npm