Guide complet de développement d'applications desktop avec Electron : De la configuration à la production
Pourquoi Electron
Le développement desktop multiplateforme a toujours été un casse-tête. Écrire des applications Windows en C# et WPF, des applications macOS en Swift et AppKit, des applications Linux en GTK ou Qt — trois bases de code, triple maintenance. Electron a changé la donne : une seule base de code HTML/CSS/JavaScript, empaquetée pour Windows, macOS et Linux simultanément.
Si vous utilisez VS Code, Figma, Slack ou Discord, vous exécutez déjà des applications Electron tous les jours. La vitesse de démarrage de VS Code et les performances de rendu de Figma prouvent qu'Electron peut égaler l'expérience native avec la bonne architecture.
J'ai aidé à migrer un outil interne de C# WinForms vers Electron. La vitesse de livraison des fonctionnalités a triplé au cours des deux trimestres suivants. La raison est simple : embaucher des développeurs frontend est bien plus facile que d'embaucher des développeurs desktop en C#, et l'écosystème de composants est nettement plus riche.
Cet article est mon résumé complet post-migration. Je ne vais pas réciter la documentation API — vous pouvez la consulter vous-même — je vais me concentrer sur ce qui plante réellement en production et comment le réparer.
Initialisation du projet : Ne copiez pas le Quick-Start officiel
Le electron-quick-start officiel utilise du JavaScript brut, sans outils de build, sans modules, sans hot reload. Commencer un vrai projet de cette façon garantit du code spaghetti en deux mois.
Utilisez electron-vite ou configurez manuellement Vite + Electron :
npm create @quick-start/electron@latest my-app -- --template vue-ts
Ou configuration manuelle :
mkdir my-electron-app && cd my-electron-app
npm init -y
npm i -D electron electron-builder vite @vitejs/plugin-vue typescript
npm i vue
Structure de répertoires recommandée :
my-electron-app/
├── electron/ # Processus principal
│ ├── main.ts # Point d'entrée, création de fenêtres
│ ├── preload.ts # Pont de sécurité
│ └── ipc/ # Gestion des canaux IPC
│ ├── file.ts # Opérations sur les fichiers
│ └── system.ts # Informations système
├── src/ # Rendu (frontend)
│ ├── App.vue
│ ├── main.ts
│ └── components/
├── resources/ # Icônes de l'app, ressources statiques
├── electron-builder.yml
├── vite.config.ts
└── package.json
La décision architecturale critique : séparation complète des processus principal et de rendu. Le processus principal (electron/) gère uniquement les opérations système. Le rendu (src/) est un projet frontend standard. Cela permet aux spécialistes de chaque côté de travailler indépendamment sans friction.
Modèle de processus : Savoir qui fait quoi
Electron possède deux processus fondamentaux. Si vous n'assimilez pas ce concept, les problèmes de sécurité et de performance vous obligeront à réécrire le code à plusieurs reprises.
| Processus | Runtime | Capacités | Limitations |
|---|---|---|---|
| Principal | Node.js | Créer des fenêtres, lire/écrire des fichiers, appeler les API système, gérer le cycle de vie | Ne peut pas manipuler le DOM directement |
| Rendu | Chromium | Rendre le HTML/CSS, répondre aux entrées utilisateur, manipuler le DOM | Ne peut pas accéder aux API Node.js par défaut |
La communication se fait via IPC (Inter-Process Communication). Le processus principal expose une surface d'API limitée aux rendus via contextBridge.
Un flux de communication typique :
L'utilisateur clique sur "Enregistrer le fichier"
→ Rendu : ipcRenderer.invoke('save-file', fileData)
→ Principal : ipcMain.handle('save-file', async (event, data) => {
dialog.showSaveDialog() → fs.writeFileSync()
})
→ Retourne le résultat au rendu
Point d'entrée du processus principal
// electron/main.ts
import { app, BrowserWindow } from 'electron';
import { registerIpcHandlers } from './ipc';
let mainWindow: BrowserWindow | null = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, // Doit être activé
nodeIntegration: false, // Doit être désactivé
sandbox: true, // Mode bac à sable
},
});
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
}
}
app.whenReady().then(() => {
registerIpcHandlers();
createWindow();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
Script de preload — La porte de sécurité de votre application
// electron/preload.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
saveFile: (content: string, defaultName?: string) =>
ipcRenderer.invoke('save-file', content, defaultName),
openFile: () =>
ipcRenderer.invoke('open-file'),
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
getPlatform: () => process.platform,
minimizeWindow: () => ipcRenderer.send('window-minimize'),
maximizeWindow: () => ipcRenderer.send('window-maximize'),
closeWindow: () => ipcRenderer.send('window-close'),
});
Appel depuis le rendu :
// src/components/SaveButton.vue
const handleSave = async () => {
const result = await window.electronAPI.saveFile(
editorContent.value,
'untitled.txt'
);
if (result.success) {
showToast(`Enregistré dans ${result.filePath}`);
}
};
contextBridge est crucial — il restreint ce que le rendu peut appeler. N'exposez jamais le ipcRenderer brut au rendu ; cela donnerait au contenu web un accès complet à Node.js.
Sécurité : Sept règles non négociables
Le modèle de sécurité d'Electron a évolué à travers plusieurs itérations. Les anciens tutoriels montrent souvent des motifs désormais non sécurisés. En 2026, voici les exigences strictes :
| # | Règle | Configuration | Pourquoi |
|---|---|---|---|
| 1 | contextIsolation: true |
webPreferences | Isole le contexte JS du rendu du preload |
| 2 | nodeIntegration: false |
webPreferences | Bloque le require Node.js direct dans le rendu |
| 3 | sandbox: true |
webPreferences | Bac à sable au niveau de l'OS, restreint les permissions du processus |
| 4 | Utiliser contextBridge |
preload.ts | Exposition d'API basée sur une liste blanche |
| 5 | Valider l'origine des messages IPC | ipcMain.handle | Vérifier l'URL de event.senderFrame |
| 6 | Pas de contenu distant sans CSP | webPreferences | Politique CSP requise pour les URL distantes |
| 7 | Signer et notariser | electron-builder | macOS exige la notarisation, Windows recommande la signature |
Validation de l'origine IPC
// electron/ipc/file.ts
ipcMain.handle('save-file', async (event, content: string) => {
if (!event.senderFrame?.url.startsWith('file://')) {
throw new Error('Appelant non autorisé');
}
if (typeof content !== 'string' || content.length > 10 * 1024 * 1024) {
throw new Error('Contenu invalide');
}
const { filePath } = await dialog.showSaveDialog({
defaultPath: 'untitled.txt',
filters: [{ name: 'Text', extensions: ['txt', 'md'] }],
});
if (!filePath) return { success: false, canceled: true };
await fs.promises.writeFile(filePath, content, 'utf-8');
return { success: true, filePath };
});
Gestion des fenêtres : Au-delà de new BrowserWindow
Fenêtres sans cadre et barres de titre personnalisées
La plupart des applications desktop modernes abandonnent les barres de titre natives — VS Code, Figma, Notion le font toutes. Implémentation :
const mainWindow = new BrowserWindow({
frame: false,
titleBarStyle: 'hidden',
...(process.platform === 'darwin'
? { titleBarStyle: 'hiddenInset' }
: { frame: false }),
});
Barre de titre personnalisée dans le rendu :
<!-- src/components/TitleBar.vue -->
<template>
<div class="title-bar" :class="{ 'is-mac': isMac }">
<div class="title-bar-drag" @dblclick="toggleMaximize">
<span class="app-title">{{ title }}</span>
</div>
<div class="window-controls" v-if="!isMac">
<button @click="minimize" class="control-btn minimize">─</button>
<button @click="toggleMaximize" class="control-btn maximize">□</button>
<button @click="close" class="control-btn close">✕</button>
</div>
</div>
</template>
Note sur -webkit-app-region: drag — cette propriété CSS permet le glisser-déplacer sur la zone de la barre de titre. Les zones de boutons doivent la surcharger avec no-drag, sinon cliquer sur un bouton déclenche le déplacement de la fenêtre.
Communication entre plusieurs fenêtres
Les applications complexes nécessitent souvent plusieurs fenêtres (paramètres, aperçu, dialogues). Acheminez la communication inter-fenêtres via le processus principal :
ipcMain.on('broadcast-to-windows', (event, channel: string, data: unknown) => {
BrowserWindow.getAllWindows().forEach(win => {
if (win.webContents.id !== event.sender.id) {
win.webContents.send(channel, data);
}
});
});
Intégration de modules natifs : Quand Node.js ne suffit pas
Certaines tâches sont trop lentes en JavaScript pur — traitement d'images par lot, encodage vidéo. C'est là que les modules natifs C++ interviennent.
Option 1 : Addon natif Node.js (node-addon-api)
// native/thumbnail.cc
#include <napi.h>
Napi::Value GenerateThumbnail(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
std::string inputPath = info[0].As<Napi::String>();
std::string outputPath = info[1].As<Napi::String>();
// Utiliser FFmpeg/libvips pour générer la vignette
return Napi::String::New(env, outputPath);
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("generateThumbnail",
Napi::Function::New(env, GenerateThumbnail));
return exports;
}
NODE_API_MODULE(thumbnail, Init)
Option 2 : Processus fils appelant des outils externes
Préférez cette option — découplée, les plantages n'affectent pas le processus principal :
// electron/ipc/image.ts
import { spawn } from 'child_process';
ipcMain.handle('compress-image', async (event, inputPath: string) => {
return new Promise((resolve, reject) => {
const child = spawn('oxipng', ['--opt', 'max', '--strip', 'safe', inputPath]);
let stderr = '';
child.stderr.on('data', (data) => { stderr += data; });
child.on('close', (code) => {
if (code === 0) resolve({ success: true });
else reject(new Error(`oxipng a quitté avec le code ${code} : ${stderr}`));
});
});
});
Mise à jour automatique : Mises à niveau transparentes
La mise à jour automatique est la norme pour les applications desktop. electron-updater (basé sur electron-builder) est l'option la plus mature :
// electron/updater.ts
import { autoUpdater } from 'electron-updater';
import { BrowserWindow, dialog } from 'electron';
export function setupAutoUpdater(mainWindow: BrowserWindow) {
autoUpdater.setFeedURL({
provider: 'generic',
url: 'https://releases.your-app.com/updates/',
});
setInterval(() => {
autoUpdater.checkForUpdates();
}, 4 * 60 * 60 * 1000);
autoUpdater.on('update-available', () => {
dialog.showMessageBox(mainWindow, {
type: 'info',
title: 'Mise à jour disponible',
message: 'Téléchargement de la mise à jour en arrière-plan...',
});
});
autoUpdater.on('update-downloaded', () => {
dialog.showMessageBox(mainWindow, {
type: 'info',
title: 'Mise à jour prête',
message: 'Redémarrez maintenant pour installer la dernière version.',
}).then(() => {
autoUpdater.quitAndInstall();
});
});
autoUpdater.on('error', (err) => {
console.error('Erreur de mise à jour :', err.message);
});
setTimeout(() => autoUpdater.checkForUpdates(), 5000);
}
Empaquetage et distribution
Exemple de electron-builder.yml :
appId: com.yourcompany.yourapp
productName: YourApp
directories:
output: release
files:
- dist/**/*
- electron/**/*.js
- package.json
win:
target:
- target: nsis
arch: [x64, arm64]
icon: resources/icon.ico
mac:
target:
- target: dmg
arch: [x64, arm64]
icon: resources/icon.icns
category: public.app-category.developer-tools
hardenedRuntime: true
notarize:
teamId: YOUR_TEAM_ID
linux:
target:
- target: AppImage
arch: [x64]
- target: deb
arch: [x64]
category: Development
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
Commandes de build :
npm run dev # développement local
npm run build # plateforme actuelle
npm run build:win # Windows
npm run build:mac # macOS
npm run build:linux # Linux
Optimisation des performances : Ne laissez pas votre application devenir Chrome
La principale critique d'Electron est sa consommation mémoire. Une application Electron basique utilise 50-80 Mo, et avec du contenu réel, dépasse facilement les 200 Mo+. Voici des stratégies validées en production :
1. Chargement différé des fenêtres non critiques
let settingsWindow: BrowserWindow | null = null;
ipcMain.handle('open-settings', async () => {
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.focus();
return;
}
settingsWindow = new BrowserWindow({ /* ... */ });
settingsWindow.loadURL('...');
settingsWindow.on('closed', () => { settingsWindow = null; });
});
2. Limitation en arrière-plan
const win = new BrowserWindow({
webPreferences: {
backgroundThrottling: true,
offscreen: false,
},
});
win.on('blur', () => {
win.webContents.setBackgroundThrottling(true);
});
3. Utiliser BrowserView pour le contenu intégré
const view = new BrowserView({
webPreferences: { sandbox: true, contextIsolation: true },
});
mainWindow.setBrowserView(view);
view.setBounds({ x: 0, y: 0, width: 800, height: 600 });
view.webContents.loadURL('https://preview.your-app.com/doc/123');
4. Liste de contrôle des fuites mémoire
| Vérification | Outil | Métrique |
|---|---|---|
| Processus zombies après fermeture de fenêtre | Gestionnaire des tâches / Activity Monitor | Processus electron persistants |
| webContents non détruits | webContents.getAllWebContents() |
Compte croissant |
| Listeners IPC non nettoyés | ipcMain.eventNames() |
removeHandler manquant |
| Références à de grands objets | Panneau Memory de Chrome DevTools | Comparaison d'instantanés du tas |
Conseils de débogage
Débogage du processus principal
# Lancer avec le débogueur attaché
electron --inspect=5858 .
# Ouvrir chrome://inspect dans Chrome pour se connecter
VSCode launch.json :
{
"type": "node",
"request": "launch",
"name": "Debug Main Process",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"runtimeArgs": ["--inspect=5858", "."],
"port": 5858
}
Débogage du processus de rendu
Les DevTools s'ouvrent automatiquement en mode développement. En production, forcez-les avec un flag :
if (process.argv.includes('--devtools')) {
mainWindow.webContents.openDevTools({ mode: 'detach' });
}
Pièges courants
Piège 1 : La notarisation macOS échoue
"notarization failed: The binary is not signed"
Solution : Assurez-vous d'avoir hardenedRuntime: true, un fichier d'entitlements correct et un certificat Apple Developer ID.
Piège 2 : Chemin Windows trop long
Windows a une limite de chemin de 260 caractères. Les node_modules profondément imbriqués la dépassent facilement. Activez la prise en charge des chemins longs dans Windows 10 1607+ via la stratégie de groupe, ou utilisez electron-builder install-app-deps.
Piège 3 : Les modules natifs échouent après l'empaquetage asar
electron-builder empaquette le code dans app.asar par défaut, mais les modules natifs .node ne peuvent pas être chargés depuis l'intérieur de l'asar :
# electron-builder.yml
asarUnpack:
- "node_modules/sharp/**"
- "node_modules/better-sqlite3/**"
- "native/**/*.node"
Piège 4 : Long écran blanc au démarrage
- Divisez la sortie Vite par code-splitting, chargez un code minimal pour le premier rendu
- Utilisez un écran de démarrage pour couvrir la période blanche
- Affichez la fenêtre uniquement sur l'événement
ready-to-show:
mainWindow.once('ready-to-show', () => { mainWindow.show(); });
Outils associés
- Encodage/Décodage Base64 — Gérer les ressources intégrées et les transferts de fichiers
- Formateur JSON — Déboguer les messages IPC et les fichiers de configuration
- Compresseur d'images — Optimiser la taille des ressources image dans l'application
Essayez ces outils exécutés localement dans le navigateur — aucune inscription requise →