Skip to content

Electron 核心配置

createWindow.ts

ts
this.defaultConfig = {
  // 页面路由
  route: '',
  parentId: null,
  isMainWin: false,
  width: 900,
  height: 700,
  //窗口是否在屏幕居中. 默认值为 false
  center: true,
  transparent: true,
  //设置为 false 时可以创建一个无边框窗口 默认值为 true。
  frame: false,
  titleBarStyle: 'hidden',
  backgroundColor: '#00000000',
  //窗口是否在创建时显示。 默认值为 true。
  show: false,
  maxWidth: null,
  maxHeight: null,
  minWidth: null,
  minHeight: null,
  autoHideMenuBar: true,
  resizable: true,
  minimizable: true,
  maximizable: true,
  windowName: '',
  /* 
    【父窗口不能操作】
     模态窗口 -- 模态窗口是禁用父窗口的子窗口,创
     建模态窗口必须设置 parent 和 modal 选项
  */
  modal: false,
  parent: null,
  titleBarOverlay: false,
  maximize: false,
  webPreferences: {
    devTools: DEV,
    // nodeIntegration: true,
    contextIsolation: true,
    // nodeIntegrationInWorker: true,
    webSecurity: false,
    // sandbox: false,
    nodeIntegration: true,
    preload: path.join(__dirname, '../preload/preload.js'),
    // 禁用离屏渲染
    offscreen: false,
    // 防止后台渲染节流
    backgroundThrottling: false,
    // 核心:放开HID设备访问权限
    permissions: ['hid', 'hid-device'],
    // 可选:允许实验性API(若使用小众HID特性)
    // experimentalFeatures: false,
    allowRunningInsecureContent: true,
    sandbox: false,
    enableWebHID: true, // 启用 WebHID
  },
}

main.ts

ts

  session.defaultSession.setPermissionCheckHandler(
    (webContents, permission) => {
      if (permission === 'hid') {
        return true // 允许权限
      }
      return false
    },
  )

  session.defaultSession.on(
    'select-hid-device',
    async (event, details, callback) => {
      event.preventDefault()
      try {
        // 保存HID选择的最终回调(关键:等待渲染进程返回后执行)
        hidDeviceSelectCallback = callback

        if (
          CreateWindow.getMainWindow() &&
          CreateWindow.getMainWindow().webContents
        ) {
          CreateWindow.getMainWindow().webContents.send(
            'show-hid-selector',
            details.deviceList,
          )
        } else {
          // 兜底:无窗口时取消选择
          callback(null)
          hidDeviceSelectCallback = null
        }
      } catch (err) {
        // 捕获所有可能的错误(包括IPC通信失败、渲染进程返回异常等)
        console.error('select-hid-device 处理失败:', err)
        callback(null) // 兜底:返回null取消选择
      }
    },
  )

  ipcMain.on('hid-device-selected', (_, deviceId) => {
    if (hidDeviceSelectCallback) {
      // 执行HID选择的最终回调,返回设备ID
      hidDeviceSelectCallback(deviceId || null)
      // 清空回调,避免内存泄漏
      hidDeviceSelectCallback = null
    }
  })

preload.ts

ts
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('api', {
  // 接收主进程的设备列表,触发渲染进程的UI选择
  onShowHidSelector: callback => {
    ipcRenderer.on('show-hid-selector', (_, deviceList, resolve) => {
      callback(deviceList, resolve) // 把设备列表和回调传给Vue组件
    })
  },
  // 向主进程发送「设备选择结果」
  sendSelectedHidDevice: deviceId => {
    ipcRenderer.send('hid-device-selected', deviceId)
  },
  // 可选:主动获取设备列表(备用)
  getHidDevices: () => ipcRenderer.invoke('select-hid-device'),
})

渲染层配置

app.vue

vue
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted } from 'vue'

const win = window as any

const showDeviceSelector = ref(false)
const deviceList = ref([])
let resolveSelector = null // 存储主进程的回调函数

onMounted(() => {
  // 监听主进程发来的「显示设备选择弹窗」指令
  win.api.onShowHidSelector(async (list, resolve) => {
    showDeviceSelector.value = true
    deviceList.value = list
    resolveSelector = resolve // 保存回调,用于返回选择结果

    console.info('list', list)

    const selectedDevice = await showElectronDeviceSelector(deviceList.value)

    if (!selectedDevice) {
      console.info('用户取消了设备选择')
      win.api.sendSelectedHidDevice(null)
      return null
    }
    // 向主进程发送选择结果
    win.api.sendSelectedHidDevice(selectedDevice.deviceId)

    // // 只保留用户选择的设备
    // devices = [selectedDevice]

    console.info('selectedDevice', selectedDevice)
  })
})

const requestHidDevice = async () => {
  try {
    testHID()
  } catch (err) {
    console.error('请求设备失败:', err) // 此时能看到具体错误
  }
}

async function testHID() {
  // const devices = await navigator.hid.getDevices()
  // vid: 0x1915,
  //         pid: 0x2345,
  const devices = await navigator.hid.requestDevice({
    filters: [
      {
        productId: 0x2345,
        vendorId: 0x1915,
      },
    ],
  })
  console.log('grantedDevices', devices)

  // const allAuthorizedDevices = await navigator.hid.getDevices()
  // console.log('grantedDevices', allAuthorizedDevices)

  return

  // 环境判断
  if (isElectronEnv() && devices.length > 1) {
    console.info('electron env ----------------')
    // 在Electron环境中且有多个设备时,显示设备选择弹窗
    const selectedDevice = await showElectronDeviceSelector(devices)

    if (!selectedDevice) {
      console.info('用户取消了设备选择')
      return null
    }

    // // 只保留用户选择的设备
    // devices = [selectedDevice]

    console.info('selectedDevice', selectedDevice)
  }
}

/********************************************************************************
 * @brief: 判断是否为Electron环境
 * @return {boolean} 是否为Electron环境
 ********************************************************************************/
async function isElectronEnv(): Promise<boolean> {
  // 检查是否为Electron环境的几种方式
  if (typeof window !== 'undefined') {
    // 检查window对象是否有electron属性
    const electronWindow = window as any
    if (electronWindow.electron) {
      return true
    }
    // 检查是否有navigator.userAgent包含Electron
    if (navigator.userAgent.includes('Electron')) {
      return true
    }
  }

  // 检查process对象
  if (typeof process !== 'undefined') {
    return typeof process.versions === 'object' && !!process.versions.electron
  }

  return false
}

/********************************************************************************
 * @brief: 在Electron环境中显示设备选择弹窗
 * @param {HIDDevice[]} devices 设备列表
 * @return {Promise<HIDDevice | null>} 用户选择的设备
 ********************************************************************************/
async function showElectronDeviceSelector(devices: any): Promise<any | null> {
  return new Promise(resolve => {
    // 创建弹窗容器
    const dialog = document.createElement('div')
    dialog.style.cssText = `
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background-color: rgba(0, 0, 0, 0.5);
      z-index: 9999;
      display: flex;
      justify-content: flex-start;
      align-items: flex-start;
      padding: 20px;
      box-sizing: border-box;
    `

    // 创建弹窗内容
    const content = document.createElement('div')
    content.style.cssText = `
      background-color: #2a2a2a;
      border-radius: 8px;
      padding: 16px;
      width: 430px;
      max-height: 80vh;
      overflow-y: auto;
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
    `

    // 创建标题
    const title = document.createElement('h3')
    title.textContent = '选择设备'
    title.style.cssText = `
      color: #ffffff;
      margin-top: 0;
      margin-bottom: 16px;
      font-size: 18px;
    `
    content.appendChild(title)

    // 创建设备列表
    const deviceList = document.createElement('div')
    deviceList.style.cssText = `
      display: flex;
      flex-direction: column;
      gap: 8px;
    `

    devices.forEach((device, index) => {
      const deviceItem = document.createElement('div')
      deviceItem.style.cssText = `
        padding: 12px;
        background-color: #3a3a3a;
        border-radius: 4px;
        cursor: pointer;
        transition: background-color 0.2s;
      `
      deviceItem.addEventListener('click', () => {
        resolve(device)
        document.body.removeChild(dialog)
      })
      deviceItem.addEventListener('mouseenter', () => {
        deviceItem.style.backgroundColor = '#4a4a4a'
      })
      deviceItem.addEventListener('mouseleave', () => {
        deviceItem.style.backgroundColor = '#3a3a3a'
      })

      // 设备名称
      const deviceName = document.createElement('div')
      deviceName.textContent = `设备名称: ${device.productName || device.name || '未知设备'}`
      deviceName.style.cssText = `
        color: #ffffff;
        font-weight: 500;
        margin-bottom: 4px;
      `
      deviceItem.appendChild(deviceName)

      // 设备信息
      const deviceInfo = document.createElement('div')
      deviceInfo.textContent = `VID: ${device.vendorId.toString(16).padStart(4, '0')}, PID: ${device.productId.toString(16).padStart(4, '0')}`
      deviceInfo.style.cssText = `
        color: #cccccc;
        font-size: 14px;
      `
      deviceItem.appendChild(deviceInfo)

      deviceList.appendChild(deviceItem)
    })

    content.appendChild(deviceList)

    // 创建取消按钮
    const cancelBtn = document.createElement('button')
    cancelBtn.textContent = '取消'
    cancelBtn.style.cssText = `
      margin-top: 16px;
      padding: 8px 16px;
      background-color: #4a4a4a;
      color: #ffffff;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      transition: background-color 0.2s;
      width: 100%;
    `
    cancelBtn.addEventListener('click', () => {
      resolve(null)
      document.body.removeChild(dialog)
    })
    cancelBtn.addEventListener('mouseenter', () => {
      cancelBtn.style.backgroundColor = '#5a5a5a'
    })
    cancelBtn.addEventListener('mouseleave', () => {
      cancelBtn.style.backgroundColor = '#4a4a4a'
    })
    content.appendChild(cancelBtn)

    dialog.appendChild(content)
    document.body.appendChild(dialog)
  })
}
</script>

<template>
  <!-- <MainPage /> -->
  <!-- <div class="bg-box"></div> -->

  <!-- <n-config-provider :locale="zhCN">
    <n-dialog-provider>
      <n-notification-provider>
        <keep-alive>
          <RouterView />
        </keep-alive>
      </n-notification-provider>
    </n-dialog-provider>
  </n-config-provider> -->

  <button @click="requestHidDevice">连接HID设备</button>
</template>

<style lang="scss">
:root {
  --content-box-color: rgb(255, 255, 255);
  --pop-box-color: rgba(255, 255, 255, 0.95);

  // 组件颜色
  --bg-color-comp-1: rgba(255, 255, 255, 0.95);
  --bg-color-comp-2: rgba(51, 51, 51, 0.25);
  --bg-color-comp-3: rgba(51, 51, 51, 0.1);
  --bg-color-comp-4: rgba(129, 241, 185, 0.5);

  // 文字颜色
  --text-color-1: rgba(51, 51, 51, 0.85);
  --text-color-2: rgba(51, 51, 51, 0.5);
  --text-color-3: rgba(216, 211, 211, 0.8);
  --text-color-4: rgba(255, 255, 255, 0.8);

  --span-text-color: rgb(143, 206, 202);

  --gap-1: 9px;
  --gap-2: 15px;

  // 层级
  --z-index-1: 100;
}

[main-theme='color'] {
  --content-box-color: rgb(255, 255, 255);
  --pop-box-color: rgba(255, 255, 255, 0.95);
  --text-color-1: rgba(51, 51, 51, 0.85);
  --text-color-2: rgba(51, 51, 51, 0.5);
  --span-text-color: rgb(143, 206, 202);
}

@font-face {
  font-family: 'ceyy';
  src: url('./assets/font/ceyy.ttf');
}

img[src='']:not([src]),
img:not([src]) {
  width: 0;
  height: 0;
  opacity: 0;
}
.bg-box {
  @include global.fixed-center;
  @include global.full_wh;
  z-index: -1;
}

@include global.app_common;

#app {
  background: white;
}
</style>

Released under the GPL License.