Desarrollo de apps de escritorio con Electron y Claude Code: IPC, preload, empaquetado y actualizaciones
Guía práctica de Electron con Claude Code: contextIsolation, preload, IPC, archivos, empaquetado y auto update.
Electron permite convertir una interfaz web en una aplicación de escritorio para Windows, macOS y Linux. Esa ventaja tiene un coste: una app Electron puede tocar archivos locales, abrir diálogos del sistema, gestionar menús y distribuir actualizaciones. Por eso la frontera de seguridad importa tanto como la interfaz.
Claude Code ayuda mucho cuando se usa como worker de implementación con tareas pequeñas: un canal IPC, una API de preload, una regla de acceso a archivos, una configuración de empaquetado. No conviene pedirle una app completa con instalador y auto update en una sola pasada, porque el cambio será demasiado grande para revisarlo bien.
La base actual de Electron es mantener contextIsolation, desactivar nodeIntegration en el renderer, exponer solo APIs pequeñas desde preload y validar otra vez en el main process. Para comprobar detalles, usa la documentación oficial de Context Isolation, IPC, distribución y autoUpdater.
Si estás comparando con Tauri, consulta la guía de desarrollo con Tauri. Para tipar bien la frontera entre preload y renderer, complementa con consejos de TypeScript.
La frontera de arquitectura
| Capa | Responsabilidad | Buena tarea para Claude Code | Revisión humana |
|---|---|---|---|
| main process | BrowserWindow, APIs del sistema, archivos, menús, updates | Añadir un handler IPC por vez | Validación, permisos, errores, reglas de ruta |
| preload | Puente mínimo hacia el renderer | Exponer funciones con contextBridge | No exponer ipcRenderer sin filtrar |
| renderer | UI, formularios, estado, vista | Pantalla e interacción | No usar Node ni Electron directamente |
La regla práctica es sencilla: el renderer no toca el sistema de archivos. Llama a una función concreta de preload, preload envía un mensaje IPC concreto y main process ejecuta la operación después de validar.
flowchart LR
User["Acción del usuario"] --> Renderer["Renderer\nHTML / React"]
Renderer --> Preload["Preload\ncontextBridge"]
Preload --> IPC["Canal IPC\npayload validado"]
IPC --> Main["Main process\nSO y archivos"]
Main --> Result["Resultado tipado"]
Result --> Renderer
Main --> Update["Empaquetado / auto update"]
Starter mínimo que puedes copiar
Este ejemplo usa Electron + TypeScript sin React para que la frontera sea clara. Después puedes añadir Vite, React o tu framework preferido.
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"]
}
El main process concentra el acceso al sistema operativo y valida rutas, extensiones y tamaño del contenido.
// 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 no publica IPC sin filtro. Publica capacidades concretas.
// 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 }>;
}
});
El renderer solo consume 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}`;
});
Prompt útil para Claude Code
Añade soporte para abrir y guardar Markdown en la app Electron + TypeScript existente.
Restricciones:
- no exponer APIs de Node.js al renderer
- mantener contextIsolation: true, nodeIntegration: false y sandbox: true
- preload solo expone window.desktop.openTextFile y window.desktop.saveTextFile
- no exponer ipcRenderer ni un invoke(channel, payload) genérico
- permitir únicamente md/txt/json
- no tocar el código de auto update existente
Definición de terminado:
- npm run typecheck pasa
- explicar brevemente cambios en main/preload/renderer
- listar 3 puntos de revisión de seguridad
Tres casos de uso reales
El primer caso es una app interna de notas Markdown. Es útil cuando el equipo no quiere subir documentos a un servidor. Guarda solo la lista de recientes, valida rutas al iniciar y evita copiar el contenido confidencial en archivos de configuración.
El segundo caso es un visor de logs para soporte. Los logs pueden pesar mucho y contener correos, tokens o identificadores de clientes. Lee por páginas en main process, envía al renderer solo el fragmento visible y aplica enmascarado antes de copiar o exportar.
El tercer caso es un workbench de prompts de IA para escritorio. Combina plantillas locales, portapapeles y APIs externas. Las claves deben quedarse en main process; el renderer solo recibe estado de conexión y resultados.
Fallos concretos que debes evitar
El fallo más grave es leer cualquier ruta que mande el renderer. La UI puede parecer segura, pero el renderer no debe ser tratado como confiable. Valida extensión, tamaño y directorio permitido en main process.
Otro fallo común es exponer window.api.invoke(channel, payload). Parece práctico, pero convierte preload en un túnel genérico. Es mejor una función por capacidad.
También es frecuente que funcione en desarrollo y falle empaquetado: leer desde src, escribir dentro de asar o asumir rutas de assets. Los datos escribibles deben ir bajo app.getPath("userData").
Por último, no dejes firma, instalador y update feed para la semana del lanzamiento. La guía de CI/CD con Claude Code ayuda a llevar estas comprobaciones al pipeline.
Empaquetado y auto update
Con Electron Forge puedes empezar así:
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;
El autoUpdater integrado se usa sobre todo en macOS y Windows. En Linux suele tener más sentido el gestor de paquetes de la distribución. En macOS la firma es obligatoria para actualizaciones automáticas; en Windows cambia según MSIX o 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);
}
Pide a Claude Code que documente lo que falta para producción: identidad de firma, permisos de release, propietario del feed, secrets y estrategia de rollback.
Cierre y puntos de comprobación
Claude Code acelera Electron, pero la calidad depende de mantener fronteras claras: renderer para UI, preload como puente estrecho, main para operaciones del sistema y empaquetado probado antes del lanzamiento.
ClaudeCodeLab puede ayudar con arquitectura Electron/Tauri, revisión de IPC, diseño de auto update y formación interna para usar Claude Code con seguridad. Para una revisión práctica, comparte estructura actual, plataforma objetivo y el flujo de permisos que más te preocupa.
Al probar este artículo, confirma que npm run dev compila, que el renderer no importa Electron ni Node, que no se pueden leer rutas arbitrarias, que las rutas siguen funcionando tras empaquetar y que auto update no se trata como producción hasta tener firma y feed reales.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.