fix(electron): load renderer after ipc bootstrap

This commit is contained in:
4gray committed 2026-06-28 18:02:47 +02:00
1 parent f7eb34ddba
commit f1fc5ec7b2
3 files changed
+112 -11

No files matched your search

+57 -2
View File
@@ -37,28 +37,49 @@ import {
isTrustedRendererNavigationUrl,
} from './app';
import App from './app';
import { app as electronApp } from 'electron';
import { app as electronApp, BrowserWindow, screen } from 'electron';
import { store } from './services/store.service';
type MockMainWindow = {
center: jest.Mock<void, []>;
getNormalBounds: jest.Mock<object, []>;
loadFile: jest.Mock<Promise<void>, [string]>;
loadURL: jest.Mock<Promise<void>, [string]>;
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
once: jest.Mock<void, [string, (...args: unknown[]) => void]>;
setMenu: jest.Mock<void, [unknown]>;
show: jest.Mock<void, []>;
webContents: {
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
openDevTools: jest.Mock<void, []>;
setWindowOpenHandler: jest.Mock<void, [unknown]>;
};
};
function createMockMainWindow(): MockMainWindow {
return {
center: jest.fn<void, []>(),
getNormalBounds: jest.fn<object, []>().mockReturnValue({}),
loadFile: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
loadURL: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
once: jest.fn<void, [string, (...args: unknown[]) => void]>(),
setMenu: jest.fn<void, [unknown]>(),
show: jest.fn<void, []>(),
webContents: {
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
openDevTools: jest.fn<void, []>(),
setWindowOpenHandler: jest.fn<void, [unknown]>(),
},
};
}
type AppInternals = {
mainWindow: MockMainWindow;
loadedMainWindow: MockMainWindow | null;
mainWindow: MockMainWindow | null;
mainWindowLoadPromise: Promise<void> | null;
onReady: () => void;
rendererLoadingEnabled: boolean;
loadMainWindow: () => Promise<void>;
};
@@ -70,7 +91,16 @@ describe('Electron app security helpers', () => {
beforeEach(() => {
jest.clearAllMocks();
delete process.env.ELECTRON_IS_DEV;
const appInternals = getAppInternals();
appInternals.loadedMainWindow = null;
appInternals.mainWindow = null;
appInternals.mainWindowLoadPromise = null;
appInternals.rendererLoadingEnabled = false;
(electronApp as unknown as { isPackaged: boolean }).isPackaged = false;
(screen.getPrimaryDisplay as jest.Mock).mockReturnValue({
workAreaSize: { height: 720, width: 1280 },
});
(store.get as jest.Mock).mockReturnValue(undefined);
});
it('creates an explicitly hardened BrowserWindow webPreferences object', () => {
@@ -175,6 +205,31 @@ describe('Electron app security helpers', () => {
warnSpy.mockRestore();
});
it('defers renderer loading until the main process explicitly enables it', async () => {
const appInternals = getAppInternals();
const mainWindow = createMockMainWindow();
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
appInternals.onReady();
expect(BrowserWindow).toHaveBeenCalledWith(
expect.objectContaining({
show: false,
webPreferences: expect.objectContaining({
preload: expect.stringContaining('main.preload.js'),
}),
})
);
expect(mainWindow.loadURL).not.toHaveBeenCalled();
expect(mainWindow.loadFile).not.toHaveBeenCalled();
await appInternals.loadMainWindow();
expect(mainWindow.loadURL).toHaveBeenCalledWith(
'http://localhost:4200'
);
});
it('clears only service worker registrations and cache storage', async () => {
await clearElectronServiceWorkerStorage();
+49 -8
View File
@@ -198,9 +198,12 @@ function attachWindowTrace(mainWindow: Electron.BrowserWindow): void {
export default class App {
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
static mainWindow: Electron.BrowserWindow;
static mainWindow: Electron.BrowserWindow | null = null;
static application: Electron.App;
static BrowserWindow;
private static loadedMainWindow: Electron.BrowserWindow | null = null;
private static mainWindowLoadPromise: Promise<void> | null = null;
private static rendererLoadingEnabled = false;
private static shouldOpenDevTools() {
return process.env.ELECTRON_OPEN_DEVTOOLS === '1';
@@ -230,15 +233,21 @@ export default class App {
App.mainWindow = null;
}
private static startMainWindowLoad(): void {
void App.loadMainWindow().catch((error) => {
console.error('Failed to load main window:', error);
});
}
private static onReady() {
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
if (rendererAppName) {
App.initMainWindow();
void App.loadMainWindow().catch((error) => {
console.error('Failed to load main window:', error);
});
if (App.rendererLoadingEnabled) {
App.startMainWindowLoad();
}
}
}
@@ -248,6 +257,9 @@ export default class App {
if (App.mainWindow === null) {
App.onReady();
}
if (App.rendererLoadingEnabled) {
App.startMainWindowLoad();
}
}
private static handleRendererNavigation(
@@ -363,6 +375,8 @@ export default class App {
// in an array if your app supports multi windows, this is the time
// when you should delete the corresponding element.
App.mainWindow = null;
App.loadedMainWindow = null;
App.mainWindowLoadPromise = null;
});
App.mainWindow.on('close', () => {
@@ -409,22 +423,49 @@ export default class App {
});
}
private static async loadMainWindow(): Promise<void> {
private static async loadMainWindowContent(
mainWindow: Electron.BrowserWindow
): Promise<void> {
// load the index.html of the app.
if (App.isDevelopmentMode()) {
const loadPromise = App.mainWindow.loadURL(
const loadPromise = mainWindow.loadURL(
`http://localhost:${rendererAppPort}`
);
if (App.shouldOpenDevTools()) {
App.mainWindow.webContents.openDevTools();
mainWindow.webContents.openDevTools();
}
await loadPromise;
} else {
await clearElectronServiceWorkerStorage();
await App.mainWindow.loadFile(getPackagedRendererIndexPath());
await mainWindow.loadFile(getPackagedRendererIndexPath());
}
}
static async loadMainWindow(): Promise<void> {
App.rendererLoadingEnabled = true;
if (!rendererAppName || !App.mainWindow) {
return;
}
if (App.loadedMainWindow === App.mainWindow) {
return;
}
if (!App.mainWindowLoadPromise) {
const mainWindow = App.mainWindow;
App.mainWindowLoadPromise = App.loadMainWindowContent(mainWindow)
.then(() => {
App.loadedMainWindow = mainWindow;
})
.finally(() => {
App.mainWindowLoadPromise = null;
});
}
await App.mainWindowLoadPromise;
}
static main(app: Electron.App, browserWindow: typeof BrowserWindow) {
// we pass the Electron.App object and the
// Electron.BrowserWindow into this function
+6 -1
View File
@@ -112,7 +112,6 @@ export default class Main {
DatabaseEvents.bootstrapDatabaseEvents();
EpgEvents.bootstrapEpgEvents();
RemoteControlEvents.bootstrapRemoteControlEvents();
void appUpdateService.checkForUpdatesOnStartup();
// Set main window for downloads and reset stale downloads
if (App.mainWindow) {
@@ -124,6 +123,12 @@ export default class Main {
trace('startup', 'reset-stale-downloads:done');
}
// Load the renderer only after IPC handlers are registered. On slower
// Linux CI hosts the renderer can otherwise invoke Electron bridge IPC
// before the main process has installed handlers.
await App.loadMainWindow();
void appUpdateService.checkForUpdatesOnStartup();
if (isStartupTraceEnabled()) {
trace('startup', 'bootstrap-events:done');
}