Use Cases (Updated: 6/1/2026)

Electron Desktop App Development with Claude Code: IPC, Preload, Packaging, and Auto Update

Build safer Electron apps with Claude Code: contextIsolation, preload, IPC, file access, packaging, and updates.

Electron Desktop App Development with Claude Code: IPC, Preload, Packaging, and Auto Update

Electron is attractive because one web UI can become a Windows, macOS, and Linux desktop app. The tradeoff is that an Electron app can reach much deeper into the operating system than a normal website. It can open files, show native dialogs, manage menus, and ship automatic updates. That makes the security boundary more important than the UI framework.

Claude Code is useful here when you use it as an implementation worker with narrow tasks: one IPC channel, one preload API, one file-access rule, one packaging change. It is risky when you ask it to create a whole desktop app, updater, and installer in one pass, because the diff becomes too large to review.

The current Electron baseline is clear: keep contextIsolation enabled, keep nodeIntegration off in the renderer, expose only small APIs through preload, and validate payloads in the main process. The official references worth keeping open are Context Isolation, Inter-Process Communication, Distribution Overview, and autoUpdater.

For a framework comparison, read the Tauri development guide. For typing the preload and renderer boundary, the TypeScript tips article is the natural companion.

The Architecture Boundary

Treat every Electron app as three separate surfaces.

LayerResponsibilityGood Claude Code taskHuman review focus
Main processBrowserWindow, OS APIs, files, menus, updatesAdd one IPC handler at a timeValidation, permissions, errors, path rules
PreloadMinimal bridge from renderer to ElectronExpose typed functions with contextBridgeNo raw ipcRenderer exposure
RendererUI state, forms, views, interactionBuild the screen and state flowNo direct Node or Electron access

That separation is the article’s main point. Claude Code can write the boilerplate quickly, but a human still has to review the permission model. If the renderer can send arbitrary channel names, read arbitrary paths, or receive the raw IPC event object, the app may work but the boundary is weak.

Practical Flow

flowchart LR
  User["User action"] --> Renderer["Renderer\nHTML / React"]
  Renderer --> Preload["Preload\ncontextBridge"]
  Preload --> IPC["IPC channel\nvalidated payload"]
  IPC --> Main["Main process\nOS and file access"]
  Main --> Result["Typed result"]
  Result --> Renderer
  Main --> Update["Packaging / auto update"]

The renderer never touches the file system directly. It calls a named preload function. The preload validates the shape of the request and forwards it to a named IPC channel. The main process performs OS work and returns a structured result.

Copy-Paste Starter

The following starter is intentionally plain Electron + TypeScript. Add React, Vite, or another UI stack after this boundary is understood.

mkdir electron-secure-notes
cd electron-secure-notes
npm init -y
npm install --save-dev electron typescript @types/node
npm pkg set main="dist/main.js"
npm pkg set scripts.dev="tsc -p tsconfig.json && electron ."
mkdir src
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "moduleResolution": "Node",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist",
    "rootDir": "src",
    "types": ["node", "electron"]
  },
  "include": ["src/**/*.ts"]
}
// src/main.ts
import { app, BrowserWindow, dialog, ipcMain, session } from "electron";
import fs from "node:fs/promises";
import path from "node:path";

type OpenedTextFile = {
  filePath: string;
  content: string;
};

type SaveTextRequest = {
  filePath: string;
  content: string;
};

const allowedExtensions = new Set([".txt", ".md", ".json"]);
const allowedRoots = new Set<string>();

function createWindow() {
  const win = new BrowserWindow({
    width: 1100,
    height: 760,
    webPreferences: {
      preload: path.join(__dirname, "preload.js"),
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: true
    }
  });

  win.loadFile(path.join(__dirname, "index.html"));
}

function registerCsp() {
  session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
    callback({
      responseHeaders: {
        ...details.responseHeaders,
        "Content-Security-Policy": [
          "default-src 'self'; script-src 'self'; style-src 'self'"
        ]
      }
    });
  });
}

function isInsideRoot(root: string, target: string) {
  const relative = path.relative(root, target);
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}

async function assertOpenedPath(filePath: string) {
  const extension = path.extname(filePath).toLowerCase();
  if (!allowedExtensions.has(extension)) {
    throw new Error("Unsupported file type.");
  }
  return filePath;
}

async function assertAllowedFile(filePath: string) {
  const resolved = path.resolve(filePath);
  const extension = path.extname(resolved).toLowerCase();

  if (!allowedExtensions.has(extension)) {
    throw new Error("Only txt, md, and json files are allowed.");
  }

  const insideAllowedRoot = Array.from(allowedRoots).some((root) =>
    isInsideRoot(root, resolved)
  );

  if (!insideAllowedRoot) {
    throw new Error("Open the file with the dialog before saving it.");
  }

  return resolved;
}

ipcMain.handle("dialog:openTextFile", async (): Promise<OpenedTextFile | null> => {
  const result = await dialog.showOpenDialog({
    title: "Open text file",
    properties: ["openFile"],
    filters: [{ name: "Text", extensions: ["txt", "md", "json"] }]
  });

  if (result.canceled || result.filePaths.length === 0) {
    return null;
  }

  const filePath = path.resolve(result.filePaths[0]);
  const safePath = await assertOpenedPath(filePath);
  allowedRoots.add(path.dirname(safePath));

  return {
    filePath: safePath,
    content: await fs.readFile(safePath, "utf8")
  };
});

ipcMain.handle("file:saveText", async (_event, payload: SaveTextRequest) => {
  if (
    !payload ||
    typeof payload.filePath !== "string" ||
    typeof payload.content !== "string" ||
    payload.content.length > 1_000_000
  ) {
    throw new Error("Invalid save request.");
  }

  const filePath = await assertAllowedFile(payload.filePath);
  await fs.writeFile(filePath, payload.content, "utf8");
  return { ok: true };
});

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

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

app.on("activate", () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow();
  }
});
// src/preload.ts
import { contextBridge, ipcRenderer } from "electron";

type OpenedTextFile = {
  filePath: string;
  content: string;
};

type SaveTextRequest = {
  filePath: string;
  content: string;
};

const desktopApi = {
  openTextFile: () =>
    ipcRenderer.invoke("dialog:openTextFile") as Promise<OpenedTextFile | null>,

  saveTextFile: (payload: SaveTextRequest) => {
    if (
      !payload ||
      typeof payload.filePath !== "string" ||
      typeof payload.content !== "string"
    ) {
      throw new Error("Invalid save payload.");
    }

    return ipcRenderer.invoke("file:saveText", payload) as Promise<{ ok: boolean }>;
  }
};

contextBridge.exposeInMainWorld("desktop", desktopApi);
// src/global.d.ts
export {};

declare global {
  interface Window {
    desktop: {
      openTextFile: () => Promise<{ filePath: string; content: string } | null>;
      saveTextFile: (payload: {
        filePath: string;
        content: string;
      }) => Promise<{ ok: boolean }>;
    };
  }
}
<!-- src/index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'; style-src 'self'"
    />
    <title>Secure Notes</title>
  </head>
  <body>
    <main>
      <h1>Secure Notes</h1>
      <button id="open">Open</button>
      <button id="save">Save</button>
      <p id="status">No file opened.</p>
      <textarea id="editor" rows="24" cols="90"></textarea>
    </main>
    <script src="./renderer.js"></script>
  </body>
</html>
// src/renderer.ts
const openButton = document.querySelector<HTMLButtonElement>("#open");
const saveButton = document.querySelector<HTMLButtonElement>("#save");
const editor = document.querySelector<HTMLTextAreaElement>("#editor");
const status = document.querySelector<HTMLParagraphElement>("#status");

let currentFilePath: string | null = null;

if (!openButton || !saveButton || !editor || !status) {
  throw new Error("Required UI elements are missing.");
}

openButton.addEventListener("click", async () => {
  const file = await window.desktop.openTextFile();
  if (!file) {
    status.textContent = "Open canceled.";
    return;
  }

  currentFilePath = file.filePath;
  editor.value = file.content;
  status.textContent = `Opened: ${file.filePath}`;
});

saveButton.addEventListener("click", async () => {
  if (!currentFilePath) {
    status.textContent = "Open a file first.";
    return;
  }

  await window.desktop.saveTextFile({
    filePath: currentFilePath,
    content: editor.value
  });

  status.textContent = `Saved: ${currentFilePath}`;
});

Run npm run dev. When asking Claude Code to build this, also ask it to run the same command and report whether TypeScript compiled and whether Electron launched without new CSP warnings.

Prompt Template for Claude Code

Add Markdown open/save support to the existing Electron + TypeScript app.

Constraints:
- do not expose Node.js APIs to the renderer
- keep contextIsolation: true, nodeIntegration: false, and sandbox: true
- expose only window.desktop.openTextFile and window.desktop.saveTextFile from preload
- do not expose raw ipcRenderer, ipcMain, or generic channel invocation
- allow only md/txt/json files
- do not touch the existing auto update code

Definition of done:
- npm run typecheck passes
- explain the main/preload/renderer changes briefly
- list three security review points

This prompt is small enough to review. A broad prompt such as “build an Electron note app and add auto updates” usually changes too many boundaries at once.

Three Real Use Cases

The first use case is an internal Markdown notes app. Electron is a good fit when the app must edit local files without uploading them to a server. The key rule is to read and write only files selected through the native dialog. If you store a recent-files list, keep it under app.getPath("userData"), validate each path on startup, and avoid caching confidential document contents.

The second use case is a support log viewer. Logs can be huge and may contain email addresses, bearer tokens, or customer identifiers. Ask Claude Code to page the file in the main process and send only the visible slice to the renderer. Also ask it to mask secrets before copy/export actions.

The third use case is a desktop AI prompt workbench. It may combine local templates, clipboard data, and external API calls. Keep API keys in the main process. The renderer should receive connection status and generated output, not raw tokens, headers, or secret-bearing request objects.

Failure Cases to Catch

The most common failure is an IPC handler that reads whatever path the renderer sends. Even if the UI has only one open-file button, treat the renderer as untrusted. Validate extension, size, and allowed roots in the main process.

The second failure is exposing ipcRenderer.invoke as a generic helper such as window.api.invoke(channel, payload). It looks elegant, but it turns preload into a thin tunnel. Prefer one method per capability.

The third failure is a dev-only path. Reading from src/index.html, writing into an asar archive, or assuming an asset path exists after packaging will break once the app is made. Use app.getPath("userData") for writable data and treat bundled files as read-only.

The fourth failure is postponing packaging and updates until release week. Code signing, installer behavior, update feeds, and platform-specific rules should be tested early. The CI/CD setup guide is useful when you move this into a release pipeline.

Packaging and Auto Update

Electron packaging is not just a final command. You need installer formats, signing, update feeds, secrets, and a rollback plan.

npm install --save-dev @electron-forge/cli @electron-forge/maker-squirrel @electron-forge/maker-zip @electron-forge/maker-deb @electron-forge/maker-rpm
npx electron-forge import
// forge.config.ts
import type { ForgeConfig } from "@electron-forge/shared-types";

const config: ForgeConfig = {
  packagerConfig: {
    asar: true,
    icon: "assets/icon"
  },
  rebuildConfig: {},
  makers: [
    {
      name: "@electron-forge/maker-squirrel",
      config: {
        name: "secure_notes"
      }
    },
    {
      name: "@electron-forge/maker-zip",
      platforms: ["darwin"]
    },
    {
      name: "@electron-forge/maker-deb",
      config: {}
    },
    {
      name: "@electron-forge/maker-rpm",
      config: {}
    }
  ]
};

export default config;

Electron’s built-in autoUpdater is primarily for macOS and Windows. Linux distribution channels usually handle updates differently. On macOS, signing is required for automatic updates. On Windows, behavior depends on whether you ship MSIX or Squirrel.Windows.

// src/update.ts
import { app, autoUpdater, BrowserWindow } from "electron";

function sendStatus(win: BrowserWindow, status: string) {
  win.webContents.send("update:status", status);
}

export function configureAutoUpdate(win: BrowserWindow) {
  if (!app.isPackaged) {
    sendStatus(win, "updates disabled in development");
    return;
  }

  if (process.platform === "linux") {
    sendStatus(win, "use the distribution package manager on Linux");
    return;
  }

  const owner = "YOUR_GITHUB_OWNER";
  const repo = "YOUR_GITHUB_REPO";
  const feedUrl = `https://update.electronjs.org/${owner}/${repo}/${process.platform}-${process.arch}/${app.getVersion()}`;

  autoUpdater.setFeedURL({ url: feedUrl });

  autoUpdater.on("checking-for-update", () => sendStatus(win, "checking"));
  autoUpdater.on("update-available", () => sendStatus(win, "available"));
  autoUpdater.on("update-not-available", () => sendStatus(win, "not available"));
  autoUpdater.on("update-downloaded", () => sendStatus(win, "downloaded"));
  autoUpdater.on("error", (error) => sendStatus(win, `error: ${error.message}`));

  setTimeout(() => {
    try {
      autoUpdater.checkForUpdates();
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      sendStatus(win, `error: ${message}`);
    }
  }, 10_000);
}

Ask Claude Code to document what remains unconfigured: signing identities, update feed ownership, GitHub release permissions, and production secrets. Do not let placeholder tokens become real code.

Review Checklist

  • BrowserWindow keeps contextIsolation, nodeIntegration, and sandbox in the expected state
  • preload exposes capability-specific methods, not raw IPC
  • IPC channels have names tied to business actions
  • main process validates payloads again
  • file path, extension, size, and root rules are enforced
  • CSP is not loosened just to silence warnings
  • writable data goes under userData
  • packaging is tested before release week
  • update code is disabled in development and documented per platform

Closing

Claude Code can make Electron development much faster, but Electron quality depends on boundaries: renderer for UI, preload for a narrow bridge, main process for OS access, and packaging tested before launch. That is the difference between a demo and a maintainable desktop product.

ClaudeCodeLab helps teams design Electron and Tauri apps, review IPC and update flows, and train developers to use Claude Code safely in existing codebases. Share the current repo shape and the release target when you need implementation support.

When you try this article in your own project, verify these points before adding more UI: npm run dev compiles, no renderer code imports Electron or Node, arbitrary paths cannot be read, packaged file paths still work, and auto update is treated as production only after signing and feed setup are real.

#Claude Code #Electron #desktop apps #TypeScript #cross-platform
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.