「Electron」自定义全局快捷键及冲突检测

932 阅读7分钟

背景

在使用Electron开发桌面端应用时,不免会有快捷键的需求,比如打开应用、关闭应用、截图等。一般快捷键总是随着软件的退出被注销,以免干扰其他应用。但是你的电脑打开了多个软件,如果其中有快捷键被先打开的软件占用,那么你当前的软件将会注册失败。这就需要我们对快捷键进行必要的冲突检测和管理。拿国民应用微信来说,当启动微信后,微信会检测注册的快捷键是否与其他应用冲突,如有则会弹窗提示,如下图。用户点击 前往重设 按钮跳到设置窗口,可以看到哪些快捷键冲突了,点击相应的快捷键可以重新设置。

image.png

在写本篇前我已经实现完了,可以看一下效果:

1.gif

🆗,先明确一下需求:

  1. 应用启动时检测快捷键的注册,看是否与其他软件冲突
  2. 可自定义快捷键的设置,并持久化保存至用户设置
  3. 可恢复默认设置

1、Electron全局快捷键的注册和注销

官方文档:传送门

核心代码非常简单:注册快捷键就是调用globalShortcut.register,注销则调用globalShortcut.unregister。这里就照搬一下官网的示例:

const { app, globalShortcut } = require('electron')

app.whenReady().then(() => {
  // 注册一个'CommandOrControl+X' 快捷键监听器
  const ret = globalShortcut.register('CommandOrControl+X', () => {
    console.log('CommandOrControl+X is pressed')
  })

  if (!ret) {// ret为Boolean类型
    console.log('registration failed')
  }

  // 检查快捷键是否注册成功
  console.log(globalShortcut.isRegistered('CommandOrControl+X'))
})

app.on('will-quit', () => {
  // 注销快捷键
  globalShortcut.unregister('CommandOrControl+X')

  // 注销所有快捷键
  globalShortcut.unregisterAll()
})

这里要注意一下,注册全局的快捷键必须在ready事件之后,才能注册成功。上面的代码也有体现。

一般来说应用的快捷键分为两类:

  • 窗口聚焦时注册,失焦时注销。比如Esc关闭应用
  • 应用只要在运行,快捷键就一直生效。比如截图

但所有的快捷键都应该在应用退出时注销: globalShortcut.unregisterAll()

对于聚焦快捷键我们可以监听主窗口的focus和blur事件:

mainWindow.on("focus", () => {
   globalShortcut.register("Esc", () => {
      mainWindow.close();
    });
  });

  mainWindow.on("blur", () => {
    globalShortcut.unregister("Esc");
  });

🆗,了解了Electron快捷键的基本使用后,我们现在来实现上面的需求。

2、封装主进程自定义快捷键的注册和注销

为了保持主进程代码的整洁,我们将注册自定义快捷键单独处理。在主进程的目录下新建一个customerShortcut.js。现在以注册打开应用主面板和截图的快捷键为例来实现我们的需求:

// customerShortcut.js
import { ipcMain, globalShortcut } from "electron";
export default function customerShortcut (mainWindow) {
  // 注销原来的快捷键
  ipcMain.on("unregister-shortcut", (event, name) => {
    globalShortcut.unregister(name);
  });

  //注册快捷键
  ipcMain.on("register-shortcut", (event, name, type) => {
    const ret = globalShortcut.register(name, () => {
      switch (type) {
        case "callscreen":
          callScreenCapture(mainWindow);
          break;
        case "screenshots":
          screenshotCapture();
          break;  
        default:
          break;
      }
    });
    console.log("register-shortcut", ret, type);
    if (!ret) {
      console.log("注册快捷键失败");
    }
    // 将注册状态通知给渲染进程
    mainWindow.webContents.send("register-shortcut-status", ret, type);
  });
}

const callScreenCapture = (win) => {
  win.show();
  win.focus();
}

const screenshotCapture = () => {
    // TO DO
}

然后在main.js中引入

// main.js
import { app, BrowserWindow, ipcMain } from "electron";
import path from "path";
import { electronApp, optimizer, is, platform } from "@electron-toolkit/utils";
import customerShortcut from "./services/customerShortcut";

const createMainWindow = async () => {
  mainWindow = new BrowserWindow({
    width: 760,
    height: 650,
    autoHideMenuBar: true,
    movable: true,
    show: false,
    title: "XXXX",
    icon: path.join(__dirname, "../public/logo.ico"),
    webPreferences: {
      devTools: true,
      scrollBounce: platform.isMacOS, // mac下滚动条是否回弹
      preload: path.join(__dirname, "preload.js"), // 预加载脚本
    },
  });

  mainWindow.setMenu(null);
  mainWindow.on("ready-to-show", () => {
    mainWindow.show();
  });

  if (is.dev && process.env.VITE_DEV_SERVER_URL) {
    mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
    // 开发环境下 开启调试工具
    mainWindow.webContents.openDevTools();
  } else {
    mainWindow.loadFile(path.join(__dirname, "../dist/index.html"));
  }
};

app.whenReady().then(async () => {
  await createMainWindow();
  customerShortcut(mainWindow); // 执行自定义快捷键注册

  app.on("browser-window-created", (_, window) => {
    optimizer.watchWindowShortcuts(window);
  });

  app.on("activate", function () {
    if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
  });
});

好啦,主进程的代码完事了,在customerShortcut.js中我们通过mainWindow.webContents.send()将快捷键的注册状态发送给渲染进程。在渲染进程中可通过Electron的ipcRenderer.on()来监听主进程发送过来的消息,但是ipcRenderer模块不能在渲染进程直接引入使用,我们需要将ipcRenderer通过contextBridge暴露给渲染进程。

import { contextBridge, ipcRenderer} from 'electron'
contextBridge.exposeInMainWorld('ipcRenderer', ipcRenderer)

当然也可以在主窗口中将nodeIntegration选项打开,但是因为安全问题,官方建议使用contextBridge的方式。这块涉及到主进程和渲染进程的通信,这里不做展开。不了解的可自行查阅官网文档:传送门

3、渲染进程中实现自定义快捷键

3.1 监听快捷键的注册状态

在渲染进程中通过ipcRenderer.on()来监听主进程发送过来的快捷键注册状态,我们在主进程中已经向渲染进程暴露了ipcRenderer模块,在渲染进程中ipcRenderer被挂在window上

<script setup>
import { ref } from 'vue'
import { readUserSetting } from "@/utils/utils";

const settings = readUserSetting(); // 获取用户的持久化存储内容
const focusWinShortcut = ref(settings.focusWinShortcut || "Ctrl+Alt+Z");
const screenshotShortcut = ref(settings.screenshotShortcut || "Shift+Alt+A");
const shortcutTip = ref(settings.shortcutTip || false);
const ipcRenderer = window.ipcRenderer;
let failedCutList = ref([]); // 收集注册失败的快捷键名称,方便告示用户哪些快捷键注册失败了
let captureStatus = ref({}); // 各个快捷键注册的状态,用于冲突提示
let cutTipVisible = ref(false);

// 注册快捷键
const registerShortcuts = () => {
    ipcRenderer.send("register-shortcut", focusWinShortcut.value, "callscreen");
    ipcRenderer.send("register-shortcut", screenshotShortcut.value, "screenshots");
}

// 检测快捷键的注册
   const checkCapture = () => {
      ipcRenderer.on("register-shortcut-status", (event, success, type) => {
        captureStatus.value[type] = success
        if (!success) {
          switch (type) {
            case "callscreen":
              failedCutList.value.push("打开主面板");
              break;
            case "screenshots":
              failedCutList.value.push("截图");
              break;
            default:
              break;
          }
        }
      });
      setTimeout(() => {
        if (failedCutList.value.length > 0 && !shortcutTip.value) {
          cutTipVisible.value = true;// 打开提示窗口
        }
      }, 3000);
    }
    
    registerShortcuts();
    checkCapture();
</script>

🆗,此上,我们已经完成快捷键的注册和监听注册状态。接下来我们需要通过键盘事件来实现按下键盘,在设置弹窗中输出对应的codeKey,类似微信实现的效果。

3.2 实现键盘组合键

我们可以通过监听键盘的按下事件来实现将键盘的codeKey显示在设置弹窗中,直接上代码:

<template>
   <el-form class="capture-form" label-width="130px">
      <el-form-item label="发送消息">
        <el-radio-group v-model="shortCutBtn.sendMsgShortcut" @change="shortCutChange">
          <el-radio label="Enter">Enter</el-radio>
          <el-radio label="Ctrl+Enter">Ctrl+Enter</el-radio>
        </el-radio-group>
      </el-form-item>
      <el-form-item label="截图屏幕">
        <div
          class="capture-btn"
          :class="{ err: !captureStatus.screenshots }"
          @click="openShortCutDialog('screenshots')"
        >
          {{ shortCutBtn.screenshots.replace(/\+/g, " + ") }}
        </div>
        <el-tooltip content="快捷键冲突,请重新设置" placement="right" effect="light">
          <i class="el-icon-warning" v-if="!captureStatus.screenshots"></i>
        </el-tooltip>
      </el-form-item>
      <el-form-item label="打开主面板">
        <div
          class="capture-btn"
          :class="{ err: !captureStatus.callscreen }"
          @click="openShortCutDialog('callscreen')"
        >
          {{ shortCutBtn.callscreen.replace(/\+/g, " + ") }}
        </div>
        <el-tooltip content="快捷键冲突,请重新设置" placement="right" effect="light">
          <i class="el-icon-warning" v-if="!captureStatus.callscreen"></i>
        </el-tooltip>
      </el-form-item>
      <el-form-item label="检测快捷键">
        <el-checkbox v-model="shortCutTipReserve" @change="saveCutSetting"
          >快捷键与其他软件冲突时提醒</el-checkbox
        >
      </el-form-item>
      <el-form-item>
        <el-button @click="recoverDefault">恢复默认设置</el-button>
      </el-form-item>
    </el-form>
    
    <el-dialog
      v-model="shortCutVisible"
      width="400px"
      top="15%"
      :show-close="false"
      title="请直接在键盘上输入新的快捷键"
    >
      <div class="shortcut-wrap">{{ keyCombination }}</div>
      <span slot="footer" class="dialog-footer">
        <el-button @click="handlerCancelCut" size="small">取 消</el-button>
        <el-button type="primary" @click="handlerSureCut" size="small">确 定</el-button>
      </span>
    </el-dialog>
</template>
<script setup>
    import { ref } from 'vue'
    import { readUserSetting, writeUserSetting } from "@/utils/utils";
    
    const settings = readUserSetting(); // 获取用户的持久化存储内容
    const sendMsgShortcut = ref(settings.sendMsgShortcut || "Enter");
    const focusWinShortcut = ref(settings.focusWinShortcut || "Ctrl+Alt+Z");
    const screenshotShortcut = ref(settings.screenshotShortcut || "Shift+Alt+A");
    const shortCutTipReserve = ref(!settings.shortcutTip);
    const ipcRenderer = window.ipcRenderer;
    let shortCutVisible = ref(false);
    let captureStatus = ref({});
    let keyCombination = ref('');
    let currentKeys = ref([]);
    let shortCutType = ref('');
    let failedCutList = ref([]);
    let shortCutBtn = ref({
        sendMsgShortcut: sendMsgShortcut,
        screenshots: screenshotShortcut,
        callscreen: focusWinShortcut
    });
    
    // 键盘按下事件  核心代码
    const handleKeyDown = (event) => {
      // 忽略功能键(如箭头键、F1-F12,1-9等)
      const ctrlKey = event.ctrlKey;
      const altKey = event.altKey;
      const shiftKey = event.shiftKey;
      const isChart = /^[A-Za-z]$/.test(event.key);
      const hasKey = currentKeys.value.includes(event.key);

      // 特殊处理Backspace键
      if (event.key === "Backspace") {
        currentKeys.value = [];
        keyCombination.value = "无";
        return;
      }

      // 如果没有按下任何修饰键,则只处理单个字符键
      if (!ctrlKey && !altKey && !shiftKey) {
        if (isChart) {
          currentKeys.value = [event.key.toUpperCase()];
          updateKeyCombination();
          setTimeout(() => {
            currentKeys.value = [];
          }, 10);
          return;
        }
        return;
      }

      // 如果按下了任意修饰键及其任意组合
      if (
        (ctrlKey && altKey && shiftKey) ||
        (ctrlKey && altKey) ||
        (ctrlKey && shiftKey) ||
        (altKey && shiftKey) ||
        ctrlKey ||
        shiftKey ||
        altKey
      ) {
        if (isChart) {
          // 如果currentKeys数组中已经存在字母键,则替换最后一个元素
          if (currentKeys.value.some((key) => /^[A-Za-z]$/.test(key))) {
            currentKeys.value = currentKeys.value.filter((key) => !/^[A-Za-z]$/.test(key)); // 移除已有的字母键
          }
          currentKeys.value.push(event.key.toUpperCase());
        } else if (!hasKey) {
          // 如果是修饰键本身且不在currentKeys中,则添加
          currentKeys.value.push(event.key);
        }
        updateKeyCombination();
        return;
      }

      // 更新按键组合显示
      updateKeyCombination();
    }

    const updateKeyCombination() => {
      keyCombination.value = currentKeys.value.map((key) => key.replace("Control", "Ctrl")).join("+");
    }

    const isFunctionKey = (key) => {
      const functionKeys = [
        "ArrowLeft",
        "ArrowRight",
        "ArrowUp",
        "ArrowDown",
        "Esc",
        "Tab",
        "Enter",
        "Meta",
        "Space",
        "PageUp",
        "PageDown",
        "Home",
        "End",
        "Insert",
        "Delete",
        "CapsLock",
        "NumLock",
        "ScrollLock",
        "PrintScreen",
        "Pause",
        "F1",
        "F2",
        "F3",
        "F4",
        "F5",
        "F6",
        "F7",
        "F8",
        "F9",
        "F10",
        "F11",
        "F12",
        "1",
        "2",
        "3",
        "4",
        "5",
        "6",
        "7",
        "8",
        "9",
        "0",
        "[",
        "]",
        "/",
      ];
      return functionKeys.includes(key);
    }
    
    const openShortCutDialog = (val) => {
      shortCutVisible.value = true;
      shortCutType.value = val;
      if (val === "callscreen") keyCombination.value = shortCutBtn.value.callscreen;
      if (val === "screenshots") keyCombination.value = shortCutBtn.value.screenshots;
    }

    const handlerSureCut = () => {
      // 先取消注册原来的快捷键
      if (shortCutType.value === "callscreen") ipcRenderer.send("unregister-shortcut", shortCutBtn.value.callscreen);
      if (shortCutType.value === "screenshots") ipcRenderer.send("unregister-shortcut", shortCutBtn.value.screenshots);

      setTimeout(() => {
        ipcRenderer.send("register-shortcut", keyCombination.value, shortCutType.value);

        ipcRenderer.on("register-shortcut-status", (event, success) => {
          if (success) {
            const settings = readUserSetting() || {};

            if (shortCutType.value === "callscreen") {
              shortCutBtn.value.callscreen = keyCombination.value;
              settings.focusWinShortcut = keyCombination.value;
              writeUserSetting(settings);
            }

            if (shortCutType.value === "screenshots") {
              shortCutBtn.value.screenshots = keyCombination.value;
              settings.screenshotShortcut = keyCombination.value;
              writeUserSetting(settings);
            }
            shortCutVisible.value = false;
            ElMessage({
                type: 'success',
                message: '快捷键设置成功',
            })
          } else {
            ElMessage({
                type: 'error',
                message: '快捷键冲突',
            })
          }
        });
      }, 500);
    }

    const handlerCancelCut = () => {
      shortCutVisible.value = false;
      currentKeys.value = [];
      keyCombination.value = "";
      shortCutType.value = "";
    }
    
    const saveCutSetting = (val) => {
      const settings = readUserSetting();
      settings.shortcutTip = !val;
      writeUserSetting(settings);
    }
    
    const recoverDefault = () => {
      shortCutBtn.value = {
        sendMsgShortcut: "Enter",
        screenshots: "Shift+Alt+A",
        callscreen: "Ctrl+Alt+Z",
      };
      ipcRenderer.send("register-shortcut", "Ctrl+Alt+Z", "callscreen");
      ipcRenderer.send("register-shortcut", "Shift+Alt+A", "screenshots");
      ipcRenderer.on("register-shortcut-status", (event, success, type) => {
        captureStatus.value[type] = success
        if (!success) {
          switch (type) {
            case "callscreen":
              failedCutList.value.push("打开主面板");
              break;
            case "screenshots":
              failedCutList.value.push("截图");
              break;
            default:
              break;
          }
        }
      });
      setTimeout(() => {
        if (failedCutList.value.length > 0) {
          ElMessageBox.alert('检测到有快捷键被占用', 'Title', { 
              confirmButtonText: '确定', 
              callback: (action: Action) => {}
          });
        } else {
          ElMessage({
                type: 'success',
                message: '恢复默认成功',
            })

          const settings = readUserSetting() || {};
          settings.sendMsgShortcut = shortCutBtn.value.sendMsgShortcut;
          settings.focusWinShortcut = shortCutBtn.value.callscreen;
          settings.screenshotShortcut = shortCutBtn.value.screenshots;
          writeUserSetting(settings);
        }
      }, 100);
    }
    
    watch(() => shortCutVisible, (value) => {
       if (value) {
        document.addEventListener("keydown", handleKeyDown);
      } else {
        document.removeEventListener("keydown", handleKeyDown);
      }
    })
    
</script>

🆗,以上就是全部代码了,至于readUserSetting和writeUserSetting两个工具函数的作用是读取用户的设置和将用户设置写入到系统磁盘中以实现持久化,方式有很多这里就不展开了。

💕看完两件事:

  1. 点赞 | 你可以点击——>收藏——>退出一气呵成,但别忘了点赞🤭
  2. 关注 | 点个关注,下次不迷路😘