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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-27 20:08:31 +02:00
1 parent 1ef75b6392
commit 9f8713bdcb
3 files changed
+80 -4

No files matched your search

+42
View File
@@ -57,6 +57,7 @@ type MockMainWindow = {
getNormalBounds: jest.Mock<object, []>;
// Read once by attachWindowStateEvents to seed the tracked window
// state; only reached off macOS, where the custom controls exist.
isDestroyed: jest.Mock<boolean, []>;
isFullScreen: jest.Mock<boolean, []>;
isMaximized: jest.Mock<boolean, []>;
loadFile: jest.Mock<Promise<void>, [string]>;
@@ -82,6 +83,7 @@ function createMockMainWindow(): MockMainWindow {
loadURL: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
once: jest.fn<void, [string, (...args: unknown[]) => void]>(),
isDestroyed: jest.fn<boolean, []>().mockReturnValue(false),
setMenu: jest.fn<void, [unknown]>(),
show: jest.fn<void, []>(),
webContents: {
@@ -95,6 +97,7 @@ function createMockMainWindow(): MockMainWindow {
type AppInternals = {
loadedMainWindow: MockMainWindow | null;
mainWindow: MockMainWindow | null;
mainWindowListeners: Array<(mainWindow: MockMainWindow) => void>;
mainWindowLoadPromise: Promise<void> | 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();
+34
View File
@@ -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();
}
+4 -4
View File
@@ -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