mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
1 parent
1ef75b6392
commit
9f8713bdcb
3 files changed
+80
-4
No files matched your search
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user