Das contextBridge-IPC-Pattern ist die idiomatische Art, in modernen Electron-Apps Renderer-zu-Main-Kommunikation zu strukturieren: keine offenen IPC-Channels im Web-Code, kein direktes ipcRenderer. Stattdessen eine getypte API, die im Preload definiert und im Renderer wie eine Library verwendet wird.
Die drei Schichten
| Schicht | Was passiert |
|---|---|
| Renderer | Ruft window.api.foo() wie eine normale Library |
| Preload | Bridged über contextBridge.exposeInMainWorld und ipcRenderer.invoke |
| Main | Empfängt mit ipcMain.handle, validiert, führt aus |
Damit:
- Der Renderer kennt keine IPC-Channels — die Implementierung ist gekapselt
- Die gesamte Validierung und Native-Logik landet im Main
- Beim Refactoring (Channel umbenennen) ändert sich nur Preload, der Renderer bleibt gleich
Vollständiges Pattern
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
import fs from 'node:fs/promises';
import path from 'node:path';
function registerIpcHandlers() {
// app
ipcMain.handle('app:get-version', () => app.getVersion());
// dialog
ipcMain.handle('dialog:open-file', async (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
const result = await dialog.showOpenDialog(win, { properties: ['openFile'] });
return result.canceled ? null : result.filePaths[0];
});
// files
ipcMain.handle('files:read', async (_event, filePath) => {
// VALIDIERUNG
const userData = app.getPath('userData');
const resolved = path.resolve(filePath);
if (!resolved.startsWith(userData)) {
throw new Error('Path outside allowed area');
}
return fs.readFile(resolved, 'utf-8');
});
}
app.whenReady().then(() => {
registerIpcHandlers();
// Window erzeugen
});import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('api', {
app: {
getVersion: () => ipcRenderer.invoke('app:get-version')
},
dialog: {
openFile: () => ipcRenderer.invoke('dialog:open-file')
},
files: {
read: (path) => ipcRenderer.invoke('files:read', path)
}
});async function loadConfig() {
const path = await window.api.dialog.openFile();
if (!path) return;
try {
const content = await window.api.files.read(path);
document.getElementById('config').textContent = content;
} catch (err) {
alert('Fehler: ' + err.message);
}
}Der Renderer-Code liest sich wie gegen eine lokale Library. IPC ist komplett unsichtbar.
TypeScript für die API
import { contextBridge, ipcRenderer } from 'electron';
const api = {
app: {
getVersion: (): Promise<string> => ipcRenderer.invoke('app:get-version')
},
files: {
read: (path: string): Promise<string> => ipcRenderer.invoke('files:read', path),
write: (path: string, content: string): Promise<void> =>
ipcRenderer.invoke('files:write', path, content)
}
} as const;
contextBridge.exposeInMainWorld('api', api);
export type Api = typeof api;import type { Api } from '../preload/preload';
declare global {
interface Window {
api: Api;
}
}
export {};Damit hat Renderer-TypeScript Auto-Complete für window.api.files.read('...') mit korrekten Typen.
Error-Handling
ipcMain.handle('files:read', async (_event, path) => {
try {
return await fs.readFile(path, 'utf-8');
} catch (err) {
if (err.code === 'ENOENT') {
throw new Error('Datei nicht gefunden');
}
if (err.code === 'EACCES') {
throw new Error('Keine Leserechte');
}
throw err;
}
});try {
const content = await window.api.files.read(path);
} catch (err) {
// err.message kommt direkt aus dem Main-Throw
showToast('Fehler: ' + err.message);
}Promise-Rejection im Main → Promise-Rejection im Renderer. Saubere Fehler-Propagation.
Wann Push-Events vom Main
Manche Daten initiiert der Main, nicht der Renderer — Theme-System-Wechsel, Download-Progress, Live-Updates. Dafür kombiniert man invoke/handle mit Subscriptions:
contextBridge.exposeInMainWorld('api', {
theme: {
onChange: (callback) => {
const listener = (_event, isDark) => callback(isDark);
ipcRenderer.on('theme:changed', listener);
return () => ipcRenderer.removeListener('theme:changed', listener);
}
}
});nativeTheme.on('updated', () => {
BrowserWindow.getAllWindows().forEach(win => {
win.webContents.send('theme:changed', nativeTheme.shouldUseDarkColors);
});
});const unsubscribe = window.api.theme.onChange((isDark) => {
document.body.classList.toggle('dark', isDark);
});
// beim Cleanup
unsubscribe();Besonderheiten
Renderer kennt KEINE IPC-Channels.
Der ganze Sinn: nur Preload kennt die Strings. Im Renderer rufst du window.api.files.read(...) — wenn du den Channel umbenennst, ändert sich nur eine Zeile im Preload, der Renderer-Code bleibt unberührt.
Validierung gehört in den Main, NICHT ins Preload.
Preload ist eine dünne Bridge ohne Logik. Wenn du dort validierst, denkt eine kompromittierte Web-Seite ggf. „Bridge umgehen" — und der Main hätte keinen Schutz. Pragmatisch: alle Sicherheitschecks im Main-Handler.
Namespacing macht die API verständlich.
window.api.files.read(...) liest sich wie eine echte Library. Ohne Namespacing wird's bei wachsendem API unübersichtlich (window.api.readFile, window.api.openDialog, window.api.getVersion...).
Subscription mit Cleanup-Funktion zurückgeben.
Bei ipcRenderer.on(...) muss auch ein Weg zum Abmelden da sein. Pattern: return () => ipcRenderer.removeListener(...). Sonst leaken Listener bei jedem Component-Re-Mount.
handle zentralisieren in einer Funktion.
registerIpcHandlers() als eine Funktion — bei wachsender App lohnt es sich, das in eigene Module zu splitten (ipc/files.ts, ipc/dialog.ts) und in main.js nur die Setup-Funktion aufzurufen.
Bei großen Apps: TypeScript-Channels strukturieren.
Ein Object-Type wie type IpcMap = { 'files:read': (path: string) => Promise<string> } plus generische invoke<K extends keyof IpcMap>-Wrapper sorgt für Typensicherheit auf beiden Seiten. Aufwand am Anfang, zahlt sich bei größeren Apps aus.