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.
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:
ng build --configuration production --base-href ./import { ApplicationConfig } from "@angular/core";
import { provideRouter, withHashLocation } from "@angular/router";
import { routes } from "./app.routes";
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes, withHashLocation())],
};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.
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.isPackagedto udokumentowany sposób na rozróżnienie dev i prod. Bez własnych flag i bez zgadywania poNODE_ENV.- Sprawdzenie przez
path.relativeto ten sam guard, który docs Electrona pokazują przyprotocol.handle. Request z zakodowanym slashem, np.app://bundle/..%2F..%2Fsekret.txt, podecodeURIComponentpróbuje wyjść poza build i dostaje 400. - Preload dalej wskazuje na zbudowany
preload.jsobokmain.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):
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 makeGdy 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ł:
npx @electron/asar list out/my-app-linux-x64/resources/app.asar | grep renderer/index.htmlPuł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.