Débuter avec Electron



Développé et maintenu par Github, Electron est un framework qui permet de construire des applications de bureau multi plateformes (OSX, Windows, Linux) en utilisant les technologies web, HTML, CSS et JavaScript. Il embarque NodeJS pour le runtime et le moteur de rendu de Chromium pour le rendu graphique. Electron offre en plus une API qui sert d’abstraction pour interagir avec le système d’exploitation.

Comme fil rouge j’ai décidé de faire un Digital Asset Management ou DAM. Je vais développer deux fonctionnalités très simples:

  • Importer un Asset
  • Lister les Assets importés.

Créer et lancer l’application

J’utilise le builder Electron Forge pour créer le projet. Il en existe d’autres.

npx create-electron-app electron-dam
cd electron-dam

Forge met à disposition des Templates afin de commencer avec plus d’outils comme par exemple Webpack et TypeScript.

Je lance l’application:

npm start

Electron Hello World !

Je peux voir plusieurs choses assez cool. Mon application ressemble à n’importe quelle autre: un titre, un menu, le rendu visuel de ma fenêtre principale et même une DevToolBar !

J’ai testé sur Ubuntu 20.04, Windows 10, OSX Catalina.

Ma première impression est très bonne. C’est très simple d’avoir un premier déliverable.

Bootstraping

Je regarde ce que contient le projet.

// package.json
{
    "main": "src/index.js",
}

Comme toute application NodeJS, main spécifie le point d’entrée de l’application.

// package.json
{
    "scripts": {
        "start": "electron-forge start",
        "package": "electron-forge package",
        "make": "electron-forge make",
        "publish": "electron-forge publish",
        "lint": "echo \"No linting configured\""
    },
    ...
}

Forge met à disposition plusieurs commandes utiles pour développer et publier son application.

// package.json
{
    "config": {
        "forge": {
            "makers": [
                {
	            "name": "@electron-forge/maker-squirrel",
	            "config": {
		        "name": "electron_dam"
	            }
	        },
	        {
	            "name": "@electron-forge/maker-zip",
	            "platforms": [
	                "darwin"
	            ]
	        },
	        {
	            "name": "@electron-forge/maker-deb",
	            "config": {}
	        },
	        {
	            "name": "@electron-forge/maker-rpm",
	            "config": {}
	        }
            ]
        }
    }
}

Forge propose par défaut plusieurs Makers qui permettent de créer des distribuables spécifiques à chaque platforme.

Il y a aussi une section Plugins, qui permet d’étendre les fonctionnalités de Forge. Par exemple il est possible de profiter de Webpack avec le Hot Module Reload.

Le fichier index.js est très bien commenté et l’API d’Electron est simple à appréhender. J’ai très rapidement compris le contenu du fichier.

Les fichiers index.html et index.css contiennent la partie visuelle de mon application comme une application web classique.

Si je modifie le HTML ou le CSS je ne suis pas obligé de relancer l’application. Je peux rafraichir la page avec Ctrl+R pour voir les modifications.

Il est possible d’ajouter du Javascript:

<!-- index.html -->
<script>
    document.getElementsByTagName('h1')[0].addEventListener("click", (event) => { 
	alert('Welcome !');
    });
</script>

Electron Welcome !

Cependant si je veux changer par exemple la taille de ma fenêtre:

const mainWindow = new BrowserWindow({
  width: 1200,
  height: 800,
});

Je suis obligé de relancer mon application.

⚠️ Le fichier index.js est exécuté par NodeJS alors que les fichiers HTML et CSS sont eux interprétés par le navigateur. Le fichier index.js fait office de Backend tandis que le html fait office de Frontend.

Importer un Asset

Afin de stocker les Assets et être en mesure de jouer avec, je dois d’abord définir un répertoire de référence sur l’ordinateur de l’utilisateur.

Initialiser le répertoire de stockage

Au lancement de l’application je vais vérifier si le dossier existe. Si non je vais le créer. J’ai choisi arbitrairement le dossier de destination. Dans le futur je pourrais laisser le choix à l’utilisateur.

// index.js - Main process
const { app } = require('electron');
const path = require('path');
const fs = require("fs");

const assetsFolder = path.join(
  app.getPath('documents'), '/electron-dam-assets'
);

try {
  if (!fs.existsSync(assetsFolder)) {
    fs.mkdirSync(assetsFolder);
  }
} catch (err) {
  console.error(err);
}

La méthode app.getPath offre plusieurs options possibles. D’un point de vue développeur elle permet de s’abstraire du système d’exploitation sur lequel l’application est exécutée.

Après avoir relancer mon application, je vérifie qu’il existe maintenant un dossier electron-dam-assets dans mes Documents:

ls -al ~/Documents

Ajouter un menu

Comme intéraction je propose à l’utilisateur un menu d’application supplémentaire Asset. Par défaut Electron initialise un menu. Lorsque l’application est ready il est possible de le modifier via l’API Menu et MenuItem. Lorsque l’utilisateur va cliquer sur Import l’application va lui ouvrir une fenêtre pour sélectionner un fichier sur son ordinateur. Tout comme Menu et MenuItem, l’objet dialog permet de faire cela sans se soucier de l’OS.

// index.js
import { Menu, MenuItem, dialog } from 'electron';

app.on('ready', () => {
  let menu = Menu.getApplicationMenu();
  menu.append(new MenuItem({
    'label': 'Asset',
    'submenu': [{ 
      'label': 'Import',
      'click': () => {
    	  let filepaths = dialog.showOpenDialogSync({ 
          properties: ['openFile'] 
        });
        if (filepaths === undefined) {
          return;
        }
      }
    }]
  }));
  Menu.setApplicationMenu(menu);
}

Electron Menu

Electron dialog

Sauvegarder le fichier

J’ajoute une fonction importAsset pour copier le fichier sélectionné par l’utilisateur dans le dossier d’Assets.

'click': () => {
  ...
  importAsset(filepaths.shift());
}

const importAsset = (filepath) => {
  let filename = path.basename(filepath);
  let newFilepath = path.join(assetsFolder, filename);
  fs.copyFile(filepath, newFilepath, (err) => {
    if (err) throw err;
  })
}

En testant la fonctionnalité je m’assure que l’Asset a bien été importé:

Electron asset saved !

Afficher la liste des Assets

La prochaine étape est maintenant d’afficher les Assets importés sur la fenêtre principale.

<!-- index.html -->
<div id="assets"></div>
/* index.css */
#assets {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-gap: 10px;
}

.asset {
  width: 200px;
  height: 200px;
  border: black solid 2px;
}

Pour chaque instance de BrowserWindow est associé un processus de rendu (renderer proces). J’ajoute deux options à la création de ma fenêtre principale. Je vais d’abord autorisé l’integration de NodeJS dans ce processus de rendu et je lui passe le chemin vers le dossier d’assets. C’est une des façons de communiquer entre le processus principal (main process) et un processus de rendu. J’explique dans le chapitre suivant la notion de processus dans Electron qui est très importante.

// index.js
const mainWindow = new BrowserWindow({
	...
  webPreferences: {
    nodeIntegration: true,
    additionalArguments: [assetsFolder]
  }
});

Dans mon script coté client je vais récupérer le chemin du dossier de mes Assets depuis les arguments de process. Ensuite pour chaque fichier dans ce dossier je vais ajouter un nouvel élément img dans mon HTML avec pour attribut src le chemin vers le fichier.

// index.html
const path = require("path");
const fs = require("fs");

let assetsFolder = process.argv.slice(-1)[0];
let assets = document.getElementById('assets')
assets.innerHTML = '';
fs.readdir(assetsFolder, function (err, files) {
    if (err) {
        return console.log('Unable to scan directory: ' + err);
    } 
    files.forEach(function (file) {
        let img = document.createElement("img");
        img.src = path.resolve(assetsFolder, file);
        img.className = "asset";
        assets.appendChild(img);
    });
});

Si je relance l’application je devrais être en mesure de voir les Assets que j’ai déjà importé !

Electron asset list

J’ai terminé l’implémentation des deux fonctionnalités. Je vais maintenant expliquer quelques concepts que j’estime extrêmement important à saisir.

Le système de Processus

Electron a deux types de processus.

MainProcess

Il n’y a qu’un seul processus principal. Le MainProcess va servir principalement à créer des pages web à l’aide de BrowserWindow et à interagir avec le système grâce à NodeJS et à l’API d’Electron. C’est l’idée de Backend auquel je faisais référence un peu plus tôt.

Renderer process

Pour chaque instance de BrowserWindow (donc pour chaque page web) Electron associe un processus de rendu RendererProcess. Il peut donc y avoir autant de processus de rendu que de page web. Quand l’instance de BrowserWindow est détruite, le processus de rendu est lui aussi détruit. Le processus de rendu a pour but d’intéragir avec l’utilisateur. L’idée est de capter une action et de fournir un visuel adéquat. Le RendererProcess a accès aux API web classiques.

Plus d’informations sur la documentation.

Sécurité

J’ai rendu possible l’accès à NodeJS depuis le processus de rendu pour faciliter l’implémentation.

⚠️ Donner l’accès à NodeJS au processus de rendu est très pratique mais rend mon application totalement vulnérable. Si par exemple je charge du contenu distant (ce qui arrive souvent) j’expose mon système à des attaques de type injection XSS.

Par défaut, Electron désactive l’intégration de NodeJS dans le processus de rendu et respecte le principe du moindre privilège.

La documentation officielle fournit une liste de recommandations à propos de la sécurité des applications.

Si il est très déconseillé de laisser la possibilité au processus de rendu d’accéder au système, comment notre Frontend va t’il pouvoir communiquer avec notre Backend comme on pourrait le faire en Web avec des appels HTTP ?

IPC (Inter-Processes Communication)

Pour que les deux processus communiquent, l’API d’Electron met à disposition deux modules IpcMain et IpcRenderer.

ipcRenderer.send(channel, data); // Send data to main process
ipcRenderer.on(channel, (event, ...args) => func(...args)); // Receive data from main process

ipcMain.on(channel, (event, ...args) => func(...args)); // Receive data from a renderer process
BrowserWindow.webContents.send(channel, data); //Send data to a specific renderer process

Puisque ipcRenderer fait partie de l’API Electron, il n’est pas possible de l’utiliser depuis le processus de rendu. Il manque une dernière étape que les développeurs d’Electron ont implémenté sous forme de preloading.

Lorsque l’on créé une nouvelle page web on peut exécuter un script de preload:

// index.js
const mainWindow = new BrowserWindow({
    ...
    webPreferences: {
        preload: path.join(__dirname, "preload.js")
    }
  });

Ce script a accès à NodeJS et va nous servir de passerelle entre le processus principal et celui de rendu de la fenêtre courante. Le but de ce script est d’implémenter le principe de moindre privilège. On va pouvoir étendre les capacités du processus de rendu avec ce qui est seulement nécessaire.

// preload.js
const { contextBridge,ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld(
    "api", {
        send: (channel, data) => {
            ipcRenderer.send(channel, data);
        },
        receive: (channel, func) => {
            ipcRenderer.on(channel, (event, ...args) => func(...args));
        }
    }
);

Grâce à cela notre processus de rendu a maintenant accès à une nouvelle interface api.

//index.html
window.api.receive("channel", (data) => {
    console.log(`Received ${data} from main process`);
});
window.api.send("channel", "some data");

Pour comprendre un peu mieux le sujet sur la sécurité, la communication inter-processus, le principe de moindre responsabilité ainsi que le preloading il y a un excellent commentaire et un fichier Markdown.

Tests & Debug

Il existe plusieurs moyens de tester et debugger son application Electron. Je survole le sujet.

Packager et publier son application

Electron Forge fournit plusieurs utilitaires pour aider à partager son application.

Aller plus loin

La documentation officielle d’Electron est très complète. Toute l’API du framework est disponible. La section tutoriel couvrira la plupart des besoins en terme de développement.

Voici une liste d’applications développées avec Electron que j’utilise au quotidien sur différents systèmes et qui fonctionnent bien:

  • Slack
  • Discord
  • Notion
  • VSCode
  • WhatsApp
  • Twitch
  • Figma

Conclusion

L’initialisation d’une nouvelle application et la voir fonctionner en seulement deux commandes est très appréciable.

La promesse du multi platformes à l’air vraiment d’être tenue. Je pense qu’il y a forcément des exceptions mais l’impression que j’en ai jusque là est bonne.

L’expérience développeur est agréable aussi. La maturité grandissante de l’écosystème JavaScript aide en ce sens. L’utilisation de TypeScript ou Webpack par exemple est un gros plus. Il est possible d’utiliser des frameworks front tel que React ou Vue si besoin.

Un développeur JavaScript n’aura absolument aucun mal à prendre en main le framework.

Il y a quand même quelques notions difficiles à appréhender. Malgré que l’on retrouve vite nos habitudes de développeur web, l’environnement est différent et impose des contraintes différentes.

Globalement l’expérience est très positive et jusque là je ne vois aucune raison de ne pas utiliser Electron comme framework de développement d’application de bureau !