diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index 3afd14d84..fd0bc4d29 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -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; + getNormalBounds: jest.Mock; loadFile: jest.Mock, [string]>; loadURL: jest.Mock, [string]>; + on: jest.Mock void]>; + once: jest.Mock void]>; + setMenu: jest.Mock; + show: jest.Mock; webContents: { + on: jest.Mock void]>; openDevTools: jest.Mock; + setWindowOpenHandler: jest.Mock; }; }; function createMockMainWindow(): MockMainWindow { return { + center: jest.fn(), + getNormalBounds: jest.fn().mockReturnValue({}), loadFile: jest.fn, [string]>().mockResolvedValue(), loadURL: jest.fn, [string]>().mockResolvedValue(), + on: jest.fn void]>(), + once: jest.fn void]>(), + setMenu: jest.fn(), + show: jest.fn(), webContents: { + on: jest.fn void]>(), openDevTools: jest.fn(), + setWindowOpenHandler: jest.fn(), }, }; } type AppInternals = { - mainWindow: MockMainWindow; + loadedMainWindow: MockMainWindow | null; + mainWindow: MockMainWindow | null; + mainWindowLoadPromise: Promise | null; + onReady: () => void; + rendererLoadingEnabled: boolean; loadMainWindow: () => Promise; }; @@ -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(); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index 57c70cbad..326ef26e8 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -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 | 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 { + private static async loadMainWindowContent( + mainWindow: Electron.BrowserWindow + ): Promise { // 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 { + 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 diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 23c215079..d630b351b 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -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'); }