--- name: electron-dev description: Electron desktop application development with React, TypeScript, and Vite. Use when building desktop apps, implementing IPC communication, managing windows/tray, handling PTY terminals, integrating WebRTC/audio, or packaging with electron-builder. Covers patterns from AudioBash, Yap, and Pisscord projects. --- # Electron desktop development Patterns and practices for building production-quality Electron applications with React and TypeScript. ## Security baseline (Electron 30+) Electron's defaults have hardened over the past several releases. As of Electron 28+, `contextIsolation: true` and `sandbox: true` are the defaults for new BrowserWindow instances — most security advice from older guides assumed you had to opt in. You don't anymore; you have to opt OUT, and you should not. Set explicitly anyway, so a config drift never weakens the security model: ```javascript 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') } }); ``` Validate every IPC message in main. Don't trust the renderer. ### Electron Fuses + ASAR integrity Electron Fuses are package-time toggles baked into the binary. The two relevant for security distribution: - `EnableEmbeddedAsarIntegrityValidation` — verifies the app.asar hash at runtime against a hash embedded in the binary. Defends against attackers swapping the asar contents post-install. - `OnlyLoadAppFromAsar` — refuses to load app code from anywhere except the validated asar. These are **opt-in**, not default. Enable both for production. Requires `@electron/asar` 3.1.0+ to generate the asar with embeddable integrity. electron-builder configures this via `electronFuses` in the build config; `@electron/fuses` does it programmatically. CVE-2023-44402 (ASAR integrity bypass via filetype confusion) was the canonical motivation here — without integrity + only-load-from-asar, an attacker who can modify app files can swap behavior silently. ### Common renderer-side risks - **Preload script confusion** — only expose narrow, typed surfaces via `contextBridge.exposeInMainWorld`. Never re-export `ipcRenderer` itself; expose specific methods that map to specific channels. - **`file://` IPC and navigation** — restrict navigation with `webContents.on('will-navigate', e => e.preventDefault())` for windows that shouldn't change URL. Deny `setWindowOpenHandler` requests by default; allow-list specific origins. - **`shell.openExternal` with user input** — validate the URL scheme before opening. An attacker-controlled `file://` or `javascript:` URL hands them code execution. ## Architecture patterns ### Project structure ``` 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 communication pattern **Main process (main.cjs):** ```javascript 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 script (preload.cjs):** ```javascript 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); } }); ``` **Renderer (React):** ```typescript const result = await window.electron.actionName(args); useEffect(() => { return window.electron.onEventName((data) => { setState(data); }); }, []); ``` ## System tray integration ```javascript 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(); } }); ``` ## Global shortcuts ```javascript 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 terminal integration (node-pty) ```javascript 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); }); ``` ## Audio recording workflow ```typescript // 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 patterns (PeerJS) ```typescript 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); ``` ## Build configuration (electron-builder.yml) ```yaml 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 notarization** is required for distribution outside the App Store; Gatekeeper blocks unnotarized apps on first launch. Set the env vars `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID` (or use an App Store Connect API key) before running `npm run package`. electron-builder ≥ 24.13 handles notarization natively via the `mac.notarize` field; older versions require the `electron-notarize` afterSign hook. For Windows, code signing with an EV cert is increasingly necessary to avoid SmartScreen warnings. electron-builder reads `CSC_LINK` (PFX) and `CSC_KEY_PASSWORD` env vars. ## Common pitfalls **Stale closures in callbacks:** ```typescript // 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 }); ``` **Context isolation security:** - Never expose `ipcRenderer` directly to renderer - Always use `contextBridge.exposeInMainWorld()` - Validate all IPC arguments in main process - Use TypeScript interfaces for IPC contracts **BrowserView is deprecated — use WebContentsView:** `BrowserView` was deprecated in Electron 30 (April 2024) and the underlying implementation has been replaced. `BrowserView` still works as a compatibility shim over `WebContentsView`, but new code should target `WebContentsView` directly. The constructors take the same `webPreferences` shape, so the migration is mostly mechanical. The differences worth knowing: - `WebContentsView` is added via `win.contentView.addChildView(view)` instead of `win.addBrowserView(view)` - Sizing is via `view.setBounds({x, y, width, height})` — no `setAutoResize`. You wire your own resize handlers if you want auto-resize. - Z-order is the order of `addChildView` calls; `removeChildView` then re-`addChildView` to bring forward. ```javascript 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 }); ``` See the official [BrowserView → WebContentsView migration guide](https://www.electronjs.org/blog/migrate-to-webcontentsview) for edge cases (popups, devtools, focus management). **Cross-platform shell detection:** ```javascript const shell = process.platform === 'win32' ? 'powershell.exe' : process.env.SHELL || '/bin/bash'; const shellArgs = process.platform === 'win32' ? ['-NoLogo'] : []; ``` ## Development workflow ```bash # 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 ```