diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index bfd5a24a2..1f6c02b97 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -57,6 +57,7 @@ type MockMainWindow = { getNormalBounds: jest.Mock; // Read once by attachWindowStateEvents to seed the tracked window // state; only reached off macOS, where the custom controls exist. + isDestroyed: jest.Mock; isFullScreen: jest.Mock; isMaximized: jest.Mock; loadFile: jest.Mock, [string]>; @@ -82,6 +83,7 @@ function createMockMainWindow(): MockMainWindow { loadURL: jest.fn, [string]>().mockResolvedValue(), on: jest.fn void]>(), once: jest.fn void]>(), + isDestroyed: jest.fn().mockReturnValue(false), setMenu: jest.fn(), show: jest.fn(), webContents: { @@ -95,6 +97,7 @@ function createMockMainWindow(): MockMainWindow { type AppInternals = { loadedMainWindow: MockMainWindow | null; mainWindow: MockMainWindow | null; + mainWindowListeners: Array<(mainWindow: MockMainWindow) => void>; mainWindowLoadPromise: Promise | null; onReady: () => void; rendererLoadingEnabled: boolean; @@ -118,6 +121,7 @@ describe('Electron app security helpers', () => { appInternals.mainWindow = null; appInternals.mainWindowLoadPromise = null; appInternals.rendererLoadingEnabled = false; + appInternals.mainWindowListeners.length = 0; (electronApp as unknown as { isPackaged: boolean }).isPackaged = false; (screen.getPrimaryDisplay as jest.Mock).mockReturnValue({ workAreaSize: { height: 720, width: 1280 }, @@ -315,6 +319,44 @@ describe('Electron app security helpers', () => { expect(mainWindow.loadFile).not.toHaveBeenCalled(); }); + it('runs a main-window listener immediately when a window already exists', () => { + const mainWindow = createMockMainWindow(); + (BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow); + (electronApp.isReady as jest.Mock).mockReturnValue(true); + App.main(electronApp, BrowserWindow); + const listener = jest.fn(); + + App.onMainWindowCreated( + listener as unknown as (mainWindow: Electron.BrowserWindow) => void + ); + + expect(listener).toHaveBeenCalledWith(mainWindow); + }); + + // macOS keeps the process alive without windows, so anything caching the + // window (download-broadcast) must be handed the rebuilt one. + it('re-runs main-window listeners when the window is rebuilt', () => { + const firstWindow = createMockMainWindow(); + (BrowserWindow as unknown as jest.Mock).mockReturnValue(firstWindow); + (electronApp.isReady as jest.Mock).mockReturnValue(true); + App.main(electronApp, BrowserWindow); + const listener = jest.fn(); + App.onMainWindowCreated( + listener as unknown as (mainWindow: Electron.BrowserWindow) => void + ); + listener.mockClear(); + + // Simulate the macOS 'closed' handler clearing the reference. + getAppInternals().mainWindow = null; + const secondWindow = createMockMainWindow(); + (BrowserWindow as unknown as jest.Mock).mockReturnValue(secondWindow); + + App.ensureMainWindow(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(secondWindow); + }); + 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 1f1113d90..77a63d2d7 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -216,6 +216,9 @@ 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 | null = null; + private static readonly mainWindowListeners: Array< + (mainWindow: Electron.BrowserWindow) => void + > = []; static application: Electron.App; static BrowserWindow; private static loadedMainWindow: Electron.BrowserWindow | null = null; @@ -268,6 +271,36 @@ export default class App { } } + /** + * Registers a listener that needs the current main window, and re-runs it + * whenever a new one is created. + * + * The main window is not created once per process: on macOS the window can + * be closed and rebuilt (dock `activate`, or a second launch handed over by + * the single-instance guard) while the process lives on. Anything that + * caches the window — `download-broadcast`'s module-level reference, for + * one — would otherwise keep pointing at a destroyed window and silently + * stop delivering to the renderer. Fires immediately when a window already + * exists, so callers registering after startup do not miss the first one. + */ + static onMainWindowCreated( + listener: (mainWindow: Electron.BrowserWindow) => void + ): void { + App.mainWindowListeners.push(listener); + + if (App.mainWindow && !App.mainWindow.isDestroyed()) { + listener(App.mainWindow); + } + } + + private static notifyMainWindowCreated( + mainWindow: Electron.BrowserWindow + ): void { + for (const listener of App.mainWindowListeners) { + listener(mainWindow); + } + } + /** * Brings the app back to a windowed state, re-creating the main window if * it is gone. @@ -396,6 +429,7 @@ export default class App { App.mainWindow.setMenu(null); attachWindowTrace(App.mainWindow); App.attachWindowStateEvents(App.mainWindow); + App.notifyMainWindowCreated(App.mainWindow); if (!savedWindowBounds) { App.mainWindow.center(); } diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 9b91f7b34..b745ad244 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -149,10 +149,10 @@ export default class Main { EpgEvents.bootstrapEpgEvents(); RemoteControlEvents.bootstrapRemoteControlEvents(); - // Set main window for downloads and reset stale downloads - if (App.mainWindow) { - setDownloadsMainWindow(App.mainWindow); - } + // Keep the downloads broadcaster bound to the live window. macOS can + // rebuild the window while the process runs, and a stale reference + // silently swallows every DOWNLOADS_UPDATE_EVENT. + App.onMainWindowCreated(setDownloadsMainWindow); // Load the renderer only after IPC handlers are registered. On slower // Linux CI hosts the renderer can otherwise invoke Electron bridge IPC