Electron: Node.js à la conquête du desktop

GNU/Linux Magazine HS n° 085 | juillet 2016 | Michael Bailly.
Creative Commons
  • Actuellement 0 sur 5 étoiles
0
Merci d'avoir participé !
Vous avez déjà noté cette page, vous ne pouvez la noter qu'une fois !
Votre note a été changée, merci de votre participation !
J'ai lu hier sur Twitter « JavaScript conquered the Web, and now it comes to the desktop ». Je ne peux qu’acquiescer pleinement. La technologie Electron permet de créer des applications de bureau en JavaScript via Node.js et Chromium. Le bonheur sur un plateau.

Full-Stack. Le mot est à la mode. Le sacro-saint Graal, le mouton à cinq pattes, le sauveur de la situation, c'est bien sûr le Full Stack developer qui vient d'être embauché ! Et pour cause : il connaît aussi bien les entrailles des navigateurs web, que les secrets les plus intimes du serveur Node.js. Et bien, celui-là va être comblé : imaginez que, pour créer des applications, votre code JavaScript puisse être en même temps dans le contexte du navigateur, et dans le contexte de Node.js... ? Imaginez que vous puissiez disposer de la foultitude des modules npm, et en même temps disposer des Media Queries, des animations HTML5, de votre framework frontend JavaScript de prédilection… ? C'est exactement ce que propose electron.js, un projet créé par les adorables développeurs de chez GitHub.

1. Un framework d'application « client lourd »

Un peu à la manière de Cordova, qui permet de créer des applications Android, iOS et autres à partir des technologies HTML5/CSS3/JavaScript, Electron propose de créer des applications desktop pour plateformes Linux, Windows et Mac OS X. Sa vitrine internet est accessible à l'URL : http://electron.atom.io/.

Au moment de l'écriture de cet article, Electron est disponible en version 1.0.1 (la version 1.0.0 est sortie le 9 mai 2016), il embarque Node version 5.10 et Chromium 49. Il apporte, en plus de la combinaison de Chromium et Node.JS, un ensemble de bibliothèques permettant une intégration très poussée avec l'environnement de bureau. En effet, l'expérience d'une application ne se réduit pas forcément au contenu de sa fenêtre. Pour une application de chat par exemple, on attend des notifications qui apparaissent, près de l'horloge, même lorsque la fenêtre de l'application n'est pas active. Si on programme un lecteur multimédia, on veut empêcher l'écran de rentrer en mode veille lorsque la vidéo joue.

De plus, une application ne nécessite pas toujours de fenêtre. Reprenons l'exemple de l'application de chat : il est complètement envisageable de vouloir que cette application affiche une icône près de l'horloge (dit system tray), et indique le nombre de messages non lus, même lorsque la fenêtre de chat est fermée… C'est un comportement typique, utilisé par exemple par Pidgin.

Electron part de ce principe : l'application lancée est une application « Node.js », dans laquelle le développeur peut créer des fenêtres. Voyons maintenant ce que cela donne du côté du code.

1.1 Installation de Electron

Dans ce guide, je pars du principe que vous installez Electron sur plateforme GNU/Linux. Si vous êtes sur un autre système d'exploitation, la page des releases citée plus bas contient aussi les binaires pour Mac OS X et Windows.

La première chose à faire est de télécharger Electron à cette adresse : https://github.com/electron/electron/releases. Il faut ensuite dézipper votre archive (pour ma part le fichier est electron-v1.0.1-linux-x64.zip) dans un nouveau répertoire, par exemple/usr/local/electron. Enfin, il est nécessaire de rajouter ce répertoire dans votre PATH, soit dynamiquement via la commande :

$ export PATH="/usr/local/electron:$PATH"

Soit de manière permanente en utilisant les fichiers de configuration de votre shell favori. Maintenant, la commande suivante doit retourner le numéro de version de Electron qui vient d'être installé :

$ electron --version

L'environnement est prêt à être utilisé.

1.2 Premiers pas avec Electron

Comme indiqué précédemment, Electron est une application « Node.js » qui ouvre des fenêtres. L'application minimale est donc composée de deux fichiers : le fichier qui lance l'application, et que l'on nomme en général main.js, et le fichier qui charge la vue de la fenêtre, en général index.html. Voici une version minimaliste du fichier principal main.js :

'use strict';

const electron = require('electron');

const app = electron.app;

const BrowserWindow = electron.BrowserWindow;

var mainWindow = null;

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

  app.quit();

});

app.on('ready', function() {

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

  mainWindow.loadURL('file://' + __dirname + '/index.html');

  mainWindow.on('closed', function() {

    mainWindow = null;

  });

});

Cette application récupère via le module npmelectrondeux objets,app, etBrowserWindow.apppermet de contrôler le cycle de vie de l'application. BrowserWindow, lui, est une classe, que l'on instancie lorsque l'on veut créer une nouvelle fenêtre. Cette application doit ouvrir une fenêtre, qui est une instance deBrowserWindowet que l'on stockera dans la variable mainWindow. Il est nécessaire de garder une référence globale à cette variable, sans quoi elle serait recyclée par le garbagecollectordu moteur V8, ce qui aurait pour fâcheuse conséquence de fermer la fenêtre. Grâce à l'objet app, on définit le cycle de vie de  l'application : on écoute l'évènement window-all-closed, qui se déclenche, figurez-vous, lorsque toutes les fenêtres de son application sont fermées, et on demande à notre application de s’arrêter dans ce cas-là. On écoute ensuite l'évènement ready, et c'est là que les choses deviennent intéressantes. On crée une nouvelle instance de BrowserWindow, et on lui fait charger une URL locale : celle bien entendu du fichierindex.html. Enfin, on n'oublie pas bien entendu de déréférencer l'instance de notre fenêtre lorsque celle-ci est fermée, sans quoi cela créerait un leak.

Le fichier index.html est extrêmement simple :

<!DOCTYPE html>

<html>

  <head>

    <meta charset="UTF-8">

    <title>Hello World!</title>

  </head>

  <body>

    <h1>Bonjour depuis Electron !</h1>

  </body>

</html>

Comme le veut l'ancestrale tradition, ce fichier HTML affiche une version Electron de « Hello World ».

Reste maintenant à lancer l'application. Rien de plus simple :

$ ls

index.html  main.js

$ electron main.js

Et la fenêtre en question apparaît, comme la capture d'écran de la figure 1 en témoigne.

Figure 1

On remarque que lorsqu'on ferme cette fenêtre, le programme s'arrête, conformément aux instructions qu'on lui a fournies.

1.3 La combinaison de Node.js et Chromium.

Notre précédente fenêtre a beau avoir belle allure, elle manque sans doute d'un peu d’intelligence. Par exemple, peut-on y afficher le contenu d'un fichier, disons /etc/passwd ? On reprend le fichier index.html auquel on rajoute des instructions JavaScript :

<!DOCTYPE html>

<html>

  <head>

    <meta charset="UTF-8">

    <title>Hello World!</title>

  </head>

  <body>

    <h1>Bonjour depuis Electron !</h1>

    <code id="passwd"></code>

    <script>

      const fs = require('fs');

      let passwdContents = fs.readFileSync('/etc/passwd');

      let passwdNode = document.querySelector('#passwd');

      passwdNode.innerHTML = passwdContents;

    </script>

  </body>

</html>

Et là, à ce moment-là précisément, j'espère que vous ressentez le vertige et la grandeur de ce que vous venez de découvrir, les possibilités infinies et la facilité avec laquelle on peut maintenant programmer des applications desktop. On a rajouté à notre structure HTML une balise code qui a pour id passwd. On a ensuite ouvert une balise JavaScript. On commence par utiliser le module fs de Node.js afin de lire le contenu du fichier /etc/passwd. Puis on utilise la méthode querySelector de l'objet DOM document pour sélectionner la balise code et la remplir. C'est toute la magie de Electron qui se révèle ici : dans un même contexte d’exécution, on a accès, en même temps, aux API JavaScript d'un navigateur, en l’occurrence Chromium, et du contexte de Node.js, avec les globales comme process, l'ensemble des modules disponibles via npm. Pour les plus curieux, l'explication de l'implémentation technique commence ici : http://electron.atom.io/docs/v0.37.7/development/atom-shell-vs-node-webkit/.

Figure 2

2. Electron dans le détail

2.1 Processus principal et renderers

Une application Electron est donc, en premier lieu, un script JavaScript exécuté via la commande electron <fichier>.js. On appelle ce processus le processus principal (main process). Ce processus dispose, en plus des modules Node.js classiques, des modules spécifiques rajoutés par Electron et disponibles via l'appel :

var <module electron> = require('electron').<module electron> ;

Ainsi, pour utiliser le module permettant de monitorer les changements d'état de l'alimentation, on utilise :

var powerMonitor = require('electron').powerMonitor ;

Depuis ce processus principal, on l'a vu, il est possible d'ouvrir des fenêtres. Lorsqu'on ouvre une fenêtre, un nouveau processus système est lancé, qui fait apparaître la fenêtre et un contexte JavaScript y est associé. On appelle ce process un renderer. Eh bien, figurez-vous que ces deux contextes JavaScript sont complètement séparés, étanches. Et, bien entendu, les modules Electron dont on vient de parler, tels le powerMonitor, ne sont pas disponibles dans les processus renderer ? En fait, si...

2.1.1 Le module remote

Le module remote permet d'accéder, depuis un processus renderer, aux modules du processus général.

Dans un processus renderer, on accèdera au powerMonitor de cette manière :

var powerMonitor = require('electron').remote.powerMonitor ;

2.1.2 Les modules ipcMain/ipcRemote

Les modules ipcMain (à charger dans le processus principal) et ipcRemote (à charger dans les processus renderer) permettent une communication par messages synchrones ou asynchrones entre un renderer et le processus principal. Voici un exemple de message asynchrone :

- Dans le fichier index.html :

const ipcRenderer = require('electron').ipcRenderer ;

ipcRenderer.send('my-topic', {ping : true}) ;

- Dans le fichier main.js :

const ipcMain = require('electron').ipcMain ;

ipcMain.on('my-topic', function(event, data) {

  console.log('Got a message on channel "my-topic"', data) ;

}) ;

À l’exécution de ce programme, on voit bien dans le terminal le log :

Got a message on channel "my-topic" { ping: true }

2.2 Communication entre les processus renderer

Electron propose trois systèmes de communication utilisables entre renderers : l'utilisation des standards web, le partage de données JSON au travers d'un système IPC (Inter-Process Communication), et un système d’évènements, plus habituel dans le monde JavaScript.

La première suggestion des développeurs de Electron, lorsqu'il est nécessaire de partager des données, est d'utiliser les objets standards localStorage et/ou sessionStorage, et pourquoi pas IndexedDb. Cette première solution doit être capable de couvrir la plupart des besoins. Cependant, on peut aussi utiliser une facilité fournie par le framework, qui consiste à partager des objets json. Enfin presque.

Pour commencer, il faut que l'objet partagé soit exposé dans le scope global (https://nodejs.org/api/globals.html#globals_global). Bon soit, c'est à peu près la chose la plus mondialement déconseillée, de proposer de mettre les données dans le scope global du processus principal…Exécutons-nous ; on rajoute dans le fichier main.js les lignes suivantes :

 global.sharedThing = {

  label: 'Shared from main.js'

};

On décide donc de partager l'objet sharedThing. Celui-ci contient une propriété label qui indique qu'il a été créé dans le fichier main.js. On récupère ce label, puis on le met à jour, depuis une BrowserWindow via le code suivant :

<!DOCTYPE html>

<html>

  <head>

    <meta charset="UTF-8">

    <title>JSON data sharing</title>

  </head>

  <body>

    <script>

      var sharedThing = require('remote').getGlobal('sharedThing');

      console.log(sharedThing.label);

      sharedThing.label = 'shared from index.html';

    </script>

  </body>

</html>

Il est à noter que ces données peuvent être accédées et par le processus principal, et par les différentes fenêtres.

Dernière solution, il est possible de transmettre des messages, synchrones ou asynchrones, entre renderers.  Cependant, les différentes fenêtres ne se connaissant pas entre elles, il est nécessaire de passer par le processus principal pour envoyer les messages. Voici, par exemple, l'implémentation d'un broadcast, qui envoie un message à l'ensemble des autres fenêtres ouvertes de l'application.

'use strict';

const electron = require('electron');

const app = electron.app;

const BrowserWindow = electron.BrowserWindow;

var windows = [];

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

  app.quit();

});

app.on('ready', function() {

  var w = new BrowserWindow({width: 800, height: 600});

  w.loadURL('file://' + __dirname + '/index.html');

  windows.push(w);

  w.on('closed', function() {

    windows = windows.filter(function(window) {

      return window !== w;

    });

  });

  w.webContents.on('broadcast-to-other-windows', function(data) {

    // envoie le message aux autres fenetres de l'application

    windows.filter(function(window) {

      return window !== w;

    }).forEach(function(window) {;

      window.webContents.emit('broadcast', data);

    });

  });

});

Puis, dans une page renderer :

// écoute les messages broadcastés par les autres fenêtres de l'application

var myWebContents = require('electron').remote.getCurrentWebContents();

myWebContents.on('broadcast', function(data) {

  console.log('received ', data, 'from another window of the application');

});

// broadcast un message, avec des données, aux autres fenêtres de l'application

myWebContents.emit('broadcast-to-other-windows', { hello: 'there' });

On découvre ce faisant l'objet WebContents. Celui-ci représente le contenu de la page web. Il propose des méthodes comme loadUrl(url, options) ou encore findInPage(text, options). C'est sans doute l'objet Electron qui expose le plus de méthodes, car on peut contrôler extrêmement finement le moindre évènement qui se passe dans la page Web.

3. Les API spécifiques aux applications desktop

Dans les précédents paragraphes, nous avons expliqué le fonctionnement d'une application Electron. Maintenant, penchons-nous sur ce qui fait la spécificité de l'outil: la mise à disposition d'API qui permettent une intégration poussée avec l'environnement de bureau.

3.1 Tray API

Comment traduit-on Tray en français ? Ce sont les petites icônes qui viennent s'ajouter dans la zone de notification, en général « près de l'horloge », et qui permettent d'accéder rapidement à l'application. Voyons donc comment rajouter une entrée dans le Tray, et comment définir le menu qui s'ouvre lorsque l'on clique dessus. Nous devons commencer par apprendre rapidement le fonctionnement des classes Menu et MenuItem, qui permettent de créer des menus. Ces classes génériques sont utilisées, non seulement pour le Tray, mais aussi pour créer les menus des fenêtres (Fichier, Édition…) et aussi pour gérer les menus contextuels (le menu qui s'affiche lorsqu'on utilise le bouton droit de la souris).

3.1.1 Menu et MenuItem

Les objets Menu et MenuItem permettent donc de créer des menus. Le MenuItem permet de déclarer une entrée de menu. Commençons par un exemple :

const MenuItem = require('electron').MenuItem;

let newThingItem = new MenuItem({

  label: "New…",

  type: "normal",

  click: function(menuItem, browserWindow) {

    console.log('New is clicked');

  }

});

Le constructeur MenuItem est simple, il prend un objet d'options. Parmi ces options, on peut noter en particulier:

- label : une chaîne de caractères qui contient le texte de cette entrée de menu ;

- type : définit le type d'entrée, qui peut être normal, separator, submenu, checkbox ou radio ;

- icon : l’icône de cette entrée de menu ;

- enabled : un booléen permettant de désactiver l'entrée de menu, tout en la laissant visible ;

- visible : un booléen permettant de faire disparaître l'entrée de menu ;

- checked : un booléen permettant d'indiquer si l'entrée de menu (du type checkbox ou radio) est cochée.

Un Menu, quant à lui, permet de regrouper des MenuItems. Il y a plusieurs manières d'instancier un menu ; on prend ici l'exemple le plus simple, qui utilise la méthode append pour rajouter des entrées :

const MenuItem = require('electron').MenuItem;

const Menu = require('electron').Menu;

let newThingItem = new MenuItem({

  label: "New…",

  type: "normal",

  click: function(menuItem, browserWindow) {

    console.log('New is clicked');

  }

});

let openThingItem = new MenuItem({

  label: "Open…",

  type: "normal",

  click: function(menuItem, browserWindow) {

    console.log('Open is clicked');

  }

});

let trayMenu = new Menu();

trayMenu.append(newThingItem);

trayMenu.append(openThingItem);

3.1.2 Le Tray

Maintenant que l'on sait créer un menu, utilisons le Tray afin de faire apparaître une icône de notre application dans la zone de notification. Un clic sur cette icône fera apparaître ledit menu. Le Tray, comme quelques autres fonctionnalités de Electron, ne peut pas être utilisé avant que Electron ait fini d'initialiser complètement l'application. Il faut par conséquent écouter l'évènement ready de l'objet app et initialiser le tray dans un callback. Voici comment faire :

const Tray = require('electron').Tray;

const app = require('electron').app;

function createTray() {

  let tray = new Tray('./linux2.jpg');

  tray.setContextMenu(trayMenu);

}

app.on('ready', createTray);

Dans la fonction createTray, on initialise un objet Tray en lui donnant en paramètre une image. On lui assigne ensuite le menu contextuel créé ci-avant.

Figure 3

3.2 Dialog API

Tôt ou tard, il est nécessaire dans une application desktop de lancer des dialogues permettant, par exemple, de sélectionner des fichiers, ou encore d'afficher des messages. L'API dialog de Electron est faite pour cela.

Voici, par exemple, le code permettant de proposer à l'utilisateur de sélectionner des fichiers Open Document Text (extension .odt) au sein de l'application :

'use strict';

var app = require('electron').app;

var dialog = require('electron').dialog;

function openDialog() {

  dialog.showOpenDialog({

    title: 'Please select some Open Document Text',

    defaultPath: '/tmp',

    properties: ['openFile', 'multiSelections'],

    filters: [

      { name: 'Open Document Text', extensions: ['odt'] },

      { name: 'All Files', extensions: ['*'] }

    ]

  }, function(filesPath) {

    console.log('files:', filesPath);

  });

}

app.on('ready', openDialog);

Comme souvent, l'API n'est utilisable qu'une fois que l'application est initialisée, c'est pourquoi on écoute l'évènement ready. On utilise ensuite la méthode showOpenDialog de l'objet dialog, qui prend comme argument un objet d'options et un callback. Le callback recevra en argument, soit undefined, si l'utilisateur n'a pas choisi de fichier, soit un tableau contenant le chemin entier des fichiers (par exemple /home/someuser/thedoc.odt).

Figure 4

3.3 Shell API

Un autre besoin assez fréquent, et qui démontre bien l'intégration avec le système de bureau sous-jacent est l'API shell. Elle permet par exemple d'ouvrir un fichier avec le programme par défaut du système, d'afficher un fichier avec l'explorateur du système, ou encore de placer un fichier dans la corbeille.

On l'utilise de cette manière :

shell.openItem('/some/where/file.pdf');

Cette commande ouvrira le fichier avec le lecteur PDF système.

shell.showItemInFolder('/some/where/file.pdf');

Celle-ci affichera le fichier dans l'explorateur de fichiers du système.

shell.moveItemTotrash('/some/where/file.pdf');

Enfin, celle-là mettra le fichier dans la corbeille du système.

3.4 Et d'autres API…

On pourrait continuer, et expliciter une par une l'ensemble des API de Electron, mais l'intérêt est limité. La documentation, disponible à l'URL http://electron.atom.io/docs/, est complète et claire. Ce que l'on a mis en évidence cependant, c'est que ces API sont simples à utiliser ! Cela permet d'abaisser la difficulté dans la création d'une application « client lourd ».

4. Packaging

Tout cela est bien joli, mais jusque-là, nous n'avons pas réellement un exécutable natif, mais seulement un ensemble de fichiers JavaScript que l'on exécute via la commande electron. Le framework offre aussi la possibilité de créer des installeurs (.exe sous Windows, .dmg sous Mac OS X et exécutables 32 et 64 bits sous Linux), et cela va, selon le niveau de raffinement que l'on souhaite, de simple à très compliqué. En effet, nombreux sont les obstacles et détails pour packager une application. Le plus ardu est sans doute de gérer les modules natifs. Il est possible d'utiliser des modules npm qui sont écrits en C++ et compilés. Dans ce cas, il faut les recompiler pour chaque plateforme cible. Un autre détail d'importance, nommer correctement l’exécutable, afin que l'application porte le nom qu'on lui a choisi et non pas « electron », est plus compliqué que ce qu'il y paraît à première vue (sauf sous Linux, ou un simple renommage de l'exécutable suffit). Il faut fournir des icônes pour l'application, en différentes tailles. Et associer l’icône au programme peut être très simple (Linux) ou très compliqué (Windows).

Heureusement, la communauté s'est penchée sur le sujet, et il existe des outils permettant d'automatiser la majeure partie du travail. Pour peu que l'application respecte une certaine organisation des fichiers, et que l'on fournisse les données correctes, telles que le nom final de l'application par exemple, et la création de fichiers exécutables s'en trouve énormément simplifiée. On citera entre autres les projets electron-packager (https://github.com/electron-userland/electron-packager) et electron-builder (https://github.com/electron-userland/electron-builder).

Le système de packaging étant intrusif par rapport à l'organisation des fichiers sur le disque, il est fortement conseillé de lire et suivre les recommandations formulées avant de se lancer dans le développement proprement dit de l'application.

Conclusion

La technologie Electron repose sur des briques modernes (Chromium et Node.js), et permet d'utiliser une foultitude de modules disponibles via npm. Elle offre la possibilité aux développeurs web d'utiliser leurs outils quotidiens pour créer des applications de bureau. L'industrie ne s'y est pas trompée : le client lourd de Slack, le nouvel éditeur de code de Microsoft (oui oui, Microsoft), ou encore le client lourd de WordPress, l'utilisent. Les équipes de GitHub ne se sont pas trompées non plus : « Si vous savez construire un site web, vous savez construire une application de bureau ».