« Précédent

Electron / Avancé

A travers un exemple simple (un todo) nous allons explorer différents éléments de l'API d'electronjs

Mise en route

cd todos
npm init -y
npm install --save electron
...
"scripts": {
    "start": "electron .", // ici
    "test": "echo \"Error: no test specified\" && exit 1"
  },
...

Vous pouvez utiliser ce modèle d'index.js pour la majorité de vos projet:

const { app, BrowserWindow } = require("electron");
const path = require("path");

let mainWin;

function createWindow() {
  mainWin = new BrowserWindow({
    height: 600,
    width: 800,
    webPreferences: {
      preload: path.join(__dirname, "preload.js"),
    },
  });

  mainWin.setTitle("Todos");
  mainWin.loadFile("main.html");
  //win.webContents.openDevTools();
}

app.whenReady().then(createWindow);

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

app.on("activate", () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow();
  }
});

et celui ci pour main.html

<head>

</head>
<body>
    <h1>Mes Tâches:</h1>
</body>

Les Menus

Nous allons mettre en place un menu qui va nous permettre d'ajouter une tâche en ouvrant une seconde fenêtre.

Commençons par créer le menu. Ca se passe en deux temps. D'abord nous allons créer un modèle de menu, puis nous affecterons ce modèle au menu de l'application.

Pour le modèle:

const menuTemplate = [
  {
    label: "Todos",
    submenu: [
      {
        label: "Nouvelle tâche",
      },
      {
        label: "Quitter",
        click() {
          app.quit();
        }
      },
    ],
  },
];

Simplement, un tableau d'objets avec un menu principal et des sous menus. Un label pour nommer chaque chose et dans le cas de "Quitter" on ajoute une methode click() dans laquelle nous faisons appelle à app.quit() qui quittera l'application complète.

Ensuite, il faut dire au menu qu'il prend ce modèle:

const mainMenu = Menu.buildFromTemplate(menuTemplate);
  Menu.setApplicationMenu(mainMenu);

Sans oublier les imports en haut du fichier.

//index.js
const { app, BrowserWindow, Menu, MenuItem } = require("electron");
const path = require("path");

let mainWin;

function createWindow() {
  mainWin = new BrowserWindow({
    height: 600,
    width: 800,
    webPreferences: {
      preload: path.join(__dirname, "preload.js"),
    },
    title: "Todos",
  });

  mainWin.setTitle("Todos");
  mainWin.loadFile("main.html");

  const mainMenu = Menu.buildFromTemplate(menuTemplate);
  Menu.setApplicationMenu(mainMenu);
  //win.webContents.openDevTools();
}

app.whenReady().then(createWindow);

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

app.on("activate", () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow();
  }
});

const menuTemplate = [
  {
    label: "Todos",
    submenu: [
      {
        label: "Nouvelle tâche",
      },
      {
        label: "Quitter",
        click() {
          app.quit();
        }
      },
    ],
  },
];
// uniquement sur macos pour décaler le menu todos
if (process.platform === "darwin") {
  menuTemplate.unshift({ label: "" });
}

Vous remarquerez que j'ai ajouté un bloc d'instruction en bas pour qu'electron reconnaisse si nous sommes sous macOS et qu'il ajoute un menu vide dans ce cas.

Raccourcis clavier (multiplateforme)

Ce serait bien de pouvoir fermer l'application avec une combinaison de touches et que ça fonctionne sur macOS, Windows et Linux.

Encore un fois c'est assez simple. L'objet menu accepte un paramètre accelerator où nous allons écrire le raccourcis qui nous intéresse.

Je suis sur MacOS donc j'ajoute:

//index.js
//...
const menuTemplate = [
  {
    label: "Todos",
    submenu: [
      {
        label: "Nouvelle tâche",
      },
      {
        label: "Quitter",
        accelerator: "Cmd+Q",
        click() {
          app.quit();
        }
      },
    ],
  },
];
//...

Oui mais les autres systèmes n'ont pas ce raccourcis. C'est plutôt Ctrl+Q. Utilisons donc, comme je l'ai fait plus haut, process.platform pour connaitre le système hôte et une ternaire:

const menuTemplate = [
  {
    label: "Todos",
    submenu: [
      {
        label: "Nouvelle tâche",
      },
      {
        label: "Quitter",
        accelerator: (process.platform === "darwin")?"Cmd+Q":"Ctrl+Q",
        click() {
          app.quit();
        }
      },
    ],
  },
];

Maintenant, electron va s'adapter et proposer le raccourcis en fonction du système hôte.

MacOS est appelé "darwin" si vous n'aviez pas vu.

menu

Une nouvelle fenêtre

Activons le menu Nouvelle tâche. Tout d'abord créons une fonction pour créer la fenêtre et rendons la disponible à l'extérieur de la fonction.

let addWin 

const createAddWindow = () => {
  addWin = new BrowserWindow({
    width: 300,
    height: 300,
    title: 'Ajouter une Tâche'
  });
};

Remarquez que nous avons spécifié la taille et le titre de cette fenêtre.

Et ajoutons l'action au menu:

const menuTemplate = [
  {
    label: "Todos",
    submenu: [
      {
        label: "Nouvelle tâche",
        click() {
          createAddWindow()
        }
      },
      {
        label: "Quitter",
        accelerator: process.platform === "darwin" ? "Cmd+Q" : "Ctrl+Q",
        click() {
          app.quit();
        },
      },
    ],
  },
];

Il nous faut une nouvelle fenêtre. Celle ci, sans surprise, aura un html comme UI (add.html).

<head>

</head>
<body>
    <form >
        <div>
            <label>Entrez votre tâche</label>
            <input type="text" placeholder="une tâche" autofocus>
        </div>
        <button type="submit">Ajouter</button>

    </form>
</body>

et il fait l'ajouter aussi à la méthode de lancement de la fenêtre:

const createAddWindow = () => {
  addWin = new BrowserWindow({
    width: 300,
    height: 300,
    title: 'Ajouter une Tâche'
  });

  addWin.loadFile("add.html")
};

Ok, si vous testez ça, vous avez bien une nouvelle fenêtre qui apparait quand on clique dans le menu (nouvelle tâche).

Gestion des instances de fenêtres

Si on ferme la fenêtre principal alors qu'une fenêtre add est ouvert, alors seul la fenêtre principal se ferme mais pas l'application.

C'est un peu gênant. Pour régler ce problème, nous allons simplement ajouter un écouteur à la fenêtre principal et dire que lorsque celle ci est fermée l'application complète doit être fermée.

function createWindow() {
  mainWin = new BrowserWindow({
    height: 600,
    width: 800,
    webPreferences: {
      preload: path.join(__dirname, "preload.js"),
    },
    title: "Todos",
  });

  mainWin.setTitle("Todos");
  mainWin.loadFile("main.html");

  mainWin.on('closed', () => {app.quit();}); // ici 

  const mainMenu = Menu.buildFromTemplate(menuTemplate);
  Menu.setApplicationMenu(mainMenu);
}

Interaction entre nos fenêtres

Petits modifications pour que notre projet soit propre. Renommez preload.js en mainPreload.js et ajoutez addPreload.js, puis affectez chacun d'eux dans les webpreferences de nos fonctions de créations de fenêtres.

à ce stade nous avons un projet qui ressemble à ça:

todos
├── add.html
├── addPreload.js
├── assets
├── index.js
├── main.html
├── mainPreload.js
├── node_modules
├── package-lock.json
└── package.json

Dans addPreload ajoutons quelques interactions et envoyons l'input vers index.js

const { ipcRenderer } = require("electron/renderer"); //on oublie pas les imports

window.addEventListener('DOMContentLoaded', () => {

  document.querySelector('form').addEventListener("submit", (event) => {
    event.preventDefault();
    const todo = document.querySelector('input').value;
    ipcRenderer.send('todo:add', todo); // on envoie tout ça à index.js
  });
});

On reçoit l'info dans index.js

const { app, BrowserWindow, Menu, ipcMain } = require("electron"); // on n'oublie pas d'ajouter ipcMain

//...
// on récupère
ipcMain.on("todo:add", (event, todo) => {
  mainWin.webContents.send("todo:data", todo); on renvoie à la fenêtre principale
  addWin.close(); et on ferme la fenêtre addWin
});

Enfin on arrive à mainPreload.js et index.html

<!--index.html-->
<head>

</head>
<body>
    <h1>Mes Tâches:</h1>
    <ul>

    </ul>
</body>
//mainPreload.js
const { ipcRenderer } = require("electron");

ipcRenderer.on('todo:data', (event,data) => {
    const li = document.createElement("li");
    const text = document.createTextNode(data);

    li.appendChild(text);
    document.querySelector('ul').appendChild(li);
})

Ok, maintenant, tout devrait fonctionner parfaitement.

Parenthèse sur le debug

Depuis que nous avons mis en place le menu. Il n'est plus possible d'afficher le debugger. Nous pouvons le réactiver. Nous allons même le faire uniquement pour notre phase de développement.

Ajoutez ce bout de code à la fin d'index.js

/ Pour ajouter un menu d'affichage du debugger quand on est dev
if (process.env.NODE_ENV !== "production") {
  menuTemplate.push({
    label: "DEBUG!!!",
    submenu: [
      {
        label: "Afficher le debugger",
        click(item, focusedWindow) {
          focusedWindow.webContents.openDevTools(true);
        },
      },
    ],
  });
}

Quel est le mécanisme? Simplement process.env.NODE_ENV peut prendre plusieurs valeurs dont production (production, development, staging, test). Ici nous disons à electron d'ajouter un menu quand nous ne sommes pas en production. Ensuite nous activons le debugger uniquement à la fenêtre qui a le focus avec focusedWindow.

Et Voilà !

Garbage Collector

Il y un problème auquel il faut faire attention! Lorsque l'on ferme une fenêtre (BrowserWindow.close()), en réalité nous ne libérons pas la mémoire. Ainsi quand nous rouvrons une autre fenêtre, une nouvelle instance de BrowserWindow est créée mais l'autre n'est pas vraiment libérée. Elle attend dans le Garbage Collector et de proche en proche la mémoire se remplit. La méthode est de réaffecter notre fenêtre à null et dire à javascript qu'il pourra voder la mémoire quand il en aura besoin.

Dans le code, nous avons juste à ajouter dans la méthode de création un écouteur:

const createAddWindow = () => {

  //...

  addWin.loadFile("add.html");
  addWin.on("closed", () => addWin = null); // ici
};

les sources ici