Custom Protocols in Electron haben zwei Anwendungsfälle: erstens interne Schemes wie app:// als sichere Alternative zu file:// für lokale Inhalte, zweitens externe Schemes wie myapp://login für Deep-Links aus dem Browser oder anderen Apps. Beide nutzen das protocol-Modul.
Internes Protocol — protocol.handle
Statt file://-Pfade direkt zu laden, ist es heute Standard, ein eigenes app:// zu registrieren — sicherer (CSP einfacher) und sauberer.
import { app, protocol, net } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// VOR app.ready: Privilegien deklarieren
protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { secure: true, standard: true, supportFetchAPI: true } }
]);
app.whenReady().then(() => {
// Handler registrieren
protocol.handle('app', (request) => {
const url = new URL(request.url);
const filePath = path.join(__dirname, 'dist', url.pathname);
return net.fetch('file://' + filePath);
});
const win = new BrowserWindow({ /* ... */ });
win.loadURL('app://./index.html');
});Was passiert:
registerSchemesAsPrivilegedmuss vorapp.readyaufgerufen werden — sagt Chromium, dassapp://als sicheres, Standard-fähiges Schema gelten sollprotocol.handle('app', handler)registriert den Request-Handler- Der Handler bekommt einen Web-Standard-
Requestund gibt eineResponsezurück (oder ein Promise darauf) - Window lädt
app://./index.htmlstattfile://...
net.fetch ist Electrons spezielle Fetch-Funktion, die auch file://-URLs versteht.
Warum nicht file://?
file:// hat in Web-Kontexten viele Eigenheiten:
- CORS verhält sich seltsam
- CSP kann nicht streng konfiguriert werden
- Service-Worker funktionieren nicht
- Manche modernen Web-APIs verweigern auf
file://
Mit app:// als „secure" und „standard" registriert hast du einen vollwertigen Origin — alle Web-Features funktionieren wie über HTTPS.
Custom Protocol mit dynamischen Daten
Du kannst auch dynamische Inhalte liefern — z. B. Bilder aus einer DB:
protocol.handle('media', async (request) => {
const url = new URL(request.url);
const id = url.pathname.replace('/', '');
const media = await db.getMedia(id);
if (!media) {
return new Response('Not found', { status: 404 });
}
return new Response(media.buffer, {
status: 200,
headers: { 'Content-Type': media.mimeType }
});
});
// im Renderer: <img src="media://./image-42">Damit kann der Renderer Bilder per URL anfordern, ohne dass die ganzen Bytes per IPC laufen müssen — Browser-Standard, mit Caching, Range-Requests etc.
Externes Protocol — Deep Links
Damit myapp://login?code=abc aus dem Browser deine App startet:
if (process.defaultApp) {
// Während Dev: argv[1] ist der App-Pfad
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient('myapp', process.execPath, [path.resolve(process.argv[1])]);
}
} else {
app.setAsDefaultProtocolClient('myapp');
}
// macOS: Deep-Link kommt als open-url Event
app.on('open-url', (event, url) => {
event.preventDefault();
handleDeepLink(url);
});
// Windows/Linux: Deep-Link kommt via second-instance args
app.on('second-instance', (_event, argv) => {
const url = argv.find(a => a.startsWith('myapp://'));
if (url) handleDeepLink(url);
});
function handleDeepLink(url) {
const parsed = new URL(url);
// myapp://login?code=abc
if (parsed.host === 'login') {
const code = parsed.searchParams.get('code');
completeLogin(code);
}
}Klassischer Use-Case: OAuth-Callback. User klickt „Login mit GitHub", Browser öffnet sich, GitHub redirect zurück zu myapp://login?code=xxx, OS startet deine App mit dem Code.
Plattform-Eigenheiten
| Plattform | Wo wird's registriert |
|---|---|
| macOS | Info.plist CFBundleURLTypes (electron-builder generiert) |
| Windows | Registry (per setAsDefaultProtocolClient zur Laufzeit) |
| Linux | .desktop-Datei mit MimeType=x-scheme-handler/myapp |
In electron-builder.yml:
protocols:
- name: My App Protocol
schemes:
- myappDamit wird's bei der Distribution automatisch konfiguriert.
Single-Instance + Deep Link
Pattern: bei zweitem Start nicht eine zweite Instanz starten, sondern den Link an die laufende Instanz weiterreichen:
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', (_event, argv) => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
const url = argv.find(a => a.startsWith('myapp://'));
if (url) handleDeepLink(url);
});
app.whenReady().then(createWindow);
}Ohne diesen Lock startet jeder Deep-Link eine neue Instanz — das nervt User und macht Login-Flows kaputt.
Besonderheiten
app:// statt file:// für lokale Inhalte.
Modern empfohlen — vollwertiger Origin, CSP funktioniert sauber, Service-Worker und moderne Web-APIs sind verfügbar. Die alte file://-Variante hat zu viele Browser-Eigenheiten.
registerSchemesAsPrivileged VOR app.ready.
Sonst werden die Privilegien nicht gesetzt. Das ist eine der wenigen Funktionen, die VOR app.ready stehen muss — direkt nach dem ersten Setup-Code in main.js.
Plattform-Trennung bei Deep-Links: open-url vs. second-instance.
macOS: Event open-url. Windows/Linux: kommt via Command-Line-Argumente, also second-instance mit argv parsen. Beides parallel implementieren — nicht eines weglassen.
Single-Instance-Lock ist Pflicht für saubere Deep-Links.
Ohne Lock startet jeder Klick eine neue App-Instanz. Mit Lock: bestehende Instanz fokussiert, Deep-Link wird dort verarbeitet.
Beim Distribuieren: electron-builder konfiguriert das Protocol.
macOS Info.plist und Linux .desktop-Datei werden bei Distribution gebraucht — manuell zu pflegen ist fehleranfällig. electron-builder hat dafür ein eigenes protocols-Feld.
Custom Protocols auch für Sicherheit nutzen.
Wer im Renderer Bilder aus dem Filesystem zeigt: app:// oder eigenes media://-Schema, statt file://-URLs. Verhindert Path-Traversal-Angriffe und ist mit CSP kompatibel.