mcpField
E
electron-dev

electron-dev

github.com/jamditis开发438307 次安装4 天前更新
代码仓库

关于 electron-dev

面向使用 React 和 TypeScript 构建生产级 Electron 应用的开发技能,涵盖安全 IPC、打包以及常见桌面功能的实现模式。

主要功能

  1. 01Electron 安全配置
  2. 02使用 contextBridge 实现类型化 IPC
  3. 03系统托盘与全局快捷键
  4. 04PTY 终端与音频模式
  5. 05WebRTC 与打包指南

适用场景

  1. 01审查 Electron 安全与 IPC 设计
  2. 02使用 React 和 TypeScript 构建桌面应用
  3. 03添加托盘、快捷键或终端支持

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/jamditis/claude-skills-journalism --skill electron-dev

安装后在 Claude Code 中输入/electron-dev确认已加载。

附带文件agents/openai.yaml

详细介绍

Electron 桌面开发

使用 React 和 TypeScript 构建生产级 Electron 应用的模式与实践。

安全基线(Electron 30+)

过去几个版本中,Electron 的默认设置已变得更加安全。从 Electron 28+ 开始,新建的 BrowserWindow 实例默认启用 contextIsolation: true 和 sandbox: true。较早的指南中的许多安全建议都假设你需要主动启用这些设置。现在不需要了;你需要主动关闭它们,而你不应该这么做。

仍应显式设置这些选项,以免配置偏移削弱安全模型:

const win = new BrowserWindow({
  webPreferences: {
    contextIsolation: true,        // default since 12, mandatory for any prod app
    sandbox: true,                  // default since 28; renderer runs sandboxed
    nodeIntegration: false,         // never enable in renderer
    webSecurity: true,              // never disable
    preload: path.join(__dirname, 'preload.cjs')
  }
});

在主进程中验证每条 IPC 消息。不要信任渲染进程。

Electron Fuses + ASAR 完整性

Electron Fuses 是在打包时写入二进制文件的开关。以下两个开关与安全分发相关:

  • EnableEmbeddedAsarIntegrityValidation,在运行时将 app.asar 的哈希值与嵌入在二进制文件中的哈希值进行比对。可防止攻击者在安装后替换 asar 内容。
  • OnlyLoadAppFromAsar,拒绝从已验证的 asar 以外加载应用代码。

这两个开关都需要主动启用,并非默认启用。生产环境中应同时启用。生成包含可嵌入完整性信息的 asar 需要 @electron/asar 3.1.0+。electron-builder 可通过构建配置中的 electronFuses 配置这些开关;@electron/fuses 则可通过编程方式配置。

CVE-2023-44402(通过文件类型混淆绕过 ASAR 完整性验证)是采用这两项措施的典型原因。如果没有完整性验证和仅从 asar 加载的限制,能够修改应用文件的攻击者就可以悄无声息地替换应用行为。

常见的渲染进程风险
  • 预加载脚本混淆,仅通过 contextBridge.exposeInMainWorld 暴露范围有限且有类型定义的接口。绝不要重新导出 ipcRenderer 本身;应暴露映射到特定通道的具体方法。
  • file:// IPC 和导航,对于不应更改 URL 的窗口,使用 webContents.on('will-navigate', e => e.preventDefault()) 限制导航。默认拒绝 setWindowOpenHandler 请求;仅允许特定来源。
  • 使用用户输入调用 shell.openExternal,打开前验证 URL scheme。攻击者控制的 file:// 或 javascript: URL 会让攻击者获得代码执行能力。

架构模式

项目结构
app/
├── electron/
│   ├── main.cjs              # Main process (CommonJS required)
│   ├── preload.cjs           # Context bridge for secure IPC
│   └── server.cjs            # Optional: WebSocket/HTTP server
├── src/
│   ├── components/           # React components
│   ├── services/             # Business logic (API clients, Firebase)
│   ├── utils/                # Utilities (audio, formatting)
│   ├── types.ts              # TypeScript interfaces
│   ├── App.tsx               # Root component
│   └── index.tsx             # React entry
├── assets/                   # Icons, sounds, images
├── package.json
├── vite.config.ts
└── electron-builder.yml      # Build configuration
IPC 通信模式

主进程(main.cjs):

const { ipcMain } = require('electron');

// Handle async requests from renderer
ipcMain.handle('action-name', async (event, args) => {
  try {
    const result = await someAsyncOperation(args);
    return { success: true, data: result };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

// Send data to renderer
mainWindow.webContents.send('event-name', data);

预加载脚本(preload.cjs):

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

contextBridge.exposeInMainWorld('electron', {
  actionName: (args) => ipcRenderer.invoke('action-name', args),
  onEventName: (callback) => {
    const handler = (event, data) => callback(data);
    ipcRenderer.on('event-name', handler);
    return () => ipcRenderer.removeListener('event-name', handler);
  }
});

渲染进程(React):

const result = await window.electron.actionName(args);

useEffect(() => {
  return window.electron.onEventName((data) => {
    setState(data);
  });
}, []);

系统托盘集成

const { Tray, Menu, nativeImage } = require('electron');

let tray = null;

function createTray() {
  const icon = nativeImage.createFromPath(path.join(__dirname, '../assets/tray-icon.png'));
  tray = new Tray(icon.resize({ width: 16, height: 16 }));

  tray.setToolTip('App Name');
  tray.setContextMenu(Menu.buildFromTemplate([
    { label: 'Show', click: () => mainWindow.show() },
    { label: 'Quit', click: () => app.quit() }
  ]));

  tray.on('click', () => {
    mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show();
  });
}

// Hide to tray instead of closing
mainWindow.on('close', (event) => {
  if (!app.isQuitting) {
    event.preventDefault();
    mainWindow.hide();
  }
});

全局快捷键

const { globalShortcut } = require('electron');

app.whenReady().then(() => {
  // Register with conflict detection
  const registered = globalShortcut.register('Alt+S', () => {
    mainWindow.webContents.send('shortcut-triggered', 'toggle-recording');
  });

  if (!registered) {
    console.error('Shortcut registration failed - conflict detected');
  }
});

app.on('will-quit', () => {
  globalShortcut.unregisterAll();
});

PTY 终端集成(node-pty)

const pty = require('node-pty');

const shell = process.platform === 'win32' ? 'powershell.exe' : process.env.SHELL || '/bin/bash';

const ptyProcess = pty.spawn(shell, [], {
  name: 'xterm-256color',
  cols: 80,
  rows: 24,
  cwd: process.env.HOME,
  env: process.env
});

ptyProcess.onData((data) => {
  mainWindow.webContents.send('terminal-data', { tabId, data });
});

ipcMain.on('terminal-write', (event, { tabId, data }) => {
  ptyProcess.write(data);
});

ipcMain.on('terminal-resize', (event, { tabId, cols, rows }) => {
  ptyProcess.resize(cols, rows);
});

音频录制工作流

// Request microphone access
const stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true
  }
});

// Record audio
const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
const chunks: Blob[] = [];

mediaRecorder.ondataavailable = (e) => chunks.push(e.data);
mediaRecorder.onstop = async () => {
  const blob = new Blob(chunks, { type: 'audio/webm' });
  const base64 = await blobToBase64(blob);
  // Send to transcription API
};

mediaRecorder.start();
// Later: mediaRecorder.stop();

WebRTC 模式(PeerJS)

import Peer from 'peerjs';

const peer = new Peer(userId, {
  host: 'peerjs-server.com',
  port: 443,
  secure: true
});

// Answer incoming calls
peer.on('call', (call) => {
  call.answer(localStream);
  call.on('stream', (remoteStream) => {
    audioElement.srcObject = remoteStream;
  });
});

// Make outgoing calls
const call = peer.call(remoteUserId, localStream);
call.on('stream', (remoteStream) => {
  audioElement.srcObject = remoteStream;
});

// Screen sharing via replaceTrack (no renegotiation)
const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const videoTrack = screenStream.getVideoTracks()[0];
const sender = peerConnection.getSenders().find(s => s.track?.kind === 'video');
await sender.replaceTrack(videoTrack);

构建配置(electron-builder.yml)

appId: com.yourname.appname
productName: AppName
directories:
  output: release

win:
  target:
    - target: nsis
      arch: [x64]
  icon: assets/icon.ico

nsis:
  oneClick: false
  allowToChangeInstallationDirectory: true
  installerIcon: assets/icon.ico
  uninstallerIcon: assets/icon.ico

mac:
  target:
    - target: dmg
      arch: [x64, arm64]
  icon: assets/icon.icns
  hardenedRuntime: true
  gatekeeperAssess: false
  entitlements: build/entitlements.mac.plist
  entitlementsInherit: build/entitlements.mac.plist
  notarize:
    teamId: YOUR_APPLE_TEAM_ID

linux:
  target:
    - target: AppImage
      arch: [x64]
  icon: assets/icon.png

publish:
  provider: github
  owner: username
  repo: repo-name

extraResources:
  - from: "node_modules/node-pty/build/Release/"
    to: "node-pty/"
    filter: ["*.node"]

macOS 公证是应用在 App Store 之外分发的必要条件;Gatekeeper 会在首次启动时阻止未公证的应用。运行 npm run package 前,设置环境变量 APPLE_ID、APPLE_APP_SPECIFIC_PASSWORD 和 APPLE_TEAM_ID(或使用 App Store Connect API 密钥)。electron-builder ≥ 24.13 可通过 mac.notarize 字段原生处理公证;较旧版本则需要 electron-notarize afterSign 钩子。

对于 Windows,为避免 SmartScreen 警告,使用 EV 证书进行代码签名正变得越来越必要。electron-builder 会读取环境变量 CSC_LINK(PFX)和 CSC_KEY_PASSWORD。

常见问题

回调中的闭包过时:

// Problem: State is stale in async callbacks
const [state, setState] = useState(initialValue);
peer.on('call', () => {
  console.log(state); // Always shows initialValue
});

// Solution: Use refs for async callback access
const stateRef = useRef(state);
useEffect(() => { stateRef.current = state; }, [state]);
peer.on('call', () => {
  console.log(stateRef.current); // Current value
});

上下文隔离安全:

  • 切勿直接向渲染进程暴露 ipcRenderer
  • 始终使用 contextBridge.exposeInMainWorld()
  • 在主进程中验证所有 IPC 参数
  • 使用 TypeScript 接口定义 IPC 契约

BrowserView 已弃用,请使用 WebContentsView:

BrowserView 已于 Electron 30(2024 年 4 月)弃用,其底层实现也已替换。BrowserView 仍可作为基于 WebContentsView 的兼容层使用,但新代码应直接面向 WebContentsView。两者的构造函数接受相同的 webPreferences 结构,因此迁移基本上只需机械修改。值得了解的差异如下:

  • 使用 win.contentView.addChildView(view) 添加 WebContentsView,而不是 win.addBrowserView(view)
  • 通过 view.setBounds({x, y, width, height}) 设置尺寸,没有 setAutoResize。如需自动调整大小,请自行编写调整大小处理程序。
  • Z 轴顺序取决于 addChildView 调用的顺序;先调用 removeChildView,再调用 addChildView,即可将视图置于前面。
const { WebContentsView } = require('electron');

const view = new WebContentsView({
  webPreferences: { contextIsolation: true, sandbox: true }
});
view.webContents.loadURL('https://example.com');
mainWindow.contentView.addChildView(view);
view.setBounds({ x: 0, y: 80, width: 800, height: 520 });

有关边缘情况(弹出窗口、devtools、焦点管理),请参阅官方的 BrowserView → WebContentsView 迁移指南。

跨平台 shell 检测:

const shell = process.platform === 'win32'
  ? 'powershell.exe'
  : process.env.SHELL || '/bin/bash';

const shellArgs = process.platform === 'win32'
  ? ['-NoLogo']
  : [];

开发工作流

# Development (hot reload)
npm run electron:dev

# Production build
npm run electron:build

# Run built app locally
npx electron dist/

# Package for distribution
npm run package

评论

支持 Markdown