Electron para crear apps de escritorio
Hay una idea que aparece en casi todas las decisiones técnicas de una aplicación de escritorio, y que conviene enunciar antes de hablar de Electron: una app de escritorio es un navegador con permiso para tocar el disco. Todo lo que la web te da, y además el sistema de ficheros, los procesos, la red, el portapapeles y el registro del sistema. Esa combinación es exactamente lo que Electron decide empaquetarte.
Electron es un runtime que combina un Chromium actualizado con Node.js dentro del mismo binario. La consecuencia práctica es que escribes la interfaz con lo que ya sabes, HTML, CSS y JavaScript, y por debajo tienes un motor de navegador completo y un entorno de servidor. No es un puente entre dos mundos ni una abstracción: es literalmente un navegador con Node incrustado.
La otra mitad de la oferta es que puedes empaquetar el resultado como un programa nativo de Windows, macOS o Linux, con su icono, su instalador y su entrada en el menú de aplicaciones. Eso es lo que convierte un proyecto web en un producto distribuible.
Este texto explica cómo se usa, con el proceso principal, la carga de la interfaz, la comunicación entre procesos, el empaquetado y las decisiones donde conviene pensarse dos veces. Los ejemplos salen de un proyecto real: el mismo código que se sirve como web se empaqueta como aplicación de escritorio.
La arquitectura de dos procesos
Lo primero que hay que entender es que Electron no tiene un único proceso. Tiene al menos dos, con responsabilidades distintas, y casi todos los errores clásicos de Electron vienen de no respetar esa separación.
El proceso principal (main process) es el que decide qué ventanas existen. Es un proceso de Node.js completo, con acceso al sistema de ficheros, a la red, a los procesos hijos y a la API de ventanas. Hay exactamente uno por aplicación.
Los procesos de renderizado (renderer processes) son los procesos del navegador que pintan cada ventana. Son Chromium, y por defecto no tienen acceso a Node: el require no existe ahí, y no existe a propósito.
El proceso principal crea las ventanas con BrowserWindow. Cada ventana es un proceso de renderizado independiente, con su propio DOM y su propio ciclo de vida:
const { app, BrowserWindow } = require("electron");
function createWindow() {
const win = new BrowserWindow({
width: 1000,
height: 700,
});
}
app.whenReady().then(createWindow);
Ese es el programa mínimo. Dos cosas de esa línea corta merecen atención. La primera es require y no import: el proceso principal es CommonJS, y esa es una de las razones por las que el archivo de entrada suele llamarse main.cjs. La segunda es app.whenReady(). No se puede crear una ventana antes de que Electron esté listo, porque la API de ventanas no existe todavía, así que la creación espera a esa promesa.
El ciclo de vida de la aplicación
Electron emite eventos durante toda su vida, y los tres que importan son estos:
app.whenReady().then(() => {
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
activate es el gesto de reabrir en macOS: si no hay ninguna ventana abierta y el usuario hace clic en el icono del dock, hay que crear una ventana nueva o la aplicación parece estar muerta.
window-all-closed es la regla de cada plataforma. En macOS las aplicaciones siguen vivas sin ventanas, porque el menú de la aplicación sigue siendo usable. En Windows y en Linux, si no queda ninguna ventana, la aplicación se cierra. La condición process.platform !== "darwin" codifica exactamente esa diferencia.
Omitir este evento es un error habitual en proyectos que se desarrollan en Linux y luego fallan en macOS: sin él, la aplicación no se cierra nunca.
Cargar la web en desarrollo y el build en producción
Aquí está la decisión de diseño que más se posterga y la que más problemas causa el día del empaquetado. La interfaz que ves mientras desarrollas viene de un servidor de desarrollo, y la que va dentro del instalador es un fichero en disco. Son dos URLs distintas, y el código tiene que saber en cuál está.
La forma de distinguirlo es app.isPackaged, que vale false mientras la aplicación se ejecuta desde el código fuente y true cuando va instalada:
const isDev = !app.isPackaged;
if (isDev) {
win.loadURL("http://localhost:5173");
} else {
const indexPath = path.join(app.getAppPath(), "dist", "index.html");
win.loadFile(indexPath);
}
loadURL y loadFile no son intercambiables aunque parezcan lo mismo. loadURL navega a un protocolo http, y en desarrollo eso significa que el servidor de desarrollo de Vite está listening, con su hot reload, sus errores detallados y su fuente mapeada. loadFile abre el HTML desde el disco con el protocolo file, que es lo único que existe dentro de un ejecutable ya instalado.
app.getAppPath() devuelve la raíz de la aplicación instalada, que es el ancla correcta para localizar ficheros empaquetados. El path.join es importante: concatenar rutas a mano con / funciona en Windows y falla en Linux en cuanto hay un subdirectorio con permisos o caracteres raros. La API de path existe por algo.
El detalle que rompe el build
Hay un problema que aparece tarde y que se confunde con un error de Electron cuando en realidad es de Vite. Si tu aplicación usa rutas absolutas, al cargar con file el navegador las resuelve contra la raíz del sistema de archivos en lugar de contra la carpeta de tu build. Los assets se piden desde ahí, fallan, y la ventana aparece en blanco.
La solución es una bandera en el comando de build, y por eso los scripts suelen verse así:
{
"scripts": {
"build": "vite build --base=/",
"dist": "vite build --base=./ && electron-builder"
}
}
El ./ le dice a Vite que genere rutas relativas, que son las únicas que funcionan bajo el protocolo file. Nótese que los dos scripts son distintos a propósito: para publicar en un servidor web quieres las rutas absolutas, y para el empaquetado de escritorio quieres las relativas. Es la misma aplicación sirviéndose en dos entornos con configuraciones opuestas, y por eso conviene tener los dos comandos separados en lugar de uno con banderas condicionales.
Un solo código, dos destinos
Este proyecto es un ejemplo de por qué Electron merece una sección propia. El mismo repositorio se despliega como una web estática, servida por Vercel, y se empaqueta como aplicación de escritorio de Windows. No hay bifurcación del código de la interfaz, solo dos comandos distintos y dos destinos distintos.
La razón por la que funciona así es que Electron carga cualquier cosa que sepa responder a un protocolo de navegador. Si tu aplicación ya es una SPA que funciona en un navegador, ya tienes el 90 por ciento de una app de escritorio. Lo que falta es una ventana y un empaquetado.
Esa misma propiedad tiene una ventaja que conviene ver: la aplicación de escritorio puede seguir funcionando sin conexión si el build es puramente local, y el mismo código sirve para una web pública y para un programa instalado.
Comunicación entre procesos
Como los procesos de renderizado no tienen acceso a Node, cualquier cosa que necesite el sistema de ficheros tiene que pasar por el proceso principal. El camino se llama IPC, y hay dos capas que conviene distinguir.
La primera es ipcRenderer e ipcMain, que son las piezas internas. La segunda es el puente, que es lo que se usa de verdad en el código de la aplicación.
Las dos formas de cruzar el proceso se corresponden con dos tipos de necesidad. La de ida y vuelta, donde el renderizado pide algo y espera la respuesta, es un invoke del renderizado y un handle en el principal:
// Proceso principal
ipcMain.handle("leer-archivo", async (event, ruta) => {
const contenido = await fs.promises.readFile(ruta, "utf8");
return contenido;
});
// Proceso de renderizado
const contenido = await window.api.leerArchivo(ruta);
Y la de una sola dirección, donde el principal avisa y el renderizado escucha, es un on en los dos lados:
// Proceso principal
win.webContents.send("progreso", { porcentaje: 42 });
El preload y el puente
El problema de lo anterior es que ipcRenderer no existe en el renderizado sin nodeIntegration, y activarlo es justo lo que no hay que hacer. La solución es el script de preload, un archivo que se ejecuta con acceso a Node pero antes de que la página cargue, y que expone una superficie mínima mediante contextBridge:
// preload.cjs
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("api", {
leerArchivo: (ruta) => ipcRenderer.invoke("leer-archivo", ruta),
alProgreso: (callback) => ipcRenderer.on("progreso", (event, datos) => callback(datos)),
});
Y se registra en la ventana:
const win = new BrowserWindow({
width: 1000,
height: 700,
webPreferences: {
preload: path.join(__dirname, "preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
},
});
contextBridge no expone el objeto de Electron, expone únicamente lo que tú declares. Ese es el punto: la página web de tu aplicación no puede alcanzar el módulo fs aunque esté mal escrita, porque no existe la referencia. La superficie de ataque es exactamente la lista de métodos que has publicado.
contextIsolation: true ejecuta el mundo del preload en un contexto separado del de la página, de modo que un Object.prototype envenenado desde la web no llega al preload. No debería desactivarse nunca.
nodeIntegration: false es el valor por defecto y el que debe quedarse. Ponerlo en true hace que require funcione dentro de la página, lo que parece cómodo en el desarrollo y luego permite que cualquier script inyectado lea tus ficheros y ejecute procesos.
Seguridad: lo que hay que activar siempre
Electron se puede usar de forma insegura por accidente, porque todo lo que haces es escribir HTML. Los ajustes que hay que revisar en cada ventana son estos.
contextIsolation en true, para que el mundo del preload no se mezcle con el de la página.
nodeIntegration en false, para que el renderizado no tenga Node. Si necesitas algo de Node, se expone por el preload.
sandbox en true, que pone el renderizado en un entorno restringido del sistema. Es una capa adicional sobre las dos anteriores.
Y en el lado del proceso principal, dos reglas que no son ajustes sino detalles de implementación: no crear la ventana con webPreferences heredados sin revisar, y no cargar con nodeIntegration activo aunque sea "solo en desarrollo", porque la ventana de desarrollo acaba compilándose por accidente.
La regla de fondo es que el renderizado trata toda su entrada como no confiable, porque ese contenido puede venir de un servidor, de un fichero abierto o de una respuesta de una API. Si necesitas algo privilegiado, se cruza por un canal explícito y se valida en el proceso principal.
Empaquetar con electron-builder
Escribir el programa es la mitad del trabajo. La otra mitad es convertirlo en un instalador, y aquí entra electron-builder, que se apoya en la sección build del package.json:
{
"build": {
"appId": "com.arturo.cv",
"productName": "CV Arturo",
"win": {
"target": "nsis"
}
}
}
appId es un identificador único y estable, con formato de dominio invertido. No es el nombre que ve el usuario sino la identidad del producto frente al sistema, y conviene fijarlo desde el principio: cambiarlo después genera conflictos de firma y de actualización automática.
productName es el nombre visible, el que aparece en el menú de programas y en el título de la ventana.
win.target es el formato de instalador. nsis genera el instalador clásico de Windows, el que el usuario ve con "Siguiente, Siguiente, Instalar". En macOS los objetivos habituales son dmg y zip, y en Linux AppImage, deb o snap.
El comando de empaquetado encadena el build de la interfaz con el de la aplicación:
{
"scripts": {
"dist": "vite build --base=./ && electron-builder"
}
}
El orden importa y el && lo hace explícito: primero se genera el HTML y los assets, después se empaqueta todo eso. Si se invirtieran, el instalador contendría el build de la iteración anterior, que es un fallo silencioso porque la aplicación arranca igual y simplemente muestra contenido viejo.
Archivos que se empaquetan y archivos que no
Por defecto electron-builder es generoso e incluye más de lo necesario. Conviene declararlo:
{
"build": {
"files": ["dist/**/*", "main.cjs", "preload.cjs"],
"directories": {
"output": "release"
}
}
}
Declarar la lista de ficheros tiene dos efectos. El primero es el tamaño: sin files, el directorio de desarrollo puede acabar dentro del instalador, con el código fuente, los ficheros de prueba y las capturas. El segundo es la seguridad: si se empaqueta un .env o unas credenciales de desarrollo, viajan dentro del ejecutador.
El directorio de salida merece su propio nombre porque los artefactos generados pueden pesar cientos de megabytes y ensuciar el repositorio. Conviene añadirlo al .gitignore junto con dist.
Actualizaciones automáticas
Publicar una versión nueva es solo el primer problema: el usuario tiene que recibirla. electron-builder trae electron-updater, que se ocupa de comprobar si hay una versión nueva y de descargarla.
La lógica se apoya en un archivo de metadatos que el servidor publica y el cliente consulta. La comprobación se dispara al arrancar y de forma periódica, y solo tiene sentido en producción:
const { autoUpdater } = require("electron-updater");
app.whenReady().then(() => {
createWindow();
if (app.isPackaged) {
autoUpdater.checkForUpdatesAndNotify();
}
});
La condición app.isPackaged no es opcional. En desarrollo autoUpdater no tiene con qué trabajar, porque el proceso está corriendo desde una carpeta de código y no hay metadatos de versión publicados: la comprobación falla y ensucia la consola en cada arranque.
La alternativa al instalador automático es publicar un instalador nuevo y dejar que el sistema lo sugiera. Para aplicaciones internas o corporativas suele ser suficiente y evita todo el aparato de firma y metadatos.
La firma de código, que es el coste real
Un instalador sin firmar en Windows hace que aparezca el aviso de "proveedor desconocido" que todo el usuario ha visto alguna vez. En macOS es peor: sin firmar y sin notarizar, Gatekeeper directamente no deja abrir la aplicación.
Firmar requiere un certificado. En Windows, un certificado de firma de código; en macOS, la identidad de Developer de Apple, más la notarización, que es un paso adicional que implica subir el binario a Apple y esperar su respuesta. El proceso es lento y es la razón por la que muchas apps se publican sin firmar durante las primeras versiones.
Hay un atajo en macOS, identity: null en la configuración, que desactiva el requisito de firma para pruebas locales. Sirve para desarrollo y no debe usarse en una versión que se distribuya, porque el resultado se bloquea en cuanto pasa por la validación de Gatekeeper.
Decidir si Electron es la herramienta correcta
Con lo anterior se ve la otra cara, y conviene decirla en voz alta antes de elegir el framework.
Electron empaqueta un Chromium completo con la aplicación. Eso son del orden de cien megabytes de instalador en una versión base, y un proceso de renderizado con su memoria y su coste de arranque por ventana. Es un precio alto, pero es un precio conocido y está pagándose en cada binario que distribuyes.
La alternativa que ha ganado terreno en los últimos años es Tauri, que usa el motor de renderizado del sistema en lugar de uno propio. El resultado son instaladores de unos pocos megabytes y un consumo de memoria mucho menor. A cambio, se trabaja con Rust en el lado del proceso principal en lugar de con Node, lo que es un cambio real de lenguaje y no solo de configuración.
Neutralinojs es una tercera vía, aún más ligera, que sustituye Chromium por el motor del sistema y mantiene JavaScript en el proceso principal, aunque con un conjunto de APIs más reducido.
La decisión práctica suele ser esta: si la aplicación ya es una SPA y el equipo escribe JavaScript, Electron sigue siendo la opción con menos fricción porque no cambia nada de lo que ya sabes. Si el tamaño del instalador o el consumo de memoria son requisitos duros, o hay un equipo que pueda trabajar con Rust, Tauri es mejor opción. Y si la aplicación es en su mayoría una interfaz sin necesidad real del sistema, la respuesta correcta es no hacer una app de escritorio.
Un checklist antes de publicar
Siete preguntas que conviene poder responder antes de dar por cerrada una app de escritorio.
¿El proceso principal usa contextIsolation: true y nodeIntegration: false en todas las ventanas?
¿Todo acceso al sistema pasa por el preload y está declarado en contextBridge, sin exponer objetos de Electron?
¿El build de producción genera rutas relativas, para que funcione bajo el protocolo file?
¿La sección build declara qué ficheros se empaquetan, para no subir .env ni código fuente por accidente?
¿El appId es estable y con formato de dominio invertido?
¿El instalador está firmado, o hay una decisión consciente de publicar sin firmar?
¿La actualización automática solo se intenta con app.isPackaged, para no romper el arranque en desarrollo?
La última es la que más se olvida y la más fácil de arreglar, porque se manifiesta como un error en consola que nadie sabe interpretar. Las otras seis se detectan antes, que es exactamente lo que se busca al hacerlas una lista.
© Arturo Juárez Monroy