All skills
jwynia avatar

/electron-best-practices

@075cb8f
by J Wyniajwynia/agent-skills160 stars
20

Guide AI agents through Electron app development with React including security patterns, type-safe IPC, React integration, packaging with code signing, and testing. Keywords: electron, electron-vite, electron-forge, contextBridge, IPC, security, react, packaging, code signing, notarization, playwright, desktop app.

Use this Skill: https://skilld.dev/gh/jwynia/agent-skills/electron-best-practices

This session only. Nothing lands on disk.

referencesarchitectureproject-structure.md

≈3.1k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Project Structure: Directory Layout and electron-vite Configuration

This reference covers the recommended directory organization for Electron applications built with electron-vite and React, including configuration patterns, build tooling setup, and the rationale behind each structural decision.

Scaffolding a New Project

The fastest way to start is with the official electron-vite template:

npm create @quick-start/electron@latest my-app -- --template react-ts

This generates a fully configured project with TypeScript, React, and secure defaults. The template includes separate TypeScript configurations per process, a working electron-vite config, and a preload script with contextBridge already wired up.

Recommended Directory Layout

my-electron-app/
├── electron.vite.config.ts    # Unified build config for all three processes
├── package.json               # Scripts, dependencies, Electron Forge config
├── tsconfig.json              # Base TypeScript config (shared settings)
├── tsconfig.node.json         # Main + preload process config (extends base)
├── tsconfig.web.json          # Renderer process config (extends base)
├── src/
│   ├── main/                  # Main process (Node.js environment)
│   │   ├── index.ts           # App entry point, BrowserWindow creation
│   │   └── ipc/               # IPC handler modules
│   │       ├── file-handlers.ts
│   │       └── app-handlers.ts
│   ├── preload/               # Secure bridge between main and renderer
│   │   ├── index.ts           # contextBridge API exposure
│   │   └── index.d.ts         # TypeScript declarations for renderer
│   ├── renderer/              # React application (pure web environment)
│   │   ├── index.html         # HTML entry point (Vite uses this)
│   │   └── src/
│   │       ├── App.tsx        # Root React component
│   │       ├── main.tsx       # React DOM root creation
│   │       ├── components/    # Reusable UI components
│   │       ├── hooks/         # Custom React hooks (including IPC hooks)
│   │       ├── pages/         # Route-level components (if using routing)
│   │       └── assets/        # Static assets (images, fonts, CSS)
│   └── shared/                # Shared type definitions (no runtime code)
│       └── ipc-types.ts       # IPC channel type map
├── resources/                 # App icons, build assets, platform resources
├── out/                       # Build output (gitignored)
└── dev-app-update.yml         # Auto-update config for development

Directory Responsibilities

src/main/ -- Main Process

The main process runs in a full Node.js environment. It manages application lifecycle, creates and controls BrowserWindows, handles IPC from renderers, and accesses system APIs (file system, native dialogs, notifications).

// src/main/index.ts
import { app, BrowserWindow } from 'electron';
import { join } from 'path';
import { registerFileHandlers } from './ipc/file-handlers';
import { registerAppHandlers } from './ipc/app-handlers';

function createWindow(): BrowserWindow {
  const win = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      contextIsolation: true,
      sandbox: true,
      nodeIntegration: false,
    },
  });

  // electron-vite handles dev server vs production file loading
  if (process.env['ELECTRON_RENDERER_URL']) {
    win.loadURL(process.env['ELECTRON_RENDERER_URL']);
  } else {
    win.loadFile(join(__dirname, '../renderer/index.html'));
  }

  return win;
}

app.whenReady().then(() => {
  registerFileHandlers();
  registerAppHandlers();
  createWindow();
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});

Organize IPC handlers into separate modules under src/main/ipc/ by domain. This keeps the main entry file focused on lifecycle and window management.

// src/main/ipc/file-handlers.ts
import { ipcMain, dialog } from 'electron';
import { readFile, writeFile } from 'fs/promises';

export function registerFileHandlers(): void {
  ipcMain.handle('save-file', async (_event, content: string) => {
    const { canceled, filePath } = await dialog.showSaveDialog({
      filters: [{ name: 'Text', extensions: ['txt'] }],
    });

    if (canceled || !filePath) {
      return { success: false, error: 'Save cancelled' };
    }

    try {
      await writeFile(filePath, content, 'utf-8');
      return { success: true, data: filePath };
    } catch (err) {
      return { success: false, error: (err as Error).message };
    }
  });

  ipcMain.handle('open-file', async () => {
    const { canceled, filePaths } = await dialog.showOpenDialog({
      properties: ['openFile'],
      filters: [{ name: 'Text', extensions: ['txt', 'md'] }],
    });

    if (canceled || filePaths.length === 0) {
      return { success: false, error: 'Open cancelled' };
    }

    try {
      const content = await readFile(filePaths[0], 'utf-8');
      return { success: true, data: { path: filePaths[0], content } };
    } catch (err) {
      return { success: false, error: (err as Error).message };
    }
  });
}

src/preload/ -- Secure Bridge

The preload script runs in a restricted context with access to contextBridge. It defines the exact API surface available to the renderer. Keep preload scripts thin -- they should only translate between IPC calls and the exposed API.

// src/preload/index.ts
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';

contextBridge.exposeInMainWorld('electronAPI', {
  // Request-response (invoke/handle)
  saveFile: (content: string) => ipcRenderer.invoke('save-file', content),
  openFile: () => ipcRenderer.invoke('open-file'),

  // Event subscriptions (returns cleanup function)
  onFileChanged: (callback: (path: string) => void) => {
    const handler = (_event: IpcRendererEvent, path: string) => callback(path);
    ipcRenderer.on('file-changed', handler);
    return () => ipcRenderer.removeListener('file-changed', handler);
  },
});
// src/preload/index.d.ts
export interface ElectronAPI {
  saveFile: (content: string) => Promise<{ success: boolean; data?: string; error?: string }>;
  openFile: () => Promise<{ success: boolean; data?: { path: string; content: string }; error?: string }>;
  onFileChanged: (callback: (path: string) => void) => () => void;
}

declare global {
  interface Window {
    electronAPI: ElectronAPI;
  }
}

src/renderer/ -- React Application

The renderer is a standard React application with no Node.js access. It communicates with the main process exclusively through window.electronAPI. Organize it as you would any React project -- components, hooks, pages, assets.

// src/renderer/src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './assets/main.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);

src/shared/ -- Shared Types

This directory holds TypeScript type definitions shared across processes. It must contain only types and interfaces -- no runtime code. This is the single source of truth for IPC channel definitions.

// src/shared/ipc-types.ts
export type IpcChannelMap = {
  'save-file': { args: [content: string]; return: IpcResult<string> };
  'open-file': { args: []; return: IpcResult<{ path: string; content: string }> };
};

export type IpcResult<T> = { success: true; data: T } | { success: false; error: string };

// Event channels (main -> renderer)
export type IpcEventMap = {
  'file-changed': [path: string];
};

Configuration Files

electron-vite Unified Config

electron-vite manages three separate Vite build pipelines through a single configuration file. Each key (main, preload, renderer) configures one process with independent plugins, aliases, and build targets.

// electron.vite.config.ts
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';

export default defineConfig({
  main: {
    plugins: [externalizeDepsPlugin()],
    resolve: {
      alias: {
        '@shared': resolve('src/shared'),
      },
    },
  },
  preload: {
    plugins: [externalizeDepsPlugin()],
    resolve: {
      alias: {
        '@shared': resolve('src/shared'),
      },
    },
  },
  renderer: {
    plugins: [react()],
    resolve: {
      alias: {
        '@': resolve('src/renderer/src'),
        '@shared': resolve('src/shared'),
      },
    },
  },
});

The externalizeDepsPlugin() is critical for main and preload -- it prevents bundling Node.js built-in modules and Electron APIs, which must be resolved at runtime rather than at build time.

TypeScript Configuration

Use three TypeScript configs to enforce process boundaries at the type level.

// tsconfig.json (base, shared settings)
{
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "paths": {
      "@shared/*": ["./src/shared/*"]
    }
  }
}
// tsconfig.node.json (main + preload)
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "target": "ESNext",
    "types": ["node"]
  },
  "include": ["src/main/**/*", "src/preload/**/*", "src/shared/**/*"]
}
// tsconfig.web.json (renderer)
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "target": "ESNext",
    "jsx": "react-jsx",
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "types": ["vite/client"]
  },
  "include": ["src/renderer/**/*", "src/shared/**/*", "src/preload/index.d.ts"]
}

Note that tsconfig.web.json includes src/preload/index.d.ts so the renderer knows the shape of window.electronAPI at compile time.

package.json Key Scripts

{
  "name": "my-electron-app",
  "version": "1.0.0",
  "main": "./out/main/index.js",
  "scripts": {
    "dev": "electron-vite dev",
    "build": "electron-vite build",
    "preview": "electron-vite preview",
    "start": "electron-vite preview",
    "lint": "eslint . --ext .ts,.tsx",
    "typecheck:node": "tsc --noEmit -p tsconfig.node.json",
    "typecheck:web": "tsc --noEmit -p tsconfig.web.json",
    "typecheck": "npm run typecheck:node && npm run typecheck:web",
    "package": "electron-forge package",
    "make": "electron-forge make",
    "publish": "electron-forge publish"
  }
}

The main field points to the built output, not the source. electron-vite's dev command starts the Vite dev server for the renderer with HMR and watches main/preload sources for changes, restarting Electron on rebuild.

Build Output Structure

After running electron-vite build, the out/ directory mirrors the src/ structure with compiled JavaScript:

out/
├── main/
│   └── index.js           # Bundled main process
├── preload/
│   └── index.js           # Bundled preload script
└── renderer/
    ├── index.html         # Processed HTML
    └── assets/            # Bundled CSS, JS, images

This clean separation ensures each process loads only its own bundle. The preload path in BrowserWindow configuration points to out/preload/index.js.

See Also

  • Process Separation -- Detailed guide on what code belongs in each process and the security boundaries between them
  • electron-vite Configuration -- Advanced electron-vite configuration including environment variables and custom plugins
  • Electron Forge -- Packaging, code signing, and distribution configuration
  • Typed IPC -- Full type-safe IPC channel patterns using the shared types directory

Source: SKILL.md on GitHub

2 warnings16d5 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    The skill provides comprehensive guidance, templates, reference scripts, and architectural configurations for developing secure Electron applications with React. All components adhere strictly to security standards, and all external tools, scripts, and configurations are handled using safe practices without any malicious or suspicious patterns.

  • Socket16d

    1 alert: gptSecurity

  • Snyk16d

    Risk: LOW · No issues

  • Runlayer7mo

    35/35 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at 075cb8f. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Dormantupdated 8 months ago
Other metadata
compatibility
Requires Deno for analysis scripts. Applicable to any Electron project using TypeScript and React.
metadata
{
  "author": "agent-skills",
  "version": "1.0",
  "domain": "development",
  "type": "utility",
  "mode": "assistive"
}

README badge

README badge for jwynia/agent-skills/electron-best-practices