Desarrolla una app desktop con Electron.js

Desarrolla una app desktop con Electron.js

Últimamente, el desarrollo web ha tomado terreno dentro de las aplicaciones de escritorio, dando como resultado apps que destacan por su alcance cross-platform e interfaces que lucen espectaculares.

Cada día es más común ver apps desktop creadas con tecnologías web como:

  • VSCode.
  • Mongo Compass.
  • Discord.
  • Postman.
  • Microsoft Teams.

Y una larga lista que podrías encontrar ya publicada en la Microsoft Store, en App store (Mac) y en Snapcraft (Linux).


Pero… ¿cómo lo hacen?


En específico, todos los ejemplos mencionados previamente utilizan la misma tecnología para desktop: Electron.js.

En este artículo, incursionaremos en el uso de Electron.js desarrollando un Rompecabezas cross-platform con Vite y Vue.js.

Dado lo anterior, construiremos una app que replique el comportamiento de un rompecabezas deslizable, algo como lo que podrías encontrar en esta búsqueda de Google.

En el siguiente link se encuentra el repositorio con el proyecto completo. Por efectos prácticos, únicamente haremos la siguiente vista:  

Es una aplicación sencilla, perfecta para este ejemplo.


Empecemos por los conceptos.

¿Qué es Electron.js?


Es un framework para desarrollo de aplicaciones desktop cross-platform no nativas (Linux, Mac y Windows) que permite usar tecnologías web como núcleo para desarrollar tu app, aparte de proveer un kit de herramientas para acceder a diferentes APIs del sistema huésped y permitir utilizar frameworks como Vue, React, Svelt, etc.

Electron utiliza Chromium y Node.js. En pocas palabras, ejecuta tu código dentro de un navegador, pero la magia realmente ocurre en dos procesos sobre los que se basa todo su comportamiento: Main Process y Renderer Process.

Main Process

El Main Process o Proceso Principal (para los panas) es el punto de inicio de toda aplicación en Electron, el cual se ejecuta sobre un entorno de Node.js. Por lo tanto podemos acceder a todas las APIs disponibles para este entorno por medio de un simple require.

Dentro de este proceso se controlan diferentes apartados de la aplicación como:

Manejo de ventanas

Este proceso permite crear diferentes instancias del módulo BrowserWindow, mismo que inicia una ventana web con un proceso aislado en el que puedes renderizar un archivo HTML.

El módulo BrowserWindow tiene diferentes eventos que sirven para controlar diferentes acciones sobre la ventana, tales como minimización, maximización y cierre, entre otras tantas.

Ciclo de vida o Lifecycle

En el proceso principal se maneja el ciclo de vida de la app. Para ser exactos, una app de Electron se basa en eventos que pueden o no determinar el inicio y fin del proceso principal. Terminar el proceso principal indica el cierre de la app como tal.

Renderer Process

Cada vez que se crea una instancia de BrowserWindow, Electron lanza un proceso de renderizado por separado. Lo anterior permite hacer todo lo que realiza un navegador web estándar.

Una particularidad de estos procesos es, principalmente, que no se ejecutan en un entorno de Node.js, por lo que necesitarás usar un bundler como Webpack o Vite.js (entre otros) para importar directamente módulos de NPM.

Tanto el proceso principal como los de renderizado no pueden comunicarse directamente. Esto es por una medida de seguridad para evitar que el proceso de renderizado exponga variables con contenido sensible, como API keys, por ejemplo.


Este framework tiene ventajas y desventajas. Uno de sus puntos fuertes es que permite desarrollar una aplicación desktop con tecnologías web.

Si bien provee distintas APIs para controlar las funcionalidades del sistema huésped, también es conocido por su alto consumo de recursos, pues literalmente abrir dos Electron apps es como abrir 2 páginas web en 2 navegadores diferentes.

Para agilizar el proceso de inicio, recomiendo descargar el siguiente repositorio, que contiene todo lo necesario para iniciar de manera rápida con la revisión de archivos e implementación de modificaciones.

Al descargar el repositorio, lo único que tendrán que hacer es escribir el npm install de toda la vida y después iniciar el proyecto mediante el comando npm start. Al ejecutarlo, el comando anterior se mostrará en pantalla de manera similar a ésta:

Dentro de estos archivos, los más importantes y que revisaremos a detalle serán:

  • src/main.js.
  • src/preload.js.
  • src/renderer.js.
  • vite.renderer.config.mjs.
  • index.html.

En este template no se encuentran configurados Vue.js, vue-router ni @vitejs/plugin-vue. Por ende, empecemos por la configuración más básica, la del render process, para permitir el uso de componentes separados en archivos con la extensión .vue. Dicha configuración se debe realizar en el archivo vite.renderer.config.mjs:

import { defineConfig } from 'vite';

import vue from '@vitejs/plugin-vue'


// https://vitejs.dev/config

export default defineConfig({

 plugins: [ vue() ],

 resolve: {

   alias: {

     vue: 'vue/dist/vue.esm-bundler.js',

   },

 },

});


Antes de continuar con los archivos, debemos crear nuestro componente principal dentro un nuevo directorio llamado src/components y, al interior de éste, un fichero nuevo al que llamaremos Game.vue y en el cual agregaremos el siguiente contenido como placeholder:

<script setup>

console.log('game view')

</script>


<template>

 <section class="controls">

 </section>

 <section class="hero center">

   Game

 </section>

 <section class="content">

 </section>

</template>



Ahora agregaremos la configuración del router. Para eso, necesitamos generar un nuevo archivo llamado src/routes/index.js, que vendrá con el siguiente contenido:

import { createRouter, createWebHashHistory } from "vue-router";

import Game from "../components/Game.vue"


const routes = [

{ path: '/', component: Game },

]


export const router = createRouter({

 history: createWebHashHistory(),

 routes,

})


Si no estás familiarizado/a con vue-router no hay mucho problema: es fácil de intuir cómo funciona. Realmente todas las rutas van dentro del arreglo de routes, solo hace falta especificar el path y el componente al que apuntan.

Es momento de continuar con los archivos a los que hay que prestarles atención especial:

Index.html

Este archivo es el objetivo de renderización del Main process (src/main.js). Aquí podemos cambiar el texto del frame de la app con modificar la etiqueta <title>. También podemos indicar el elemento que será el punto de montaje de nuestra vue.js app y es tal cual lo que haremos (aparte de agregar el montaje de vue router con la etiqueta <router-view>):

<!DOCTYPE html>

<html>

 <head>

   <meta charset="UTF-8" />

   <title>PZZL</title>

 </head>

 <script type="module" src="/src/renderer.js"></script>

 <body id="app">

   <router-view></router-view>

 </body>

</html>


Con esto, terminamos con el archivo index.html.

src/renderer.js (Renderer Process)

Este archivo se encarga de la lógica del renderer process. Aunque no es exactamente importante, es clave que nuestro index.html haga referencia a él.

Aquí montaremos nuestra vue app, incluyendo el router que habíamos creado previamente:

import './styles/index.css'

import { createApp } from 'vue';

import { router } from './routes';

const app = createApp({}) // instancia de vue

// agrega el plugin del router creado en src/routes/index.js

app.use(router)

// monta la app de vue usando de raíz el elemento del dom con el id #app

app.mount('#app')


Con esto, Vue.js quedó configurado y listo para usarse en nuestra Electron app. Si ejecutamos npm start, veremos algo como lo siguiente:


Ahora veamos cómo se logra el ciclo de vida de nuestra app. El siguiente paso es revisar el archivo src/main.js.

src/Main.js (Main Process)

Este es el archivo relacionado directamente con el Main Process: aquí se pueden manejar ventanas y modificar lo que ocurre durante el lifecycle de la app. Veamos este fichero por partes:

  • Inicio de la app

El inicio de la aplicación está indicado por el evento ready. En este punto, Electron nos indica que está listo para generar nuevas ventanas de navegador. Un detalle: en esta implementación, el listener de este evento está ligado a la creación de la ventana principal:

app.on('ready', createWindow);

  • Creación de ventanas

Este paso es relativamente sencillo, pues se pueden agregar propiedades específicas a las ventanas (instancias de BrowserWindow) para que se comporten de una manera específica (inclusive agregando listeners o configurando un preload script):

const createWindow = () => {

 // Create the browser window.

 const mainWindow = new BrowserWindow({

   width: 400,

   height: 600,

   autoHideMenuBar: true,

   resizable: false,

   maximizable: false,

   webPreferences: {

     preload: path.join(__dirname, 'preload.js'),

   },

 });

 // and load the index.html of the app.

 if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {

   mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);

 } else {

   mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`));

 }

 // Open the DevTools.

 mainWindow.webContents.openDevTools({ mode: 'detach' });

};


Normalmente, la creación de ventanas permite la ejecución de un preload script, mismo que se ejecuta después del inicio de la ventana, pero antes de que inicie el Renderer Process como tal, aquí se pueden compartir valores entre un proceso y otro y exponer una clase de API. Aquí un ejemplo:

// Preload.js

const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld(

 'electron',

 {

   closeApp: () => ipcRenderer.send('close'),

 }

)


// Main.js

const { ipcMain } = require('electron')

// Recepción del evento close

ipcMain.on('close', () => {

 app.quit()

})


Como se pudo ver, se usó el módulo contextBridge para exponer una función que permite enviar una señal al Main Process por medio del módulo ipcRenderer. Este último es un emisor y receptor de eventos que hace posible enviar señales y datos al Main Process, pero no puede ser usado en el Renderer Process.

Por seguridad, es recomendable exponer pequeñas APIs con funciones que hagan directamente el proceso o lo manden por señales para que el Main Process se encargue de todo.

  • Cierre de la app

Hay varias formas de cerrar o finalizar una app de Electron. La más normal se encuentra definida por defecto en el archivo src/Main.js y, básicamente, esta función revisa el número de ventanas abiertas. Si es igual a 0 y la plataforma no es Darwin (Mac), termina el Main Process:

app.on('window-all-closed', () => {

  if (process.platform !== 'darwin') {

    app.quit();

  }

});


Si bien terminamos de revisar los archivos importantes, aún falta algo: Terminar la funcionalidad principal de nuestra app, generar el tablero del juego, renderizar y controlar su estado… ¡Pero eso ya está parcialmente resuelto! Lo haremos usando una librería y nos limitaremos a aplicar ciertas acciones y renderizar el tablero de manera reactiva.

Ahora toca instalar un paquete de npm que, convenientemente, resuelve nuestro problema:

npm i slide-puzzle-engine


Esta librería es muy sencilla y permite generar el tablero de un rompecabezas deslizable  indicando únicamente sus dimensiones. También tiene funciones especiales para intercambiar piezas en la dirección que elijamos.

Empecemos con la implementación. Primero, en el archivo src/components/Game.vue hay que crear el tablero e inicializarlo al montar el componente de Vue:

<script setup>

import { Board, Direction } from 'slide-puzzle-engine'

import {

 reactive,

 onMounted,

 computed,

 watch,

 ref,

} from 'vue'

let Game = reactive({ board: new Board({

 dimensions: {width: 3, height: 3 }

})})


</script>


Esta nueva tag de script debe ir al inicio del archivo. Por ahora, el tablero ya fue creado, aunque falta exponer sus propiedades por medio de propiedades computadas:


La función to2Array() retorna un arreglo 2D en el que las piezas son indicadas por números y el espacio vacío es indicado por una X. Sabiendo eso, podemos renderizar el tablero:


Con esto se recorre el tablero y se renderizan las piezas, aparte de que se asigna una propiedad data-cursor en true cuando la posición tiene un lugar vacío.

Debería verse así:


Realmente la app ya empieza a verse bien, y eso se debe a que el template tiene estilos precargados. De esa forma, nos podemos dedicar al funcionamiento sin distraernos tanto con el maquetado. Todo está en el directorio src/styles para cualquiera que desee dar un vistazo.

Hasta este punto el tablero ya se renderiza, pero falta hacer que las piezas se desplacen. Eso lo haremos agregando un evento de keyup al objeto document y haciendo uso del método move(), el cual facilita intercambiar posiciones entre el espacio vacío y la pieza en la dirección indicada, siempre y cuando sea un movimiento válido:


Con esto, debería permitir desplazarnos al pulsar las flechas o las letras W, A, S o D del teclado. Ya es posible movernos. Sin embargo, aún no podemos hacer lo crucial para cualquier juego: ¡Ganar!


Para eso, hay que agregar una serie de modificaciones: primero hay que hacer un watcher que se encargue de percibir los cambios del estado solved del tablero. Este watcher debe activar y desactivar los eventos del teclado, aparte de que es necesario darle algún feedback al jugador, indicándole que ha ganado:


Con eso ya vemos cambios del estado del tablero. Lo siguiente es el feedback, así que debemos modificar la etiqueta <section class=”hero center”>:

¡Listo! ¡Podemos ganar el juego!

Bien, se llegó al punto en donde la aplicación cumple con su ciclo de vida. A partir de este punto, se pueden implementar varias funcionalidades para darle más vida a este proyecto:

  • Timer para indicar al usuario cuánto tardó en resolver el puzzle.
  • Botón para reiniciar partida (intenta presionando Ctrl+r).
  • Sustituir los números por imágenes.
  • Ranking local basado en marca de tiempo.

Todas estas pequeñas ideas pueden servirte para desarrollar más este ejemplo e, inclusive, aplicar métodos de la API para carga y procesamiento de archivos (entre otros detalles ), así como el uso de Electron forge para publicar tu app en las tiendas disponibles.

Electron.js es un framework bastante amplio y puede verse más abrumador si lo mezclas con algún otro para frontend. Lo más importante es comprender cómo funcionan sus bases para hacerlo funcionar con las herramientas que más te agraden o se ajusten más a las necesidades de tu proyecto.


Conclusión

Con Electron.js no hay excusas para tener interfaces vistosas en aplicaciones desktop, aunque tiene un costo en recursos difícil de ignorar. Si te apoyas en las herramientas correctas, es un framework bastante versátil y sencillo de usar, excelente si quieres mostrar algo llamativo,  funcional en múltiples plataformas y que te ayudará a lograr resultados bastante decentes en poco tiempo.

Espero que esta guía haya sido de utilidad. ¡Nos vemos en la próxima!

💡
Las opiniones y comentarios emitidos en este artículo son propiedad única de su autor y no necesariamente representan el punto de vista de Listopro.

Listopro Community da la bienvenida a todas las razas, etnias, nacionalidades, credos, géneros, orientaciones, puntos de vista e ideologías, siempre y cuando promuevan la diversidad, la equidad, la inclusión y el crecimiento profesional de los profesionales en tecnología.