Electron应用中封装KBHook监听键盘注册快捷键事件

前言

大多数应用中都有 自定义快捷键 的能力,方便用户设置自己熟悉的操作,高效的作业。

ElectronMainProcess 模块中 提供了这样一个 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 文件即可开启全局键盘监听,当设置快捷键的时候,分为 设置状态 和 使用状态。

在设置态时,需要检查当前设置是否已占用,当保存设置后,即可关闭设置状态,进入使用状态。所以总结需求大概有以下几点是必须的

  1. 创建一个 kbhook 类,在类中初始化 ffi-napi 加载 dll 文件(你不需要知道dll文件的具体代码),只需要知道dll的能力是,当你按下键盘时,将立刻返回 { type: 'keydown', code: 'A' } 这样的消息 (即你按下了哪个键,按下还是抬起了)
  2. 类内应该维护一个状态,isSetting: true, 表示现在正在设置(如果在设置,我们需要检查快捷键是否已经被注册);还是在使用,(如果使用态,我们需要调用快捷键对应的事件)
  3. 维护一个键盘的全量表(keyboards ), { 'A': false, 'Ctrl': false ... },以记录每次按下的是哪个键
  4. 还需要维护一个记录快捷键和事件的映射表,keymap : { 'Ctrl+K': Function, ... },用来记录 注册的快捷键 和 对应的是事件
  5. 还需要维护一个 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
}
  1. isSetting: 用户当前是在设置,还是在使用(快捷键)
  2. keyboards: 你键盘上所有的按键表
  3. keymaps: 你设置的 快捷键:事件 的映射表
  4. settingCallback: 启动前我先注册一个回调进去,接收检查信息
  5. ffi: 存 ffi-napi 实例

2. 构造函数

ts 复制代码
constructor(dllPath = '../kbhook.dll') {
    try {
        this.#initialFFI(dllPath)
        this.#establishMonitoring()
    } catch(err) {
        ...
    }
}
  1. initialFFI: 实例化 ffi-napi
  2. 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 : 返回的是 keydownkeyup
  • 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+XH+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 的调用层

  1. constructor 中根据开发 或 生产环境,传入不同的 dll 文件路径
  2. 维护一个状态 events, 保存快捷键应用 与 事件触发 的指针(事件触发器是主进程 触发 渲染进程的 emit)
  3. init 的时候,注册 KBHook 中 startSettingModestopSettingModesetKeymap 方法的事件监听
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-githubkbhook-npm

相关推荐
涔溪39 分钟前
Ecmascript(ES)标准
前端·elasticsearch·ecmascript
榴莲千丞1 小时前
第8章利用CSS制作导航菜单
前端·css
奔跑草-1 小时前
【前端】深入浅出 - TypeScript 的详细讲解
前端·javascript·react.js·typescript
羡与1 小时前
echarts-gl 3D柱状图配置
前端·javascript·echarts
guokanglun1 小时前
CSS样式实现3D效果
前端·css·3d
咔咔库奇1 小时前
ES6进阶知识一
前端·ecmascript·es6
渗透测试老鸟-九青2 小时前
通过投毒Bingbot索引挖掘必应中的存储型XSS
服务器·前端·javascript·安全·web安全·缓存·xss
龙猫蓝图2 小时前
vue el-date-picker 日期选择器禁用失效问题
前端·javascript·vue.js
fakaifa2 小时前
CRMEB Pro版v3.1源码全开源+PC端+Uniapp前端+搭建教程
前端·小程序·uni-app·php·源码下载
夜色呦2 小时前
掌握ECMAScript模块化:构建高效JavaScript应用
前端·javascript·ecmascript