Electron’s architecture is deceptively simple — a Chromium renderer talks to a Node.js main process via IPC. But getting security right, handling native OS features, and shipping auto-updates without regressions requires understanding patterns that Claude Code has learned from the broad body of Electron applications.
This guide covers Electron with Claude Code: main/renderer architecture, secure IPC, native integrations, and packaging.
Electron Architecture
CLAUDE.md for Electron Projects
## Electron App
- Framework: Electron 30+ with Electron Forge
- Renderer: React 18 + TypeScript + Vite
- Main process: Node.js (src/main/)
- Preload: src/preload/index.ts (bridge between main and renderer)
- IPC: contextBridge API only — no remote module, no nodeIntegration
## Security rules (NON-NEGOTIABLE)
- nodeIntegration: false in ALL BrowserWindow configs
- contextIsolation: true in ALL BrowserWindow configs
- No direct require() in renderer — everything through preload contextBridge
- ipcRenderer: only expose specific, validated handlers in preload
- webSecurity: never disable (never set webSecurity: false)
## IPC pattern
- Renderer → Main: ipcRenderer.invoke('channel', args) → ipcMain.handle('channel', handler)
- Main → Renderer: mainWindow.webContents.send('channel', data)
- Bidirectional: use handle/invoke (returns Promise) not sendSync
## File structure
- src/main/ — Main process code (full Node.js API)
- src/preload/ — Preload scripts (limited Node.js, runs in renderer context)
- src/renderer/ — React app (no Node.js API access)
- src/shared/ — Types shared between main and renderer
Main Process Setup
// src/main/index.ts
import { app, BrowserWindow, shell, nativeTheme } from 'electron';
import { join } from 'node:path';
import { electronApp, optimizer, is } from '@electron-toolkit/utils';
let mainWindow: BrowserWindow | null = null;
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
show: false, // Don't show until ready
autoHideMenuBar: true,
titleBarStyle: 'hiddenInset', // macOS native titlebar
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: true,
// Security defaults — these are already false but explicit for clarity
nodeIntegration: false,
contextIsolation: true,
},
});
// Show when ready to prevent white flash
mainWindow.on('ready-to-show', () => {
mainWindow!.show();
});
// Open external links in browser, not in Electron
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
// Load app
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.yourcompany.yourapp');
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window);
});
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
IPC Communication
The renderer needs to read and write files to the user's
documents folder. Set up secure IPC for file operations.
Preload Script
// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron';
import type { FileAPI } from '../shared/types';
// Only expose specific, validated operations to the renderer
// The renderer has NO access to ipcRenderer directly
const fileAPI: FileAPI = {
readFile: (filePath: string) =>
ipcRenderer.invoke('file:read', filePath),
writeFile: (filePath: string, content: string) =>
ipcRenderer.invoke('file:write', filePath, content),
selectFile: (options?: { extensions?: string[] }) =>
ipcRenderer.invoke('file:select', options),
onFileChanged: (callback: (path: string) => void) => {
const listener = (_event: Electron.IpcRendererEvent, path: string) => callback(path);
ipcRenderer.on('file:changed', listener);
// Return cleanup function
return () => ipcRenderer.removeListener('file:changed', listener);
},
};
contextBridge.exposeInMainWorld('fileAPI', fileAPI);
Main Process Handlers
// src/main/handlers/file.ts
import { ipcMain, dialog, app } from 'electron';
import { readFile, writeFile } from 'node:fs/promises';
import { join, resolve, normalize } from 'node:path';
const ALLOWED_BASE = app.getPath('documents');
// Validate that the path stays within allowed directory (prevent path traversal)
function validatePath(filePath: string): string {
const normalized = normalize(resolve(ALLOWED_BASE, filePath));
if (!normalized.startsWith(ALLOWED_BASE)) {
throw new Error('Access denied: path outside allowed directory');
}
return normalized;
}
export function registerFileHandlers(): void {
ipcMain.handle('file:read', async (_event, filePath: string) => {
const safePath = validatePath(filePath);
return readFile(safePath, 'utf-8');
});
ipcMain.handle('file:write', async (_event, filePath: string, content: string) => {
if (typeof content !== 'string') throw new Error('Content must be a string');
const safePath = validatePath(filePath);
await writeFile(safePath, content, 'utf-8');
return { success: true };
});
ipcMain.handle('file:select', async (_event, options?: { extensions?: string[] }) => {
const result = await dialog.showOpenDialog({
defaultPath: ALLOWED_BASE,
filters: options?.extensions
? [{ name: 'Files', extensions: options.extensions }]
: [{ name: 'All Files', extensions: ['*'] }],
properties: ['openFile'],
});
if (result.canceled) return null;
return result.filePaths[0];
});
}
Using IPC in Renderer
// src/renderer/hooks/useFile.ts
declare global {
interface Window {
fileAPI: FileAPI;
}
}
export function useFile() {
const [content, setContent] = useState<string>('');
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [isDirty, setIsDirty] = useState(false);
const openFile = async () => {
const path = await window.fileAPI.selectFile({ extensions: ['txt', 'md'] });
if (!path) return;
const fileContent = await window.fileAPI.readFile(path);
setContent(fileContent);
setCurrentPath(path);
setIsDirty(false);
};
const saveFile = async () => {
if (!currentPath) return;
await window.fileAPI.writeFile(currentPath, content);
setIsDirty(false);
};
const handleChange = (newContent: string) => {
setContent(newContent);
setIsDirty(true);
};
return { content, currentPath, isDirty, openFile, saveFile, handleChange };
}
Auto-Updates
Ship auto-updates for Mac and Windows signed builds.
Notify the user when an update is available, let them choose when to install.
// src/main/updater.ts
import { autoUpdater } from 'electron-updater';
import log from 'electron-log';
autoUpdater.logger = log;
autoUpdater.logger.transports.file.level = 'info';
autoUpdater.autoDownload = false; // Don't auto-download — ask the user
export function setupAutoUpdater(mainWindow: BrowserWindow): void {
autoUpdater.on('checking-for-update', () => {
mainWindow.webContents.send('updater:checking');
});
autoUpdater.on('update-available', (info) => {
mainWindow.webContents.send('updater:available', {
version: info.version,
releaseNotes: info.releaseNotes,
});
});
autoUpdater.on('update-not-available', () => {
mainWindow.webContents.send('updater:not-available');
});
autoUpdater.on('download-progress', (progress) => {
mainWindow.webContents.send('updater:progress', {
percent: Math.round(progress.percent),
transferred: progress.transferred,
total: progress.total,
});
});
autoUpdater.on('update-downloaded', (info) => {
mainWindow.webContents.send('updater:downloaded', { version: info.version });
});
autoUpdater.on('error', (error) => {
log.error('Auto-update error:', error);
mainWindow.webContents.send('updater:error', error.message);
});
// IPC: user clicks "Download" in the UI
ipcMain.handle('updater:download', () => {
autoUpdater.downloadUpdate();
});
// IPC: user clicks "Install and restart"
ipcMain.handle('updater:install', () => {
autoUpdater.quitAndInstall(false, true); // isSilent=false, isForceRunAfter=true
});
// Check on launch, then every 4 hours
autoUpdater.checkForUpdates();
setInterval(() => autoUpdater.checkForUpdates(), 4 * 60 * 60 * 1000);
}
Native OS Integration
Add system tray icon with a context menu.
Keep the app running in the tray when the window is closed.
// src/main/tray.ts
import { Tray, Menu, nativeImage, app } from 'electron';
import { join } from 'node:path';
export function createTray(mainWindow: BrowserWindow): Tray {
const icon = nativeImage.createFromPath(
join(__dirname, '../../resources/tray-icon.png')
);
const tray = new Tray(icon.resize({ width: 16, height: 16 }));
const updateMenu = (isConnected: boolean) => {
const menu = Menu.buildFromTemplate([
{
label: isConnected ? '● Connected' : '○ Disconnected',
enabled: false,
},
{ type: 'separator' },
{
label: 'Open',
click: () => {
mainWindow.show();
mainWindow.focus();
},
},
{
label: 'Preferences',
accelerator: 'Cmd+,',
click: () => {
mainWindow.show();
mainWindow.webContents.send('navigate', '/settings');
},
},
{ type: 'separator' },
{
label: 'Quit',
accelerator: 'Cmd+Q',
click: () => app.quit(),
},
]);
tray.setContextMenu(menu);
};
tray.setToolTip('Your App');
updateMenu(false);
tray.on('click', () => {
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show();
});
// Minimize to tray instead of closing
mainWindow.on('close', (event) => {
if (!app.isQuitting) {
event.preventDefault();
mainWindow.hide();
}
});
return tray;
}
For packaging and code signing, Electron Forge handles this with a single electron-forge make command. For the React frontend inside your Electron renderer, the Next.js App Router guide shows RSC patterns though note Electron uses standard React without a server. For testing Electron apps, the Playwright E2E guide covers @playwright/test with electron-playwright-helpers. The Claude Skills 360 bundle includes Electron skill sets for desktop app patterns. Start with the free tier to generate IPC communication boilerplate.