Wróć do listy
28 września 2026•10 min czytania

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.

AngularTypeScriptFrontendWebdev

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.

// main.ts - antywzorzec
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

// electron/main.ts (fragment)
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ł

// electron/preload.ts
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

// src/electron-api.d.ts
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

// src/app/desktop-bridge.service.ts
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();
  }
}
// src/app/AppDesktopStatusComponent.ts
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);
  }
}
// AppDesktopStatusComponent.html (templateUrl)
// 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.