Wróć do listy
5 października 2026•10 min czytania

Angular + Electron: biały ekran po spakowaniu to ścieżki, nie Angular

Po electron-forge make biały ekran i 404 na main.js? To nie Angular, tylko rozjechane ścieżki: dist/browser, base href i ładowanie z protokołu plików. Szybki fix i wariant docelowy z własnym protokołem app.

AngularTypeScriptFrontendWebdev

ng serve plus loadURL("http://localhost:4200") działa od pierwszego strzału. Potem electron-forge make, odpalasz spakowaną apkę i dostajesz białe okno, a w DevTools ścianę 404 na main-.js i styles-.css. Angular jest niewinny. Rozjechały się trzy ścieżki: gdzie CLI wypluwa build, co mówi base href i skąd main ładuje index.html.

Tydzień temu był bezpieczny most IPC przez preload. W werdykcie padła zapowiedź: packaging i ścieżki assetów w bundlu produkcyjnym. Ten odcinek to właśnie to, nadal w tym samym shellu Electrona.

Problem: trzy ścieżki, które w dev nie mają znaczenia

W dev wszystko serwuje dev server Angulara z http://localhost:4200, więc root aplikacji to root serwera. Po spakowaniu serwera nie ma i wychodzą trzy miny.

1. Build nie leży tam, gdzie myślisz. Builder application (domyślny dla nowych projektów od Angulara 17) wrzuca bundle do dist/nazwa-projektu/browser, a nie do dist/nazwa-projektu jak stary builder browser. Docs mówią o tym wprost w sekcji Output location changes: Angular application build system. Skrypt, który kopiuje cały dist/nazwa-projektu do paczki Electrona, zgarnia folder z podfolderem browser w środku i loadFile trafia w próżnię.

2. Base href kontra protokół plików. Domyślny index.html z CLI ma base href ustawione na /. Pod HTTP to root serwera. Pod file:// to root systemu plików (na Windowsie root dysku), więc main-.js jest szukany zupełnie gdzie indziej niż obok index.html.

3. Routing na History API. PathLocationStrategy to domyślna strategia routera Angulara (LocationStrategy w docs). Pod file:// przeładowanie okna na /settings oznacza prośbę o plik /settings, którego nie ma. W przeglądarce ratuje to fallback na serwerze. W Electronie serwera nie ma, dopóki sam go nie zrobisz.

Wariant szybki: file://, względny base href i hash routing

Najkrótsza droga do działającej paczki:

# build ze względnym base href; wynik w dist/my-app/browser (my-app = nazwa projektu z angular.json)
ng build --configuration production --base-href ./
// src/app/app.config.ts (wariant file://)
import { ApplicationConfig } from "@angular/core";
import { provideRouter, withHashLocation } from "@angular/router";
import { routes } from "./app.routes";

export const appConfig: ApplicationConfig = {
  providers: [provideRouter(routes, withHashLocation())],
};
// electron/main.ts (fragment, wariant file://)
void win.loadFile(path.join(__dirname, "renderer", "index.html"));

Działa, ale kosztuje. Po pierwsze URL-e z #/settings. Po drugie file:// to dokładnie to, czego checklista bezpieczeństwa Electrona każe unikać: punkt 18 w Security. Strona z file:// ma dostęp do każdego pliku na maszynie, więc XSS w UI może czytać pliki użytkownika. Do tego twarde ścieżki absolutne w szablonach, w stylu /logo.svg, ignorują base href i dalej celują w root dysku.

Wariant docelowy: własny protokół app://

Electron rekomenduje serwowanie lokalnych stron przez custom protocol zamiast file://. Rejestrujesz schemat jako standard i secure, a w protocol.handle sam decydujesz, które pliki wychodzą na zewnątrz. API: protocol. Od Electrona 25 protocol.handle zastępuje stare metody registerProtocol.

Zysk: base href zostaje domyślne /, bo root to teraz app://bundle/. Router zostaje na History API, a fallback na index.html robisz w handlerze w kilku linijkach.

// electron/main.ts (fragment)
import { app, BrowserWindow, net, protocol } from "electron";
import path from "node:path";
import { pathToFileURL } from "node:url";

// electron-dist/renderer = kopia dist/my-app/browser
const RENDERER_DIR = path.join(__dirname, "renderer");

// przed app ready i tylko raz
protocol.registerSchemesAsPrivileged([
  { scheme: "app", privileges: { standard: true, secure: true, supportFetchAPI: true } },
]);

// zwraca ścieżkę w RENDERER_DIR albo null, gdy request próbuje wyjść poza katalog
function resolveInsideRenderer(pathname: string) {
  let decoded: string;
  try {
    decoded = decodeURIComponent(pathname);
  } catch {
    return null;
  }
  const target = path.join(RENDERER_DIR, decoded);
  const rel = path.relative(RENDERER_DIR, target);
  if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
  return target;
}

function registerAppProtocol(): void {
  protocol.handle("app", (request) => {
    const { pathname } = new URL(request.url);
    // trasy Angulara (/settings, /projects/42) nie mają rozszerzenia, więc dostają index.html
    const wanted = path.extname(pathname) === "" ? "/index.html" : pathname;
    const filePath = resolveInsideRenderer(wanted);
    if (!filePath) return new Response("Bad request", { status: 400 });
    return net.fetch(pathToFileURL(filePath).toString());
  });
}

function createWindow(): void {
  const win = new BrowserWindow({
    webPreferences: { preload: path.join(__dirname, "preload.js") },
  });
  // app.isPackaged: false przy electron . i forge start, true w spakowanej apce
  void win.loadURL(app.isPackaged ? "app://bundle/" : "http://localhost:4200");
}

app.whenReady().then(() => {
  registerAppProtocol();
  createWindow();
});

Co tu się dzieje:


  • app.isPackaged to udokumentowany sposób na rozróżnienie dev i prod. Bez własnych flag i bez zgadywania po NODE_ENV.

  • Sprawdzenie przez path.relative to ten sam guard, który docs Electrona pokazują przy protocol.handle. Request z zakodowanym slashem, np. app://bundle/..%2F..%2Fsekret.txt, po decodeURIComponent próbuje wyjść poza build i dostaje 400.

  • Preload dalej wskazuje na zbudowany preload.js obok main.js, tak jak w poprzednim odcinku.

  • Build i paczka: skleić ścieżki w jednym miejscu


Electron sam niczego nie pakuje. Oficjalny tutorial prowadzi przez Electron Forge (electron-forge import, potem make): Packaging Your Application. electron-builder też da radę, zasada jest ta sama. Do paczki ma trafić skompilowany main, preload i zawartość folderu browser.

Przykładowy układ (to przykład, nazwy folderów są umowne):

# 1. main.ts + preload.ts -> electron-dist/main.js i electron-dist/preload.js
npx tsc -p electron/tsconfig.json

# 2. Angular: wynik w dist/my-app/browser
ng build --configuration production

# 3. kopiujemy ZAWARTOŚĆ browser jako electron-dist/renderer, nie cały dist/my-app
rm -rf electron-dist/renderer
cp -r dist/my-app/browser electron-dist/renderer

# 4. w package.json: "main": "electron-dist/main.js", potem
npm run make

Gdy coś się nie zgadza, nie zgaduj. Jeśli w configu Forge masz asar: true, spakowany kod siedzi w archiwum resources/app.asar, które Electron traktuje jak wirtualny katalog. Listing od razu pokaże, czy renderer/index.html w ogóle tam trafił:

# ścieżka do out/ zależy od platformy i nazwy apki
npx @electron/asar list out/my-app-linux-x64/resources/app.asar | grep renderer/index.html

Pułapki i trade-offy

Heurystyka „brak rozszerzenia = trasa”. Jest tania i nie dotyka dysku, ale trasa z kropką w ścieżce (np. /files/raport.pdf jako route Angulara) poleci po plik i dostanie błąd. Wtedy zamiast extname sprawdzaj, czy plik istnieje, a fallback na index.html rób dopiero, gdy go nie ma.

Własna sesja. Protokół rejestruje się na domyślnej sesji. Jeśli okno ma partition w webPreferences, handler trzeba zarejestrować na session.fromPartition(...), inaczej app:// po prostu nie odpowie. To też jest w docs protocol.

registerSchemesAsPrivileged tylko przed ready. I tylko jedno wywołanie. Bez rejestracji jako standard względne URL-e się nie rozwiążą, a localStorage, IndexedDB i cookies dla tego schematu są domyślnie wyłączone.

CSP. Pod file:// zostaje meta tag, bo nagłówka HTTP nie ma skąd wziąć (punkt 7 checklisty). Z własnym protokołem odpowiedź należy do ciebie, więc nagłówek Content-Security-Policy możesz dołożyć w handlerze.

Walidacja IPC. Stały origin app://bundle w prod upraszcza punkt 17 checklisty (walidacja sender w każdym ipcMain.handle): masz jedną konkretną wartość do porównania.

ASAR jest tylko do odczytu. Nie zapisuj niczego obok __dirname w prod. Dane użytkownika idą do app.getPath("userData").

Werdykt

Biały ekran po spakowaniu Angulara w Electronie to prawie zawsze ścieżki, nie framework. Szybki fix (--base-href ./, hash routing, loadFile) wystarczy na demo. Do czegoś, co trafi do ludzi, serwuj build przez app:// z guardem na ścieżki i fallbackiem na index.html. Zostajesz przy domyślnym base href i normalnych URL-ach, a przy okazji robisz dokładnie to, co zaleca checklista Electrona.

Po stronie desktopu jest już most IPC i działająca paczka. Kolejny sensowny odcinek tej linii to mobile: ten sam Angular przez Ionic i Capacitor.