From 9f8713bdcb39da0bee741680d5cdd3a765825b74 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 22:13:16 +0200 Subject: [PATCH] fix(electron): rebind window-owned services when the window is rebuilt download-broadcast keeps a module-level BrowserWindow reference set once during bootstrap. The main window is not created once per process: macOS rebuilds it on dock 'activate' and now also when the single-instance guard hands over a second launch. The stale reference is guarded by isDestroyed(), so every DOWNLOADS_UPDATE_EVENT was silently dropped and the new renderer never saw download progress again. App.onMainWindowCreated() registers listeners that fire for the current window and again for every rebuilt one, and main.ts binds the downloads broadcaster through it. This also fixes the pre-existing dock-activate case on master. Reported by Codex review on #1272. Co-Authored-By: Claude Opus 5 --- apps/electron-backend/src/app/app.spec.ts | 42 +++++++++++++++++++++++ apps/electron-backend/src/app/app.ts | 34 ++++++++++++++++++ apps/electron-backend/src/main.ts | 8 ++--- 3 files changed, 80 insertions(+), 4 deletions(-) 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