Claude Code for Electron: Desktop Apps with Web Technologies — Claude Skills 360 Blog
Blog / Development / Claude Code for Electron: Desktop Apps with Web Technologies
Development

Claude Code for Electron: Desktop Apps with Web Technologies

Published: June 21, 2026
Read time: 9 min read
By: Claude Skills 360

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.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free