Claude CodeでElectronデスクトップアプリ開発:IPC・preload・自動更新まで実装する実践ガイド
Claude CodeでElectronアプリを作る実務手順。contextIsolation、preload、IPC、ファイル操作、配布と自動更新まで解説します。
Electronデスクトップアプリは、WebのUI資産をそのままWindows、macOS、Linux向けに届けられる一方で、通常のWebアプリより強い権限を持ちます。ファイルを読める、OSのダイアログを開ける、メニューや通知を扱える。便利ですが、設計を間違えると「ブラウザでは起きない事故」が起きます。
Claude Codeに任せる価値があるのは、Electronの定型コードを速く書かせることだけではありません。main process、preload、renderer、IPC、権限境界、配布設定を小さな単位に分け、差分をレビューしながら進められる点です。この記事では、Claude Codeにどう依頼し、どこを人間が確認すべきかまで含めて、実務で使えるElectron開発の型をまとめます。
公式ドキュメントでは、contextIsolationはElectron 12以降デフォルトで有効です。rendererからNode.jsやElectron APIを直接触らせず、preloadで必要なAPIだけを橋渡しするのが現在の基本線です。詳しくはElectronのContext Isolation、IPCチュートリアル、配布ガイド、autoUpdater APIも確認してください。
Tauriとの比較観点はClaude CodeでTauri開発、型設計の見直しはClaude CodeでTypeScript開発を加速する実践Tipsもあわせて読むと判断しやすくなります。
まず決める設計境界
Electronは「Web画面をデスクトップに表示するだけ」の技術ではありません。最低でも次の3層を分けて考えます。
| 層 | 役割 | Claude Codeに任せる粒度 | レビュー観点 |
|---|---|---|---|
| main process | BrowserWindow、メニュー、OS API、ファイル操作、更新処理 | IPC handlerを1機能ずつ追加 | 入力検証、権限、例外処理、パス制限 |
| preload | rendererに公開する最小API | contextBridgeの型付きAPIを作る | ipcRendererを丸ごと出していないか |
| renderer | React/Vue/素のDOMなどのUI | UI状態、ボタン、フォーム、表示 | OS権限を直接持っていないか |
Claude Codeへの依頼は「Electronアプリを全部作って」では広すぎます。たとえば「テキストファイルを開くIPCを1本追加し、preloadの公開API、rendererのボタン、main側の入力検証まで実装。既存の更新処理には触らない」のように、変更範囲を明示します。
特にファイルアクセスと自動更新は、作るよりレビューが重要です。動くことだけを確認して終わると、任意パス読み込み、権限過多、署名なし更新、パッケージ後だけ壊れる相対パス、といった落とし穴が残ります。
全体像:安全なElectronアプリの流れ
flowchart LR
User["User clicks UI"] --> 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"]
この図のポイントは、rendererがファイルシステムに直接触っていないことです。rendererはユーザー入力と表示に集中し、preloadは公開APIを絞り、main processだけがOS権限を持ちます。Claude Codeに実装させるときも、この境界を壊していないかをレビューします。
コピペで試せる最小スターター
まずは素のElectron + TypeScriptで、contextIsolation、preload、IPC、ファイルアクセスを確認できる最小構成を作ります。ReactやViteを入れる前に、この構造を理解しておくと、フレームワークを足しても迷いません。
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
tsconfig.jsonを作ります。
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node", "electron"]
},
"include": ["src/**/*.ts"]
}
次にmain processです。BrowserWindowではcontextIsolation: true、nodeIntegration: false、sandbox: trueを明示します。preloadのパスはビルド後のdist/preload.jsを指します。
// 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 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")
};
});
async function assertOpenedPath(filePath: string) {
const extension = path.extname(filePath).toLowerCase();
if (!allowedExtensions.has(extension)) {
throw new Error("Unsupported file type.");
}
return filePath;
}
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();
}
});
preloadではipcRendererをそのまま公開しません。公開するのは「テキストファイルを開く」「保存する」という用途別の関数だけです。これがcontextBridgeの実務上の使い方です。
// 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);
rendererでTypeScript補完を効かせるため、Window型を拡張します。
// 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 }>;
};
}
}
UIは最小で十分です。<script>はHTML内に直書きせず、CSPに合わせて外部ファイルにします。
<!-- src/index.html -->
<!doctype html>
<html lang="ja">
<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}`;
});
ここまで作ったら、npm run devで起動できます。Claude Codeにこの構成を作らせる場合は、最後に「npm run devでTypeScriptビルドが通ること、Electron起動時にDevToolsのCSP警告が増えていないことを確認して」と依頼します。
Claude Codeに渡すプロンプト例
実務では、機能を小さく切って依頼します。以下のように、変更範囲、禁止事項、検証コマンドをセットにするとレビューしやすくなります。
Electron + TypeScriptの既存アプリに、Markdownファイルを開いて保存する機能を追加してください。
制約:
- rendererにNode.js APIを公開しない
- contextIsolation: true、nodeIntegration: false、sandbox: trueを維持
- preloadではwindow.desktop.openTextFile/saveTextFileだけを公開
- ipcRendererやipcMainを丸ごと公開しない
- ファイル拡張子はmd/txt/jsonに限定
- 既存のauto update設定には触らない
完了条件:
- npm run typecheckが通る
- main/preload/rendererの変更理由を短く説明する
- セキュリティ上の確認点を3つ挙げる
この粒度なら、Claude Codeの差分を人間が追えます。逆に「Electronでメモアプリを作って。自動更新もよろしく」と依頼すると、UI、IPC、保存形式、配布設定が一度に変わり、レビュー不能になりがちです。
実例1:社内向けMarkdownメモアプリ
最初のユースケースは、社内ルールや議事録をローカルMarkdownとして編集するアプリです。ブラウザのWebアプリでも似たUIは作れますが、ローカルファイルを扱うならElectronのほうが自然です。
この場合の実装方針は、ユーザーがダイアログで選んだファイルだけを読み書き対象にします。最近開いたファイル一覧を保存したい場合も、パスをそのまま信頼せず、起動時に存在確認と拡張子チェックを行います。
Claude Codeには「最近開いたファイル一覧をapp.getPath("userData")配下のJSONに保存して。ただしファイル本文は保存しない」と頼むとよいです。本文キャッシュまで保存させると、機密メモが意図せずアプリ設定領域に残ることがあります。
実例2:サポートログビューア
2つ目は、ユーザーから送られてきたログファイルを整形して見るサポート用アプリです。ログはサイズが大きく、個人情報やトークンを含む可能性があります。
この用途では、rendererにログ全文を無制限に流し込まない設計が重要です。main processで最大サイズを確認し、必要なら先頭から数万行だけ読む、検索はストリーム処理にする、コピー時にメールアドレスやBearer tokenをマスクする、といった対策を入れます。
Claude Codeへの依頼は「100MB超のログを一括でrendererに渡さない。main側で行単位に読み、rendererにはページング済みデータだけ返す」と具体化します。パフォーマンス要件を明文化しないと、動くけれど固まるアプリになりやすいです。
実例3:デスクトップ版AIプロンプトワークベンチ
3つ目は、プロンプト、テンプレート、ローカル資料を組み合わせて作業するワークベンチです。クリップボード、ファイル、ローカルDB、外部APIを扱うため、Electronの便利さと危険さが同時に出ます。
このケースでは、APIキーをrendererに渡さないことが最優先です。外部API呼び出しはmain processで行い、rendererには結果だけ返します。ログにもリクエストヘッダーを出さず、設定画面ではキーを再表示しない設計にします。
Claude Codeには「APIキーはmain processでのみ読む。rendererには接続テスト結果のbooleanとエラーメッセージだけ返す」と指定します。権限設計の基本はClaude Codeセキュリティベストプラクティスにも通じます。
file accessで必ず見る落とし穴
ファイルアクセスは、Electron記事で最も事故りやすい部分です。特に次の失敗は実務でよく起きます。
1つ目は、rendererから任意パスを渡せばmainがそのまま読む実装です。UI上はファイル選択ボタンしかなくても、DevToolsやXSSで任意IPCを叩かれる前提で考えます。拡張子、サイズ、許可済みルートをmain側で検証します。
2つ目は、preloadでipcRenderer.sendやipcRenderer.invokeをそのまま公開する実装です。公式IPCチュートリアルでも、Electron APIを丸ごと公開せず、用途別の関数に絞ることが推奨されています。window.api.invoke(channel, payload)のような汎用口は、レビューしにくく危険です。
3つ目は、開発環境の相対パスがパッケージ後に壊れる問題です。src/index.htmlを読みに行く、assets/icon.pngの位置が変わる、asar内のファイルを書き換えようとする、といったミスです。実行時に書くデータはapp.getPath("userData")配下へ、同梱アセットは読み取り専用として扱います。
4つ目は、大きなファイルを一度にrendererへ渡すことです。IPCは便利ですが、巨大な文字列やバイナリを雑に渡すとUIが固まります。ログビューアやCSVビューアでは、ページング、ストリーム、Web Worker相当の分離を検討します。
auto updateとpackagingは開発終盤では遅い
Electronの配布は、最後にnpm run makeすれば終わりではありません。署名、インストーラー、更新フィード、ロールバック、初回起動時の挙動を早めに設計します。
Electron公式のautoUpdaterはmacOSとWindows向けの機能で、Linuxはディストリビューションのパッケージ管理に寄せるのが基本です。Windowsではパッケージ形式によってMSIXやSquirrel.Windowsの挙動が変わります。macOSでは署名が自動更新の前提になります。ここを記事や仕様書でぼかすと、リリース直前に詰まります。
Electron Forgeを使う場合の最小設定例です。実際の署名情報、GitHub token、Apple Developer ID、Windows証明書はCIのsecretで扱い、リポジトリに直書きしません。
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;
自動更新の状態をUIへ出すなら、mainからrendererへ通知します。ここでもeventオブジェクトをrendererに渡さず、文字列や構造化した結果だけを渡します。
// 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);
}
このコードは更新サーバーとリリース運用が整って初めて意味を持ちます。Claude Codeには「自動更新コードを追加」だけでなく、「開発時は無効、Linuxでは明示メッセージ、署名と更新フィードはREADMEに未設定として残す」のように運用条件も書かせます。
Claude Codeのレビュー観点
Claude Codeが出した差分は、次の順番で見ます。
BrowserWindowでcontextIsolation、nodeIntegration、sandboxが期待どおりか- preloadが用途別APIだけを公開しているか
- IPC channel名が機能別に分かれ、汎用
invokeになっていないか - main process側で入力検証をしているか
- ファイルパス、拡張子、サイズ、許可済みルートを確認しているか
- CSPを緩めすぎていないか
- devでは動くがpackage後に壊れるパス参照がないか
- 署名、更新フィード、secretをコードに直書きしていないか
このレビューは、単にセキュリティのためだけではありません。問い合わせや受託開発につなげたい記事では、「どこを人間が判断するのか」を明確にしたほうが信頼されます。Claude Codeに任せる範囲と、人間が責任を持つ範囲を分けることが、実装支援の価値そのものになります。
まとめ:Electronは境界設計が9割
Claude Codeを使うと、Electronのmain、preload、renderer、IPC、配布設定をかなり速く組み立てられます。ただし、速さだけを狙うと、権限境界が曖昧なアプリになります。重要なのは、contextIsolationを前提に、preloadでAPIを絞り、main processでファイルアクセスを検証し、packagingとauto updateを早めに試すことです。
ClaudeCodeLabでは、ElectronやTauriのデスクトップアプリ設計、Claude Codeを使った既存コードベース改善、セキュリティレビュー、社内研修の相談を受けています。PoCを短期間で形にしたい場合も、既存ElectronアプリのIPCや更新設計を見直したい場合も、まずは現在の構成と困っている箇所を共有してください。
この記事で紹介した内容を実際に試すときの確認ポイントは、npm run devが通ること、preload以外からElectron APIに触っていないこと、任意パスを読めないこと、package後にファイルパスが壊れないこと、自動更新は署名とフィードが用意できるまで本番扱いにしないことです。ここまで確認してからUIや機能を増やすと、後戻りの少ないElectron開発になります。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。