Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/jamditis-electron-dev/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 38% | 0% |
| case-14 | ✗→✓ | ▲ Improved | 97% | 0% |
| case-16 | ✗→✓ | ▲ Improved | 98% | 0% |
| case-18 | ✗→✓ | ▲ Improved | 190% | 0% |
| case-06 | ✓→✗ | ▼ Worse | 159% | 0% |
Patterns and practices for building production-quality Electron applications with React and TypeScript.
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:
javascriptconst 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 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.
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.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 configurationMain process (main.cjs):
javascriptconst { 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):
javascriptconst { 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):
typescriptconst result = await window.electron.actionName(args); useEffect(() => { return window.electron.onEventName((data) => { setState(data); }); }, []);
javascriptconst { 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(); } });
javascriptconst { 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(); });
javascriptconst 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); });
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();
typescriptimport 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);
yamlappId: 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.
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:
ipcRenderer directly to renderercontextBridge.exposeInMainWorld()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)view.setBounds({x, y, width, height}), no setAutoResize. You wire your own resize handlers if you want auto-resize.addChildView calls; removeChildView then re-addChildView to bring forward.javascriptconst { 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 for edge cases (popups, devtools, focus management).
Cross-platform shell detection:
javascriptconst shell = process.platform === 'win32' ? 'powershell.exe' : process.env.SHELL || '/bin/bash'; const shellArgs = process.platform === 'win32' ? ['-NoLogo'] : [];
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
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-06 | pass→fail | 9,848 | 10,108 | +3% | 1 | 1 | 0% | 2,004 | 5,200 | +159% | 0 | 0 | — |
case-04 | pass→pass | 15,090 | 12,589 | -17% | 1 | 1 | 0% | 2,503 | 5,428 | +117% | 0 | 0 | — |
case-05 | pass→pass | 12,306 | 13,611 | +11% | 1 | 1 | 0% | 2,292 | 5,798 | +153% | 0 | 0 | — |
case-01 | fail→pass | 23,435 | 17,590 | -25% | 1 | 1 | 0% | 4,927 | 6,813 | +38% | 0 | 0 | — |
case-02 | pass→pass | 12,853 | 8,450 | -34% | 1 | 1 | 0% | 2,413 | 4,954 | +105% | 0 | 0 | — |
case-03 | pass→pass | 9,313 | 4,982 | -47% | 1 | 1 | 0% | 1,620 | 4,084 | +152% | 0 | 0 | — |
case-07 | pass→pass | 9,129 | 9,012 | -1% | 1 | 1 | 0% | 1,948 | 5,192 | +167% | 0 | 0 | — |
case-08 | pass→pass | 10,901 | 12,584 | +15% | 1 | 1 | 0% | 2,130 | 5,802 | +172% | 0 | 0 | — |
case-09 | pass→pass | 12,804 | 11,945 | -7% | 1 | 1 | 0% | 2,426 | 5,325 | +119% | 0 | 0 | — |
case-10 | pass→pass | 14,103 | 13,279 | -6% | 1 | 1 | 0% | 2,949 | 5,958 | +102% | 0 | 0 | — |
case-11 | pass→pass | 10,637 | 9,417 | -11% | 1 | 1 | 0% | 1,897 | 4,906 | +159% | 0 | 0 | — |
case-12 | pass→pass | 15,044 | 14,717 | -2% | 1 | 1 | 0% | 2,954 | 6,064 | +105% | 0 | 0 | — |
case-13 | pass→pass | 12,364 | 14,869 | +20% | 1 | 1 | 0% | 2,456 | 6,111 | +149% | 0 | 0 | — |
case-14 | fail→pass | 13,241 | 11,408 | -14% | 1 | 1 | 0% | 2,734 | 5,395 | +97% | 0 | 0 | — |
case-20 | pass→fail | 13,375 | 13,609 | +2% | 1 | 1 | 0% | 2,693 | 5,972 | +122% | 0 | 0 | — |
case-15 | pass→pass | 9,662 | 4,765 | -51% | 1 | 1 | 0% | 1,843 | 4,009 | +118% | 0 | 0 | — |
case-16 | fail→pass | 16,510 | 15,092 | -9% | 1 | 1 | 0% | 3,056 | 6,062 | +98% | 0 | 0 | — |
case-17 | fail→fail | 14,051 | 11,504 | -18% | 1 | 1 | 0% | 2,617 | 5,396 | +106% | 0 | 0 | — |
case-18 | fail→pass | 7,634 | 4,768 | -38% | 1 | 1 | 0% | 1,364 | 3,960 | +190% | 0 | 0 | — |
case-19 | pass→pass | 14,600 | 18,256 | +25% | 1 | 1 | 0% | 3,139 | 7,437 | +137% | 0 | 0 | — |
case-21 | pass→pass | 7,758 | 8,851 | +14% | 1 | 1 | 0% | 1,513 | 4,805 | +218% | 0 | 0 | — |
case-22 | pass→pass | 16,432 | 16,138 | -2% | 1 | 1 | 0% | 3,387 | 6,344 | +87% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +9 percentage points is the difference between those two pass rates over the 22 comparable cases. 2 cases got worse with the skill loaded, and they are included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
| Model | Method | Date | Lift |
|---|---|---|---|
| gemini-3.6-flash | verified | 8/21/2026 | +23% |
Other measured skills in the registry, with their headline benchmark lift.