Manual Completo de Electron.js con TypeScript

Introducción a Electron.js

Electron.js es un framework para construir aplicaciones de escritorio multiplataforma usando tecnologías web (HTML, CSS y JavaScript). Combina Chromium y Node.js en un solo runtime.

Beneficios de Electron con TypeScript:

Crear un proyecto Electron con TypeScript

# Inicializar proyecto Node.js
mkdir mi-app-electron && cd mi-app-electron
npm init -y

# Instalar dependencias principales
npm install electron typescript --save-dev
npm install @types/node @types/electron --save-dev

# Inicializar TypeScript
npx tsc --init

# Estructura básica del proyecto
.
├── src/
│   ├── main/          # Código del proceso principal
│   ├── renderer/      # Código del proceso de renderizado
│   └── preload.ts     # Script de precarga
├── dist/              # Código compilado
├── public/            # Assets estáticos
├── tsconfig.json      # Configuración de TypeScript
└── package.json

Configuración Básica

tsconfig.json

{
  "compilerOptions": {
    "target": "ES6",
    "module": "CommonJS",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

package.json

{
  "name": "mi-app-electron",
  "version": "1.0.0",
  "main": "dist/main/main.js",
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "start": "npm run build && electron .",
    "dev": "concurrently \"npm run watch\" \"wait-on dist/main/main.js && electron .\""
  },
  "devDependencies": {
    "electron": "^latest",
    "typescript": "^latest",
    "@types/node": "^latest",
    "@types/electron": "^latest",
    "concurrently": "^latest",
    "wait-on": "^latest"
  }
}

Estructura del proceso principal

// src/main/main.ts
import { app, BrowserWindow } from 'electron'
import path from 'path'

let mainWindow: BrowserWindow | null = null

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, '../preload.js'),
      nodeIntegration: false,
      contextIsolation: true
    }
  })

  if (process.env.NODE_ENV === 'development') {
    mainWindow.loadURL('http://localhost:3000')
    mainWindow.webContents.openDevTools()
  } else {
    mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
  }

  mainWindow.on('closed', () => {
    mainWindow = null
  })
}

app.whenReady().then(createWindow)

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

app.on('activate', () => {
  if (mainWindow === null) {
    createWindow()
  }
})

Comunicación entre Procesos

IPC (Inter-Process Communication)

// src/main/main.ts (Proceso principal)
import { ipcMain } from 'electron'

ipcMain.handle('perform-task', async (event, data) => {
  console.log('Datos recibidos:', data)
  return { result: 'Tarea completada' }
})

// src/preload.ts (Exponer APIs seguras)
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('electronAPI', {
  performTask: (data: any) => ipcRenderer.invoke('perform-task', data)
})

// src/renderer/app.ts (Proceso de renderizado)
declare global {
  interface Window {
    electronAPI: {
      performTask: (data: any) => Promise<{ result: string }>
    }
  }
}

async function handleClick() {
  const response = await window.electronAPI.performTask({ id: 1 })
  console.log(response.result) // 'Tarea completada'
}

Tipos para IPC

// src/types/ipc.d.ts
interface IpcRequest<Req, Res> {
  request: Req
  response: Res
}

declare namespace ElectronAPI {
  interface Tasks {
    'perform-task': IpcRequest<{ id: number }, { result: string }>
    'save-file': IpcRequest<{ content: string }, { success: boolean }>
  }
}

// src/preload.ts con tipos fuertes
contextBridge.exposeInMainWorld('electronAPI', {
  performTask: (data: ElectronAPI.Tasks['perform-task']['request']) => 
    ipcRenderer.invoke('perform-task', data) as Promise<
      ElectronAPI.Tasks['perform-task']['response']
    >,
  
  saveFile: (data: ElectronAPI.Tasks['save-file']['request']) => 
    ipcRenderer.invoke('save-file', data) as Promise<
      ElectronAPI.Tasks['save-file']['response']
    >
})

Manejo de Ventanas

Creación de ventanas con TypeScript

// src/main/windowManager.ts
import { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'

export class WindowManager {
  private static instances: Map<string, BrowserWindow> = new Map()

  static createWindow(
    id: string,
    options: BrowserWindowConstructorOptions
  ): BrowserWindow {
    if (this.instances.has(id)) {
      const window = this.instances.get(id)!
      if (window.isMinimized()) window.restore()
      window.focus()
      return window
    }

    const defaults: BrowserWindowConstructorOptions = {
      width: 800,
      height: 600,
      webPreferences: {
        nodeIntegration: false,
        contextIsolation: true,
        sandbox: true
      },
      ...options
    }

    const window = new BrowserWindow(defaults)
    this.instances.set(id, window)

    window.on('closed', () => {
      this.instances.delete(id)
    })

    return window
  }

  static getWindow(id: string): BrowserWindow | undefined {
    return this.instances.get(id)
  }
}

Ventanas modales

// src/main/main.ts
import { BrowserWindow, ipcMain } from 'electron'
import path from 'path'

ipcMain.handle('open-modal', (event, options) => {
  const modal = new BrowserWindow({
    parent: BrowserWindow.fromWebContents(event.sender)!,
    modal: true,
    show: false,
    webPreferences: {
      preload: path.join(__dirname, '../preload.js'),
      contextIsolation: true
    },
    ...options
  })

  modal.loadFile(path.join(__dirname, '../renderer/modal.html'))
  
  modal.once('ready-to-show', () => {
    modal.show()
  })

  return modal.id
})

Almacenamiento y Bases de Datos

Electron Store

// src/main/store.ts
import Store from 'electron-store'

interface Schema {
  windowBounds: {
    width: number
    height: number
  }
  recentFiles: string[]
  settings: {
    theme: 'light' | 'dark'
    fontSize: number
  }
}

export const store = new Store<Schema>({
  defaults: {
    windowBounds: {
      width: 800,
      height: 600
    },
    recentFiles: [],
    settings: {
      theme: 'light',
      fontSize: 14
    }
  }
})

// Uso en el proceso principal
store.set('windowBounds', { width: 1024, height: 768 })
const theme = store.get('settings.theme')

SQLite con TypeScript

// src/main/database.ts
import sqlite3 from 'sqlite3'
import { open, Database } from 'sqlite'

interface User {
  id: number
  name: string
  email: string
  createdAt: Date
}

class AppDatabase {
  private db: Database | null = null

  async initialize(): Promise<void> {
    this.db = await open({
      filename: './database.sqlite',
      driver: sqlite3.Database
    })

    await this.db.exec(`
      CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        email TEXT UNIQUE NOT NULL,
        createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
      )
    `)
  }

  async addUser(user: Omit<User, 'id' | 'createdAt'>): Promise<number> {
    if (!this.db) throw new Error('Database not initialized')
    
    const result = await this.db.run(
      'INSERT INTO users (name, email) VALUES (?, ?)',
      user.name, user.email
    )
    
    return result.lastID
  }

  async getUsers(): Promise<User[]> {
    if (!this.db) throw new Error('Database not initialized')
    return this.db.all('SELECT * FROM users')
  }
}

export const database = new AppDatabase()

Actualizaciones Automáticas

electron-updater

// src/main/updater.ts
import { autoUpdater } from 'electron-updater'
import { ipcMain } from 'electron'

export function setupAutoUpdates() {
  autoUpdater.autoDownload = false
  autoUpdater.autoInstallOnAppQuit = true

  autoUpdater.on('update-available', (info) => {
    const mainWindow = BrowserWindow.getFocusedWindow()
    if (mainWindow) {
      mainWindow.webContents.send('update-available', info)
    }
  })

  autoUpdater.on('update-downloaded', (info) => {
    const mainWindow = BrowserWindow.getFocusedWindow()
    if (mainWindow) {
      mainWindow.webContents.send('update-downloaded', info)
    }
  })

  ipcMain.handle('check-for-updates', async () => {
    return await autoUpdater.checkForUpdates()
  })

  ipcMain.handle('download-update', async () => {
    return await autoUpdater.downloadUpdate()
  })

  ipcMain.handle('quit-and-install', () => {
    autoUpdater.quitAndInstall()
  })
}

// package.json
{
  "build": {
    "publish": [
      {
        "provider": "github",
        "owner": "tu-usuario",
        "repo": "tu-repositorio"
      }
    ]
  }
}

Mejores Prácticas de Seguridad

Importante: La seguridad es crítica en aplicaciones Electron. Sigue estas prácticas:

Recomendaciones clave

Configuración segura de BrowserWindow

new BrowserWindow({
  webPreferences: {
    preload: path.join(__dirname, 'preload.js'),
    nodeIntegration: false,        // Deshabilitar integración con Node
    contextIsolation: true,       // Aislar contextos
    sandbox: true,                // Habilitar sandbox
    webSecurity: true,            // Habilitar políticas de seguridad web
    allowRunningInsecureContent: false, // No permitir contenido inseguro
    experimentalFeatures: false   // Deshabilitar características experimentales
  }
})

Content Security Policy (CSP)

<meta http-equiv="Content-Security-Policy" content="
  default-src 'self';
  script-src 'self' 'unsafe-inline';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self' https://api.example.com;
  media-src 'self';
  object-src 'none';
  frame-src 'none';
">

Empaquetado y Distribución

electron-builder

// package.json
{
  "build": {
    "appId": "com.example.miapp",
    "productName": "Mi App",
    "copyright": "Copyright © 2023",
    "directories": {
      "output": "release"
    },
    "files": [
      "dist/**/*",
      "public/**/*"
    ],
    "win": {
      "target": "nsis",
      "icon": "public/icon.ico"
    },
    "mac": {
      "target": "dmg",
      "category": "public.app-category.utilities",
      "icon": "public/icon.icns"
    },
    "linux": {
      "target": "AppImage",
      "category": "Utility",
      "icon": "public/icon.png"
    }
  },
  "scripts": {
    "pack": "electron-builder --dir",
    "dist": "electron-builder",
    "dist:win": "electron-builder --win",
    "dist:mac": "electron-builder --mac",
    "dist:linux": "electron-builder --linux"
  }
}

Notarización (macOS)

// package.json
{
  "build": {
    "mac": {
      "hardenedRuntime": true,
      "gatekeeperAssess": false,
      "entitlements": "entitlements.mac.plist",
      "entitlementsInherit": "entitlements.mac.plist"
    },
    "afterSign": "scripts/notarize.js"
  }
}

// scripts/notarize.js
require('dotenv').config()
const { notarize } = require('electron-notarize')

exports.default = async function notarizing(context) {
  const { electronPlatformName, appOutDir } = context
  if (electronPlatformName !== 'darwin') return

  const appName = context.packager.appInfo.productFilename

  return await notarize({
    appBundleId: 'com.example.miapp',
    appPath: `${appOutDir}/${appName}.app`,
    appleId: process.env.APPLE_ID,
    appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD
  })
}

Conclusión

Electron.js con TypeScript ofrece una combinación poderosa para desarrollar aplicaciones de escritorio multiplataforma con tecnologías web. El sistema de tipos mejora la calidad del código y la experiencia de desarrollo.

Recursos adicionales: