Use Cases (Actualizado: 1/6/2026)

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.

Desarrollo de apps de escritorio con Electron y Claude Code: IPC, preload, empaquetado y actualizaciones

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

CapaResponsabilidadBuena tarea para Claude CodeRevisión humana
main processBrowserWindow, APIs del sistema, archivos, menús, updatesAñadir un handler IPC por vezValidación, permisos, errores, reglas de ruta
preloadPuente mínimo hacia el rendererExponer funciones con contextBridgeNo exponer ipcRenderer sin filtrar
rendererUI, formularios, estado, vistaPantalla e interacciónNo 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.

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

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.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.