fix(window-controls): derive window-state pushes from events so controls reappear after fullscreen (#1178)

The custom minimize/maximize/close controls stayed hidden forever after
leaving HTML-element (video player) fullscreen on Windows: window state was
polled at event time, and isFullScreen() can still report the pre-transition
value while 'leave-full-screen' fires, leaving a stale push with no later
event to correct it. The same polling on the companion flag cleared
isMaximized during fullscreen transitions and stuck the maximize/restore
glyph on the wrong icon.

attachWindowStateEvents now seeds the state once at window creation and each
event patches only the flag it names, sending a copy per push. The
enter/leave-html-full-screen variants are wired too.

Regression coverage: app-window-state.spec.ts (9 cases, 6 of which fail
against the old implementation) and an Electron E2E case that toggles HTML
element fullscreen and asserts the controls come back.
This commit is contained in:
4gray authored and GitHub committed 2026-07-26 01:49:08 +02:00
1 parent 6c946978b4
commit c0e065da17
6 files changed
+373 -14

No files matched your search

@@ -0,0 +1,9 @@
---
type: fix
area: window-controls
---
On Windows, the minimize, maximize and close buttons disappeared for good after
leaving fullscreen video playback — the only way to get them back was to restart
the app. They now reappear as soon as you exit fullscreen, and the maximize
button no longer gets stuck on the wrong icon afterwards.
@@ -103,6 +103,67 @@ test.describe('Custom window controls', () => {
}
});
test('@electron controls reappear after leaving HTML element fullscreen', async ({
dataDir,
}) => {
test.skip(
isWindowManagerlessCi,
'xvfb on Linux CI has no window manager'
);
const app = await launchElectronApp(dataDir);
try {
const controls = app.mainWindow.locator('app-window-controls');
await expect(controls).not.toHaveClass(/is-hidden/);
// The video players fullscreen their player root through the
// HTML element-fullscreen API. That API needs transient user
// activation, so trigger it from a real click on a temporary
// overlay instead of calling it directly from evaluate().
await app.mainWindow.evaluate(() => {
const overlay = document.createElement('div');
overlay.id = 'e2e-fullscreen-trigger';
overlay.style.cssText =
'position: fixed; inset: 0; z-index: 2147483647;';
overlay.addEventListener('click', () => {
overlay.remove();
void document.documentElement.requestFullscreen();
});
document.body.append(overlay);
});
await app.mainWindow.locator('#e2e-fullscreen-trigger').click();
const isHtmlFullScreen = () =>
app.mainWindow.evaluate(
() => document.fullscreenElement !== null
);
await expect.poll(isHtmlFullScreen, { timeout: 10_000 }).toBe(
true
);
await expect(controls).toHaveClass(/is-hidden/, {
timeout: 10_000,
});
await app.mainWindow.evaluate(() => document.exitFullscreen());
await expect.poll(isHtmlFullScreen, { timeout: 10_000 }).toBe(
false
);
// Regression: the exit push must not carry a stale
// isFullScreen=true read mid-transition — the controls have to
// come back once fullscreen is left.
await expect(controls).not.toHaveClass(/is-hidden/, {
timeout: 10_000,
});
await expect(
app.mainWindow.getByTestId('window-minimize')
).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('@electron minimize button minimizes the window', async ({
dataDir,
}) => {
@@ -0,0 +1,242 @@
/**
* Regression coverage for the WINDOW:STATE_CHANGED pushes that drive the
* renderer-drawn window controls on Windows/Linux.
*
* The handlers must never re-read window state at event time: on Windows,
* isFullScreen() can still report true while 'leave-full-screen' fires for
* HTML-element fullscreen (video player) exits, and isMaximized() reports
* false while the window is fullscreen. Polling pushed a stale
* `isFullScreen: true` on exit — leaving the custom window controls hidden
* forever — and cleared the companion `isMaximized`, sticking the
* maximize/restore glyph on the wrong icon. Instead the state is seeded once
* at attach time and each event patches only the flag it names.
*/
jest.mock('electron', () => ({
app: {
getPath: jest.fn(() => '/tmp'),
isPackaged: false,
isReady: jest.fn(() => false),
on: jest.fn(),
},
BrowserWindow: jest.fn(),
Menu: {
buildFromTemplate: jest.fn(),
},
screen: {
getPrimaryDisplay: jest.fn(),
},
session: {
defaultSession: {
clearStorageData: jest.fn(),
},
},
shell: {
openExternal: jest.fn(),
},
}));
jest.mock('./services/store.service', () => ({
store: {
get: jest.fn(),
set: jest.fn(),
},
WINDOW_BOUNDS: 'windowBounds',
}));
import { WINDOW_STATE_CHANGED } from '@iptvnator/shared/interfaces';
import App from './app';
type MockStateWindow = {
isDestroyed: jest.Mock<boolean, []>;
isFullScreen: jest.Mock<boolean, []>;
isMaximized: jest.Mock<boolean, []>;
on: jest.Mock<void, [string, () => void]>;
webContents: {
send: jest.Mock<void, [string, unknown]>;
};
};
function createMockStateWindow(): MockStateWindow {
return {
isDestroyed: jest.fn<boolean, []>().mockReturnValue(false),
isFullScreen: jest.fn<boolean, []>().mockReturnValue(false),
isMaximized: jest.fn<boolean, []>().mockReturnValue(false),
on: jest.fn<void, [string, () => void]>(),
webContents: {
send: jest.fn<void, [string, unknown]>(),
},
};
}
function attachWindowStateEvents(win: MockStateWindow): void {
(
App as unknown as {
attachWindowStateEvents: (win: MockStateWindow) => void;
}
).attachWindowStateEvents(win);
}
function fireWindowEvent(win: MockStateWindow, eventName: string): void {
const handlers = win.on.mock.calls
.filter(([name]) => name === eventName)
.map(([, handler]) => handler);
expect(handlers).toHaveLength(1);
handlers[0]();
}
describe('window state change pushes', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(
process,
'platform'
);
function setProcessPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform,
});
}
beforeEach(() => {
setProcessPlatform('win32');
});
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
});
it('registers no window-state listeners on macOS', () => {
setProcessPlatform('darwin');
const win = createMockStateWindow();
attachWindowStateEvents(win);
expect(win.on).not.toHaveBeenCalled();
});
it('pushes isFullScreen=false on leave-full-screen even while the window still reports fullscreen', () => {
const win = createMockStateWindow();
attachWindowStateEvents(win);
// Windows still reports the old state while the transition runs.
win.isFullScreen.mockReturnValue(true);
fireWindowEvent(win, 'leave-full-screen');
expect(win.webContents.send).toHaveBeenCalledWith(
WINDOW_STATE_CHANGED,
{ isMaximized: false, isFullScreen: false }
);
});
it('pushes isFullScreen=false on leave-html-full-screen even while the window still reports fullscreen', () => {
const win = createMockStateWindow();
attachWindowStateEvents(win);
win.isFullScreen.mockReturnValue(true);
fireWindowEvent(win, 'leave-html-full-screen');
expect(win.webContents.send).toHaveBeenCalledWith(
WINDOW_STATE_CHANGED,
{ isMaximized: false, isFullScreen: false }
);
});
it('pushes isFullScreen=true on enter events before the window reports fullscreen', () => {
const win = createMockStateWindow();
attachWindowStateEvents(win);
win.isFullScreen.mockReturnValue(false);
fireWindowEvent(win, 'enter-full-screen');
fireWindowEvent(win, 'enter-html-full-screen');
expect(win.webContents.send).toHaveBeenCalledTimes(2);
expect(win.webContents.send).toHaveBeenLastCalledWith(
WINDOW_STATE_CHANGED,
{ isMaximized: false, isFullScreen: true }
);
});
it('derives isMaximized from the maximize/unmaximize events instead of polling', () => {
const win = createMockStateWindow();
attachWindowStateEvents(win);
win.isMaximized.mockReturnValue(false);
fireWindowEvent(win, 'maximize');
expect(win.webContents.send).toHaveBeenLastCalledWith(
WINDOW_STATE_CHANGED,
{ isMaximized: true, isFullScreen: false }
);
win.isMaximized.mockReturnValue(true);
fireWindowEvent(win, 'unmaximize');
expect(win.webContents.send).toHaveBeenLastCalledWith(
WINDOW_STATE_CHANGED,
{ isMaximized: false, isFullScreen: false }
);
});
it('keeps the maximized flag across a fullscreen round-trip while the window misreports it', () => {
const win = createMockStateWindow();
win.isMaximized.mockReturnValue(true);
attachWindowStateEvents(win);
// Windows reports a fullscreen window as not maximized, so polling
// the companion flag here would clear it and leave the glyph on
// "maximize" after the controls come back.
win.isMaximized.mockReturnValue(false);
fireWindowEvent(win, 'enter-html-full-screen');
expect(win.webContents.send).toHaveBeenLastCalledWith(
WINDOW_STATE_CHANGED,
{ isMaximized: true, isFullScreen: true }
);
fireWindowEvent(win, 'leave-html-full-screen');
expect(win.webContents.send).toHaveBeenLastCalledWith(
WINDOW_STATE_CHANGED,
{ isMaximized: true, isFullScreen: false }
);
});
it('keeps the fullscreen flag when a maximize event arrives mid-transition', () => {
const win = createMockStateWindow();
win.isFullScreen.mockReturnValue(true);
attachWindowStateEvents(win);
win.isFullScreen.mockReturnValue(false);
fireWindowEvent(win, 'maximize');
expect(win.webContents.send).toHaveBeenLastCalledWith(
WINDOW_STATE_CHANGED,
{ isMaximized: true, isFullScreen: true }
);
});
it('sends an independent payload per push', () => {
const win = createMockStateWindow();
attachWindowStateEvents(win);
fireWindowEvent(win, 'enter-full-screen');
const firstPayload = win.webContents.send.mock.calls[0][1];
fireWindowEvent(win, 'leave-full-screen');
// The renderer must not see the earlier push mutate under it.
expect(firstPayload).toEqual({
isMaximized: false,
isFullScreen: true,
});
});
it('skips pushes for destroyed windows', () => {
const win = createMockStateWindow();
attachWindowStateEvents(win);
win.isDestroyed.mockReturnValue(true);
fireWindowEvent(win, 'leave-full-screen');
expect(win.webContents.send).not.toHaveBeenCalled();
});
});
@@ -55,6 +55,10 @@ import { store } from './services/store.service';
type MockMainWindow = {
center: jest.Mock<void, []>;
getNormalBounds: jest.Mock<object, []>;
// Read once by attachWindowStateEvents to seed the tracked window
// state; only reached off macOS, where the custom controls exist.
isFullScreen: jest.Mock<boolean, []>;
isMaximized: jest.Mock<boolean, []>;
loadFile: jest.Mock<Promise<void>, [string]>;
loadURL: jest.Mock<Promise<void>, [string]>;
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
@@ -72,6 +76,8 @@ function createMockMainWindow(): MockMainWindow {
return {
center: jest.fn<void, []>(),
getNormalBounds: jest.fn<object, []>().mockReturnValue({}),
isFullScreen: jest.fn<boolean, []>().mockReturnValue(false),
isMaximized: jest.fn<boolean, []>().mockReturnValue(false),
loadFile: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
loadURL: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
+36 -10
View File
@@ -1,5 +1,8 @@
import { app, BrowserWindow, Menu, screen, session, shell } from 'electron';
import { WINDOW_STATE_CHANGED } from '@iptvnator/shared/interfaces';
import {
ElectronBridgeWindowState,
WINDOW_STATE_CHANGED,
} from '@iptvnator/shared/interfaces';
import { join, resolve } from 'path';
import { fileURLToPath } from 'url';
import { rendererAppName, rendererAppPort } from './constants';
@@ -318,21 +321,44 @@ export default class App {
return;
}
const sendWindowState = () => {
// Window state is never re-read at event time: on Windows both
// isFullScreen() and isMaximized() can still report the
// pre-transition value while the matching event fires (notably for
// HTML-element fullscreen, i.e. the video player). Since the
// renderer replaces both flags on every push and no later event
// corrects a stale one, polling left the controls hidden forever
// after leaving fullscreen — and, for the companion flag, the
// maximize/restore glyph stuck on the wrong icon.
//
// Instead the state is seeded once here (window creation, so no
// transition is in flight) and each event patches only the flag it
// names.
const state: ElectronBridgeWindowState = {
isMaximized: win.isMaximized(),
isFullScreen: win.isFullScreen(),
};
const push = (patch: Partial<ElectronBridgeWindowState>) => {
Object.assign(state, patch);
if (win.isDestroyed()) {
return;
}
win.webContents.send(WINDOW_STATE_CHANGED, {
isMaximized: win.isMaximized(),
isFullScreen: win.isFullScreen(),
});
// A copy per push: the renderer must not observe later
// mutations of the tracked state.
win.webContents.send(WINDOW_STATE_CHANGED, { ...state });
};
win.on('maximize', sendWindowState);
win.on('unmaximize', sendWindowState);
win.on('enter-full-screen', sendWindowState);
win.on('leave-full-screen', sendWindowState);
win.on('maximize', () => push({ isMaximized: true }));
win.on('unmaximize', () => push({ isMaximized: false }));
// The html variants cover HTML-element fullscreen; not every
// platform/trigger emits both pairs, and duplicate pushes with the
// same payload are harmless.
win.on('enter-full-screen', () => push({ isFullScreen: true }));
win.on('enter-html-full-screen', () => push({ isFullScreen: true }));
win.on('leave-full-screen', () => push({ isFullScreen: false }));
win.on('leave-html-full-screen', () => push({ isFullScreen: false }));
}
private static initMainWindow() {
+19 -4
View File
@@ -255,10 +255,25 @@ handlers in `apps/electron-backend/src/app/events/window.events.ts`):
WebContents. Close goes through `win.close()` so the existing
window-bounds persistence in `app.ts` still runs.
2. `WINDOW:STATE_CHANGED` is pushed main → renderer on
maximize/unmaximize/enter-full-screen/leave-full-screen so the
maximize/restore glyph stays correct for externally triggered changes
(double-click on a drag region, OS snap, F11). The controls hide
themselves while the window is fullscreen.
maximize/unmaximize and on the fullscreen events —
`enter/leave-full-screen` plus the `enter/leave-html-full-screen`
variants emitted for HTML-element fullscreen (video player
fullscreen) — so the maximize/restore glyph stays correct for
externally triggered changes (double-click on a drag region, OS
snap, F11). The controls hide themselves while the window is
fullscreen.
Window state is **never re-read at event time**. `attachWindowStateEvents`
seeds `{ isMaximized, isFullScreen }` once at window creation and each
event patches only the flag it names; every push carries a copy of that
tracked state. On Windows both getters can still report the
pre-transition value while the matching event fires — `isFullScreen()`
stays `true` during an HTML fullscreen exit, and `isMaximized()` reads
`false` while the window is fullscreen. Because the renderer replaces
both flags on every push and no later event corrects a stale one,
polling left the controls hidden forever after leaving fullscreen and
stuck the maximize/restore glyph on the wrong icon. Regression coverage:
`app-window-state.spec.ts` and `window-controls.e2e.ts`.
Layout integration: