Développer une app Electron avec Claude Code : IPC, preload, packaging et mises à jour
Guide pratique Electron avec Claude Code : contextIsolation, preload, IPC, accès fichiers, packaging et auto update.
Electron permet de transformer une interface web en application de bureau pour Windows, macOS et Linux. Ce confort a une contrepartie : l’application peut ouvrir des fichiers locaux, appeler des boîtes de dialogue natives, gérer des menus et livrer des mises à jour. La frontière de sécurité devient donc aussi importante que le choix du framework UI.
Claude Code est utile si vous lui confiez des tâches étroites : ajouter un canal IPC, créer une API preload, renforcer une règle d’accès fichier, ajuster une configuration de packaging. Lui demander une application complète avec installeur et auto update en une seule fois produit un diff trop large pour une revue sérieuse.
La base actuelle côté Electron est de garder contextIsolation, de désactiver nodeIntegration dans le renderer, d’exposer uniquement de petites APIs via preload et de valider à nouveau dans le main process. Les références officielles à garder sous la main sont Context Isolation, IPC, Distribution Overview et autoUpdater.
Pour comparer avec Tauri, consultez le guide de développement Tauri. Pour typer proprement la frontière preload/renderer, voyez aussi les conseils TypeScript.
Séparer les trois couches
| Couche | Responsabilité | Bonne tâche pour Claude Code | Revue humaine |
|---|---|---|---|
| main process | BrowserWindow, APIs OS, fichiers, menus, updates | Ajouter un handler IPC à la fois | Validation, droits, erreurs, règles de chemin |
| preload | Pont minimal vers le renderer | Exposer des fonctions avec contextBridge | Pas d’exposition brute de ipcRenderer |
| renderer | UI, formulaires, état, affichage | Écran et interactions | Pas d’accès direct à Node ou Electron |
L’idée est simple : le renderer ne touche pas au système de fichiers. Il appelle une fonction limitée du preload, qui transmet une requête nommée au main process. Le main process valide puis exécute l’opération système.
flowchart LR
User["Action utilisateur"] --> Renderer["Renderer\nHTML / React"]
Renderer --> Preload["Preload\ncontextBridge"]
Preload --> IPC["Canal IPC\npayload validé"]
IPC --> Main["Main process\nOS et fichiers"]
Main --> Result["Résultat typé"]
Result --> Renderer
Main --> Update["Packaging / auto update"]
Starter minimal à copier
Cet exemple reste volontairement en Electron + TypeScript, sans React. Une fois la frontière claire, vous pouvez ajouter Vite ou votre stack UI.
mkdir electron-secure-notes
cd electron-secure-notes
npm init -y
npm install --save-dev electron typescript @types/node
npm pkg set main="dist/main.js"
npm pkg set scripts.dev="tsc -p tsconfig.json && electron ."
mkdir src
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node", "electron"]
},
"include": ["src/**/*.ts"]
}
Le main process garde les permissions système et valide les fichiers.
// src/main.ts
import { app, BrowserWindow, dialog, ipcMain, session } from "electron";
import fs from "node:fs/promises";
import path from "node:path";
type OpenedTextFile = {
filePath: string;
content: string;
};
type SaveTextRequest = {
filePath: string;
content: string;
};
const allowedExtensions = new Set([".txt", ".md", ".json"]);
const allowedRoots = new Set<string>();
function createWindow() {
const win = new BrowserWindow({
width: 1100,
height: 760,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
});
win.loadFile(path.join(__dirname, "index.html"));
}
function registerCsp() {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
"Content-Security-Policy": [
"default-src 'self'; script-src 'self'; style-src 'self'"
]
}
});
});
}
function isInsideRoot(root: string, target: string) {
const relative = path.relative(root, target);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
async function assertOpenedPath(filePath: string) {
const extension = path.extname(filePath).toLowerCase();
if (!allowedExtensions.has(extension)) {
throw new Error("Unsupported file type.");
}
return filePath;
}
async function assertAllowedFile(filePath: string) {
const resolved = path.resolve(filePath);
const extension = path.extname(resolved).toLowerCase();
if (!allowedExtensions.has(extension)) {
throw new Error("Only txt, md, and json files are allowed.");
}
const insideAllowedRoot = Array.from(allowedRoots).some((root) =>
isInsideRoot(root, resolved)
);
if (!insideAllowedRoot) {
throw new Error("Open the file with the dialog before saving it.");
}
return resolved;
}
ipcMain.handle("dialog:openTextFile", async (): Promise<OpenedTextFile | null> => {
const result = await dialog.showOpenDialog({
title: "Open text file",
properties: ["openFile"],
filters: [{ name: "Text", extensions: ["txt", "md", "json"] }]
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
const filePath = path.resolve(result.filePaths[0]);
const safePath = await assertOpenedPath(filePath);
allowedRoots.add(path.dirname(safePath));
return {
filePath: safePath,
content: await fs.readFile(safePath, "utf8")
};
});
ipcMain.handle("file:saveText", async (_event, payload: SaveTextRequest) => {
if (
!payload ||
typeof payload.filePath !== "string" ||
typeof payload.content !== "string" ||
payload.content.length > 1_000_000
) {
throw new Error("Invalid save request.");
}
const filePath = await assertAllowedFile(payload.filePath);
await fs.writeFile(filePath, payload.content, "utf8");
return { ok: true };
});
app.whenReady().then(() => {
registerCsp();
createWindow();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
Le preload expose des capacités métier, pas un tunnel IPC générique.
// src/preload.ts
import { contextBridge, ipcRenderer } from "electron";
type OpenedTextFile = {
filePath: string;
content: string;
};
type SaveTextRequest = {
filePath: string;
content: string;
};
contextBridge.exposeInMainWorld("desktop", {
openTextFile: () =>
ipcRenderer.invoke("dialog:openTextFile") as Promise<OpenedTextFile | null>,
saveTextFile: (payload: SaveTextRequest) => {
if (
!payload ||
typeof payload.filePath !== "string" ||
typeof payload.content !== "string"
) {
throw new Error("Invalid save payload.");
}
return ipcRenderer.invoke("file:saveText", payload) as Promise<{ ok: boolean }>;
}
});
Le renderer se limite à l’API exposée.
// src/renderer.ts
const openButton = document.querySelector<HTMLButtonElement>("#open");
const saveButton = document.querySelector<HTMLButtonElement>("#save");
const editor = document.querySelector<HTMLTextAreaElement>("#editor");
const status = document.querySelector<HTMLParagraphElement>("#status");
let currentFilePath: string | null = null;
if (!openButton || !saveButton || !editor || !status) {
throw new Error("Required UI elements are missing.");
}
openButton.addEventListener("click", async () => {
const file = await window.desktop.openTextFile();
if (!file) {
status.textContent = "Open canceled.";
return;
}
currentFilePath = file.filePath;
editor.value = file.content;
status.textContent = `Opened: ${file.filePath}`;
});
saveButton.addEventListener("click", async () => {
if (!currentFilePath) {
status.textContent = "Open a file first.";
return;
}
await window.desktop.saveTextFile({
filePath: currentFilePath,
content: editor.value
});
status.textContent = `Saved: ${currentFilePath}`;
});
Prompt concret pour Claude Code
Ajoute l’ouverture et l’enregistrement Markdown dans l’application Electron + TypeScript existante.
Contraintes:
- ne pas exposer les APIs Node.js au renderer
- conserver contextIsolation: true, nodeIntegration: false et sandbox: true
- preload expose uniquement window.desktop.openTextFile et window.desktop.saveTextFile
- ne pas exposer ipcRenderer brut ni invoke(channel, payload) générique
- autoriser seulement md/txt/json
- ne pas modifier le code d’auto update existant
Terminé quand:
- npm run typecheck passe
- les changements main/preload/renderer sont expliqués brièvement
- 3 points de revue sécurité sont listés
Trois cas d’usage
Premier cas : une app interne de notes Markdown. Elle évite d’envoyer des documents sur un serveur. Stockez seulement la liste des fichiers récents, pas le contenu confidentiel, et revalidez les chemins au démarrage.
Deuxième cas : un visualiseur de logs pour le support. Les logs peuvent être énormes et contenir des emails, tokens ou identifiants client. Lisez par pages dans le main process, envoyez seulement la tranche visible au renderer et masquez les secrets lors de la copie ou de l’export.
Troisième cas : un atelier de prompts IA en desktop. Il combine modèles locaux, presse-papiers et APIs externes. Les clés API restent dans le main process ; le renderer reçoit uniquement l’état de connexion et les résultats.
Échecs fréquents
Le premier échec est de lire n’importe quel chemin envoyé par le renderer. Même avec un seul bouton dans l’UI, considérez le renderer comme non fiable. Extension, taille et racine autorisée se vérifient dans le main process.
Le deuxième est d’exposer window.api.invoke(channel, payload). C’est compact, mais cela contourne la revue par capacité. Préférez une méthode par usage.
Le troisième est un chemin qui marche en dev et casse après packaging. Ne lisez pas src au runtime, n’écrivez pas dans asar, et placez les données modifiables sous app.getPath("userData").
Le quatrième est de découvrir la signature et les updates la semaine de sortie. Signature, feed, droits GitHub Release et comportement Windows doivent être testés tôt. Pour l’intégration pipeline, voyez le guide CI/CD.
Packaging et auto update
Avec Electron Forge, un départ minimal ressemble à ceci :
npm install --save-dev @electron-forge/cli @electron-forge/maker-squirrel @electron-forge/maker-zip @electron-forge/maker-deb @electron-forge/maker-rpm
npx electron-forge import
// forge.config.ts
import type { ForgeConfig } from "@electron-forge/shared-types";
const config: ForgeConfig = {
packagerConfig: {
asar: true,
icon: "assets/icon"
},
rebuildConfig: {},
makers: [
{
name: "@electron-forge/maker-squirrel",
config: {
name: "secure_notes"
}
},
{
name: "@electron-forge/maker-zip",
platforms: ["darwin"]
},
{
name: "@electron-forge/maker-deb",
config: {}
},
{
name: "@electron-forge/maker-rpm",
config: {}
}
]
};
export default config;
L’autoUpdater intégré vise surtout macOS et Windows. Linux s’appuie souvent sur le gestionnaire de paquets de la distribution. Sur macOS, la signature est nécessaire ; sur Windows, le comportement dépend de MSIX ou Squirrel.Windows.
// src/update.ts
import { app, autoUpdater, BrowserWindow } from "electron";
function sendStatus(win: BrowserWindow, status: string) {
win.webContents.send("update:status", status);
}
export function configureAutoUpdate(win: BrowserWindow) {
if (!app.isPackaged) {
sendStatus(win, "updates disabled in development");
return;
}
if (process.platform === "linux") {
sendStatus(win, "use the distribution package manager on Linux");
return;
}
const owner = "YOUR_GITHUB_OWNER";
const repo = "YOUR_GITHUB_REPO";
const feedUrl = `https://update.electronjs.org/${owner}/${repo}/${process.platform}-${process.arch}/${app.getVersion()}`;
autoUpdater.setFeedURL({ url: feedUrl });
autoUpdater.on("checking-for-update", () => sendStatus(win, "checking"));
autoUpdater.on("update-available", () => sendStatus(win, "available"));
autoUpdater.on("update-not-available", () => sendStatus(win, "not available"));
autoUpdater.on("update-downloaded", () => sendStatus(win, "downloaded"));
autoUpdater.on("error", (error) => sendStatus(win, `error: ${error.message}`));
setTimeout(() => {
try {
autoUpdater.checkForUpdates();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
sendStatus(win, `error: ${message}`);
}
}, 10_000);
}
Demandez aussi à Claude Code de documenter ce qui reste hors code : identité de signature, propriétaire du feed, secrets CI, droits de publication et stratégie de rollback.
Conclusion
Claude Code accélère fortement Electron, mais la qualité vient des frontières : renderer pour l’UI, preload comme pont étroit, main process pour les opérations système, packaging testé avant la sortie.
ClaudeCodeLab accompagne les équipes sur l’architecture Electron/Tauri, la revue IPC, les flux d’auto update et la formation Claude Code. Pour une aide concrète, partagez la structure actuelle, les plateformes cibles et les permissions qui vous inquiètent.
Avant d’étendre l’interface, vérifiez que npm run dev compile, que le renderer n’importe ni Electron ni Node, qu’un chemin arbitraire ne peut pas être lu, que les chemins fonctionnent après packaging et que l’auto update reste désactivé tant que signature et feed ne sont pas prêts.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.