用 Claude Code 开发 Electron 桌面应用:IPC、preload、打包与自动更新
用 Claude Code 实作 Electron 桌面应用:contextIsolation、preload、IPC、文件访问、打包和自动更新全流程。
Electron 的吸引力在于:同一套 Web UI 可以交付给 Windows、macOS 和 Linux 用户。但 Electron 应用不是普通网页。它可以打开本地文件、调用系统对话框、管理菜单、处理安装包和自动更新,因此安全边界比页面样式更重要。
Claude Code 适合承担小粒度实现任务,例如新增一个 IPC channel、补一个 preload API、调整一个打包配置、给文件访问加校验。不要一次性让它“做完整桌面应用加自动更新”,那样 diff 太大,人工很难审查权限和风险。
当前 Electron 实务的基线是:保持 contextIsolation,关闭 renderer 的 nodeIntegration,只通过 preload 暴露最小 API,并在 main process 再次校验输入。建议同时查阅官方的 Context Isolation、IPC 教程、发布说明 和 autoUpdater API。
如果你在比较 Tauri 和 Electron,可以看 Tauri 开发指南。preload 与 renderer 的类型边界,则可以结合 TypeScript 实践技巧。
先划清三层边界
| 层 | 责任 | 适合交给 Claude Code 的任务 | 人工审查重点 |
|---|---|---|---|
| main process | BrowserWindow、OS API、文件、菜单、更新 | 一次新增一个 IPC handler | 输入校验、权限、异常、路径规则 |
| preload | renderer 与 Electron 之间的最小桥接 | 用 contextBridge 暴露类型化函数 | 是否泄露原始 ipcRenderer |
| renderer | UI、表单、状态、展示 | 做页面和交互 | 是否直接接触 Node 或 Electron |
这张表是 Electron 项目是否可维护的分水岭。Claude Code 可以写得很快,但人要确认边界没有被打穿。renderer 不应该知道文件系统细节,也不应该拿到任意 channel 调用能力。
flowchart LR
User["用户操作"] --> Renderer["Renderer\nHTML / React"]
Renderer --> Preload["Preload\ncontextBridge"]
Preload --> IPC["IPC channel\n校验后的 payload"]
IPC --> Main["Main process\nOS 与文件访问"]
Main --> Result["类型化结果"]
Result --> Renderer
Main --> Update["打包 / 自动更新"]
可复制的最小项目
下面的示例不用 React,先展示 Electron + TypeScript 的安全骨架。确认边界后,再加入 Vite、React 或其他 UI 框架。
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"]
}
main process 只在这里接触文件系统,并明确开启安全相关设置。
// 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();
}
});
preload 暴露的是业务能力,而不是原始 IPC 工具。
// src/preload.ts
import { contextBridge, ipcRenderer } from "electron";
type OpenedTextFile = {
filePath: string;
content: string;
};
type SaveTextRequest = {
filePath: string;
content: string;
};
contextBridge.exposeInMainWorld("desktop", {
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 }>;
}
});
renderer 只调用 window.desktop。
// 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}`;
});
Claude Code 完成后,让它运行 npm run dev 或项目里的 typecheck,并说明 main、preload、renderer 分别改了什么。
给 Claude Code 的任务模板
请给现有 Electron + TypeScript 应用添加 Markdown 打开和保存功能。
限制:
- renderer 不暴露 Node.js API
- 保持 contextIsolation: true、nodeIntegration: false、sandbox: true
- preload 只暴露 window.desktop.openTextFile 和 window.desktop.saveTextFile
- 不暴露原始 ipcRenderer,也不要做通用 invoke(channel, payload)
- 文件只允许 md/txt/json
- 不修改现有自动更新逻辑
完成条件:
- npm run typecheck 通过
- 简短说明 main/preload/renderer 的改动
- 列出 3 个安全审查点
三个实用场景
第一个场景是公司内部 Markdown 笔记工具。文件不需要上传服务器,用户直接编辑本地文档。这里要避免把文档内容缓存到设置文件里,最近打开列表也要在启动时重新检查路径和扩展名。
第二个场景是客服日志查看器。日志可能很大,也可能含有邮箱、token 或客户编号。应让 main process 分页读取,renderer 只拿当前页数据。复制和导出时可以先做脱敏。
第三个场景是桌面版 AI prompt workbench。它会组合本地模板、剪贴板、外部 API 和项目资料。API key 应只在 main process 读取,renderer 只能接收连接状态和生成结果,不能接收 header 或 token。
常见失败与规避
最危险的失败,是 main process 读取 renderer 传来的任意路径。即使 UI 只有一个文件按钮,也要假设 renderer 不可信。扩展名、文件大小、允许目录都要在 main process 校验。
第二个失败,是在 preload 中暴露 window.api.invoke(channel, payload)。这看似通用,实际会绕过你对每个功能的权限审查。更好的方式是一个能力对应一个方法。
第三个失败,是开发环境路径在打包后失效。不要在运行时写入 asar 里的文件,也不要假设 src 路径在安装包中存在。可写数据应放在 app.getPath("userData")。
第四个失败,是临近发布才处理签名、安装包和自动更新。签名证书、GitHub Release 权限、更新 feed、Windows 首次运行行为,都应该在 CI 里提前验证。可以参考 CI/CD 设置指南。
打包与自动更新
Electron Forge 的最小配置可以这样开始:
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;
内置 autoUpdater 主要面向 macOS 和 Windows。Linux 通常依赖发行版包管理。macOS 自动更新要求签名,Windows 则要根据 MSIX 或 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);
}
让 Claude Code 写自动更新时,也要让它列出仍未配置的生产条件:签名身份、release 权限、feed 所有者、secret 注入方式、回滚策略。
结论与确认点
Claude Code 能大幅加快 Electron 开发,但 Electron 的质量取决于边界设计。renderer 只做 UI,preload 只做窄桥,main process 只执行经过校验的 OS 操作,打包和更新提前验证,这才是可维护桌面产品的基础。
ClaudeCodeLab 可以协助 Electron/Tauri 架构设计、IPC 安全审查、自动更新流程整理,以及团队 Claude Code 培训。如果你已经有 PoC 或旧 Electron 项目,可以先共享当前目录结构、发布目标和最担心的权限点。
实际试用本文内容时,请确认:npm run dev 能通过,renderer 没有导入 Electron 或 Node,任意路径无法读取,打包后文件路径仍有效,自动更新在签名和 feed 准备好之前不会被当作生产功能。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。