* notes / electron
De web a Electron
— Cómo empaqueté una aplicación web madura en Electron sin reescribirla: arquitectura de procesos, IPC seguro vía contextBridge, firma y notarización en macOS y el inevitable shock del tamaño del bundle.
El punto de partida: una app web en React con cinco años encima, y el encargo de convertirla en aplicación de escritorio para macOS y Windows. Evalué tres caminos contra los requisitos reales (acceso al sistema de archivos sin sandbox, bindings nativos con bibliotecas C++ y autoactualizaciones con canal beta):
| Opción | Lo bueno | Lo que la descartó |
|---|---|---|
| SwiftUI | App nativa de verdad en macOS | Reescribir todo, y duplicarlo para Windows |
| Tauri | Binarios pequeños, WebView del sistema | Los bindings C++ y el render distinto por SO complicaban justo lo que necesitaba |
| Electron | La app se comporta igual que en Chrome, Node completo, autoupdate maduro | Nada (salvo el peso del bundle) |
Electron era la respuesta menos romántica y más práctica. Este artículo recoge las decisiones, los atajos legítimos y los golpes que me llevé al envolver una web madura en un binario de escritorio.
Contexto: por qué Electron sigue ganando partidas
La crítica habitual contra Electron es bien conocida: empaqueta Chromium y Node, lo que produce binarios pesados, consume RAM con generosidad y arranca más lento que una aplicación nativa pura. Todo cierto. Pero también es cierto que ofrece tres cosas que sus competidores aún no replican del todo: paridad de comportamiento con tu app web (lo que ves en Chrome es lo que ves en la app), acceso pleno a Node sin renuncias ni capas WASM intermedias, y un ecosistema maduro de actualizaciones, firmas y publicación.
Tauri, su rival más mencionado, se apoya en el WebView del sistema operativo (WKWebView en macOS, WebView2 en Windows, webkitgtk en Linux), lo que reduce el tamaño del bundle a una fracción del de Electron. Pero esa misma decisión introduce un coste: tu aplicación se renderiza distinto según el SO, los bugs son distintos, y ciertas APIs avanzadas de la plataforma web pueden no estar disponibles. Para casos donde tu app es simple y quieres binarios pequeños, Tauri brilla; para casos donde necesitas determinismo total y Node sin restricciones, Electron sigue siendo difícil de batir.
La arquitectura de Electron divide tu aplicación en dos tipos de proceso. El proceso principal (main) es Node puro: gestiona ventanas, integra con APIs del sistema operativo, controla el ciclo de vida. Los procesos renderer son Chromium: uno por ventana, ejecutan tu HTML/JS/CSS sin acceso directo al sistema de ficheros. Entre ambos viajan mensajes vía IPC (ipcMain, ipcRenderer), pero, y esto es lo crítico, ese tráfico debe pasar por un preload script que expone, mediante contextBridge, una API tipada y restringida al renderer. La regla de oro es: nodeIntegration: false, contextIsolation: true, y nunca, jamás, exponer el objeto ipcRenderer completo al renderer.
Implementación: main, preload y renderer
La estructura mínima de una app Electron contemporánea consta de tres archivos. El primero, main.ts, arranca la app y crea la ventana:
import { app, BrowserWindow, ipcMain, shell } from "electron";
import path from "node:path";
import { readFile, writeFile } from "node:fs/promises";
import { autoUpdater } from "electron-updater";
const isDev = !app.isPackaged;
async function createWindow() {
const win = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 960,
minHeight: 600,
titleBarStyle: "hiddenInset",
backgroundColor: "#0f1117",
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: "deny" };
});
if (isDev) {
await win.loadURL("http://localhost:5173");
win.webContents.openDevTools({ mode: "detach" });
} else {
await win.loadFile(path.join(__dirname, "../renderer/index.html"));
}
}
ipcMain.handle("file:read", async (_event, filePath: string) => {
return readFile(filePath, "utf8");
});
ipcMain.handle("file:write", async (_event, filePath: string, contents: string) => {
await writeFile(filePath, contents, "utf8");
});
app.whenReady().then(async () => {
await createWindow();
if (!isDev) autoUpdater.checkForUpdatesAndNotify();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});Tres detalles merecen comentario. Primero, sandbox: true activa el sandbox de Chromium para el renderer, lo que limita drásticamente la superficie de ataque incluso si alguien logra inyectar código. Segundo, setWindowOpenHandler intercepta window.open y redirige las URLs externas al navegador del sistema en lugar de abrir nuevas ventanas Electron sin control. Tercero, autoUpdater está cableado contra electron-updater, que admite múltiples backends (GitHub Releases, S3, servidor estático). Para macOS, las actualizaciones exigen que el bundle esté firmado y notarizado, sin atajos.
El preload.ts actúa de puente. Aquí está la sutileza fundamental: no exponemos ipcRenderer, sino una API minúscula y tipada con las únicas operaciones que necesita el renderer:
import { contextBridge, ipcRenderer } from "electron";
const api = {
readFile: (path: string): Promise<string> =>
ipcRenderer.invoke("file:read", path),
writeFile: (path: string, contents: string): Promise<void> =>
ipcRenderer.invoke("file:write", path, contents),
onUpdateReady: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on("update-ready", handler);
return () => ipcRenderer.off("update-ready", handler);
},
};
contextBridge.exposeInMainWorld("api", api);
declare global {
interface Window {
api: typeof api;
}
}En el renderer, el código React ya consume window.api como si fuera una librería más, sin saber que detrás hay un viaje a Node:
import { useEffect, useState } from "react";
export function NotesEditor({ path }: { path: string }) {
const [text, setText] = useState("");
useEffect(() => {
window.api.readFile(path).then(setText).catch(console.error);
}, [path]);
async function save() {
await window.api.writeFile(path, text);
}
return (
<div>
<textarea value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={save}>Guardar</button>
</div>
);
}Para empaquetar, electron-builder es el estándar de facto. Una configuración mínima en package.json:
{
"build": {
"appId": "dev.akzu.notes",
"productName": "Akzu Notes",
"mac": {
"category": "public.app-category.productivity",
"hardenedRuntime": true,
"gatekeeperAssess": false,
"notarize": true,
"target": ["dmg", "zip"]
},
"win": {
"target": ["nsis"],
"signtoolOptions": {
"publisherName": "Akzu Software"
}
},
"publish": [{ "provider": "github", "owner": "akzu", "repo": "notes" }]
}
}Hallazgos: tamaño, firma y otros sustos
Voy a ser directo, porque a mí me habría gustado leer esto antes de empezar.
El bundle pesa lo que pesa. Mi aplicación web pesaba 2,4 MB en producción. El instalador final de Electron, con todo dentro, rondó los 220 MB para macOS arm64 (incluyendo DMG con icono e instalador) y 165 MB para Windows. La cifra incluye Chromium completo, Node, V8, ffmpeg y un puñado de bibliotecas nativas. No hay forma honesta de bajarlo de los ~150 MB. Si tu producto se distribuye a usuarios en mercados con ancho de banda limitado, esto importa; si se distribuye a profesionales que descargan una vez al año, no importa tanto. Tomar la decisión con los ojos abiertos es lo único exigible.
La notarización en macOS es un proceso que cuesta entender. Más allá de firmar el binario con tu certificado de Developer ID, Apple exige enviar el bundle a sus servidores para que un escáner automático lo analice y devuelva un "ticket" que se grapa al artefacto. El primer intento, prácticamente sin excepción, falla: alguna biblioteca nativa no tiene com.apple.security.cs.allow-unsigned-executable-memory declarado, o algún .dylib está firmado con un certificado distinto. Recomiendo encarecidamente automatizar la firma y notarización en CI desde el primer día, usando electron-builder con notarize: true y configurando APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD y APPLE_TEAM_ID como secretos. Es la única forma de no perder un día entero cada release.
Las actualizaciones automáticas son magia hasta que dejan de serlo. electron-updater funciona maravillosamente bien hasta que un usuario, por la razón que sea, queda atrapado en una versión muy antigua y necesita migrar el formato de datos. Conviene diseñar migraciones idempotentes del estado local desde el principio y nunca asumir que el usuario pasará por todas las versiones intermedias.
El IPC se vuelve un cuello de botella de diseño. En cuanto la app crece, terminas con docenas de canales IPC. Sin disciplina, esa superficie se vuelve inmanejable. Mi convención actual es agruparlos por dominio (fs:read, fs:write, db:query, window:minimize) y centralizar las definiciones en un solo módulo compartido entre main, preload y renderer, generando los tipos a partir de él. Es trabajo extra al inicio que paga dividendos sostenidos.
Closure
Empaquetar una web madura en Electron no es ni la decisión más glamurosa ni la más eficiente posible, pero a menudo es la que cabe en el presupuesto y el calendario reales. La clave está en respetar el modelo de seguridad (contextIsolation, preload tipado, nada de exponer ipcRenderer crudo), asumir desde el principio los costes operativos (firma, notarización, autoactualización) y no engañarse sobre el tamaño del bundle. Por consiguiente, si llegas hasta aquí con la pregunta "¿debería usar Electron en 2026?", mi respuesta es la que diría un viejo carpintero: depende del mueble. Para muchísimas apps de productividad, sigue siendo, no obstante, la herramienta que mejor sostiene el peso.