Angular + Electron: bezpieczny most IPC przez preload zamiast nodeIntegration
nodeIntegration w rendererze to klasyczny footgun. Zostaw Node w main/preload, a Angularowi daj wąski, typowany most przez contextBridge.
Angular w Chromiumie to jedno. Angular w Electronie to to samo UI plus Native API: pliki, dialogi, tray, auto-update. Most między rendererem a mainem da się zbudować na dwa sposoby: włączyć nodeIntegration i importować Electron w komponencie, albo zostawić Node poza Angularowym światem i wystawić wąski most przez preload. Oficjalna rekomendacja Electrona jest jasna: drugi wariant. Przewodniki: Context Isolation oraz Inter-Process Communication.
Po Angular Signals i effect() naturalny kolejny krok w ekosystemie to desktop: ten sam Angular w shellu Electrona, bez skoku w Next.js.
Problem: nodeIntegration: true w rendererze
Domyślnie Electron trzyma renderer w ryzach: nodeIntegration jest wyłączone (od Electron 5), contextIsolation włączone (od Electron 12), a sandbox renderera włączony (od Electron 20). Checklista bezpieczeństwa: Security. webPreferences w docs: WebPreferences.
Włączenie Node w rendererze wygląda wygodnie, bo „da się zrobić require('fs') w komponencie”. Koszt: XSS albo złośliwy skrypt w UI dostaje dostęp do Node na maszynie użytkownika. Przy nodeIntegration: true sandbox też spada. To nie jest shortcut pod „szybszy prototyp desktopu”. To zamiana webowego błędu w RCE.
const win = new BrowserWindow({
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
});Angular nie potrzebuje Node w rendererze. Potrzebuje kontraktu: „otwórz dialog”, „zapisz preferencje”, „podaj wersję aplikacji”. Resztę załatwia main.
Most: preload + contextBridge + typowany serwis Angular
Wzorzec z docs Electrona: main rejestruje handler, preload wystawia jedną metodę na kanał, renderer woła window.electronAPI…. Nie wystawiaj surowego ipcRenderer ani send / invoke bez filtrów argumentów. Docs wprost mówią, że to pozwala wysłać dowolny kanał IPC.
1. Main: ipcMain.handle
import { app, BrowserWindow, dialog, ipcMain } from "electron";
import path from "node:path";
// returns string | null
async function handleOpenFile() {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ["openFile"],
});
if (canceled || filePaths.length === 0) return null;
return filePaths[0] ?? null;
}
function createWindow(): void {
const win = new BrowserWindow({
webPreferences: {
// domyślne: contextIsolation true, nodeIntegration false, sandbox true
preload: path.join(__dirname, "preload.js"),
},
});
// w dev: loadURL(ng serve); w prod: loadFile(dist/…)
void win.loadURL("http://localhost:4200");
}
app.whenReady().then(() => {
ipcMain.handle("dialog:openFile", handleOpenFile);
createWindow();
});Prefiks dialog: to tylko namespace czytelności. Kanał jest stringiem, który sam definiujesz.
2. Preload: jedna metoda na kanał
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("electronAPI", {
// returns Promise of string | null
openFile: () => ipcRenderer.invoke("dialog:openFile"),
});Sandboxowany preload (domyślnie od Electron 20) ładuje się jak ograniczony CommonJS: zwykle jeden zbundlowany plik .js, bez ESM import na żywo w runtime. Jeśli po starcie window.electronAPI jest undefined, sprawdź ścieżkę preload i format bundla preloadu, zanim zaczniesz debugować Angulara.
3. Typy dla window
export interface ElectronAPI {
openFile: () => Promise / string | null /;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
export {};To ten sam wzorzec, który Electron pokazuje w Context Isolation: Usage with TypeScript.
4. Serwis Angular + cienki komponent
import { Injectable } from "@angular/core";
@Injectable({ providedIn: "root" })
export class DesktopBridgeService {
openFile(): Promise / string | null / {
if (typeof window === "undefined" || !window.electronAPI) {
// przeglądarka / ng serve bez Electrona
return Promise.resolve(null);
}
return window.electronAPI.openFile();
}
}import { Component, signal } from "@angular/core";
import { DesktopBridgeService } from "./desktop-bridge.service";
@Component({
selector: "app-desktop-status",
templateUrl: "./AppDesktopStatusComponent.html",
})
export class AppDesktopStatusComponent {
readonly lastPath = signal(null as string | null);
constructor(private readonly desktop: DesktopBridgeService) {}
async pickFile(): Promise / void / {
const path = await this.desktop.openFile();
this.lastPath.set(path);
}
}// button type="button" with (click)="pickFile()" label: Otwórz plik
// @if (lastPath(); as path) { show: Wybrano: {{ path }} }Komponent nie wie nic o Electronie. Wie o serwisie. W przeglądarce most jest nieobecny i dostajesz null zamiast wybuchu przy ng serve.
Dla dwukierunkowego IPC trzymaj się ipcRenderer.invoke + ipcMain.handle. sendSync blokuje renderer do czasu odpowiedzi. Docs IPC odradzają to ze względów wydajności.
Pułapki i trade-offy
Surowe IPC przez most. exposeInMainWorld({ send: ipcRenderer.send }) albo przekazanie całego ipcRenderer to antywzorzec. Od nowszych Electronów pełny ipcRenderer przez contextBridge i tak wraca jako pusty obiekt; nawet gdyby przeszedł, renderer mógłby strzelać w dowolny kanał.
Listenery main → renderer. Nie podawaj callbacka prosto do ipcRenderer.on. Pierwszy argument to event z sender i dostępem do IPC. Owijaj: (_event, value) => callback(value). Checklist punkt 20: Do not expose Electron APIs to untrusted web content.
Obiekty przez most. contextBridge kopiuje wartości; prototypów i symboli nie przeniesiesz. Jeśli preload przyjmuje obiekty z renderera, waliduj własne pola (Object.hasOwn) albo trzymaj się prymitywów. Lepiej wąski kontrakt niż „wrzuć any i jakoś będzie”.
Dev vs prod ścieżki. W dev ładujesz ng serve; w prod loadFile / custom protocol do zbudowanego Angulara. Preload musi wskazywać na absolutną ścieżkę do zbudowanego pliku, nie na źródło TypeScript.
Kiedy nodeIntegration „musi” zostać. Prawie nigdy w UI. Jeśli native moduł Node jest niezbędny, trzymaj go w main (albo w dedykowanym utility process) i wołaj przez IPC. Wyjątki istnieją, ale to świadomy wyjątek bezpieczeństwa, nie default pod scaffolding.
Werdykt
Angular w Electronie nie wymaga Node w rendererze. Wymaga kontraktu. Zostaw contextIsolation i sandbox włączone, wystaw jedną metodę na kanał przez preload, otypuj window.electronAPI i zawijaj to w serwisie Angulara. nodeIntegration: true wygląda na skrót. W praktyce jest to skrót do złego modelu zagrożeń.
Jeśli ten most już działa, kolejny sensowny odcinek w tej samej linii to packaging (electron-builder / Forge) i ścieżki assetów Angulara w bundlu produkcyjnym. Albo Ionic/Capacitor, jeśli celujesz w mobile zamiast desktopu.