关于 electron-dev
面向使用 React 和 TypeScript 构建生产级 Electron 应用的开发技能,涵盖安全 IPC、打包以及常见桌面功能的实现模式。
主要功能
- 01Electron 安全配置
- 02使用 contextBridge 实现类型化 IPC
- 03系统托盘与全局快捷键
- 04PTY 终端与音频模式
- 05WebRTC 与打包指南
适用场景
- 01审查 Electron 安全与 IPC 设计
- 02使用 React 和 TypeScript 构建桌面应用
- 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