Files
iptvnator/apps/electron-backend-e2e/src/window-controls.e2e.ts
T
4grayandClaude Fable 5 8e0abe6feb feat(ui): custom title bar with window controls for Windows and Linux (#1042)
* feat(ui): add custom title bar window controls for Windows and Linux

Hide the native title bar on win32/linux (titleBarStyle: 'hidden', frame
untouched so native resize borders and snapping keep working) and render
minimize / maximize-restore / close buttons in the renderer, mirroring the
existing macOS traffic-light setup.

- New WINDOW:* IPC contract (minimize, toggle-maximize, close, get-state)
  handled in window.events.ts, resolved from the sender WebContents;
  close goes through win.close() so window-bounds persistence still runs.
- WINDOW:STATE_CHANGED pushed on maximize/unmaximize/fullscreen so the
  maximize/restore glyph stays correct for OS-triggered changes; controls
  hide while fullscreen.
- WindowControlsComponent mounts once in app-root as a manual popover so
  it stays in the browser top layer above CDK overlays (dialogs,
  multi-EPG) - same behavior as macOS traffic lights.
- Theme-aware via CSS vars (--app-on-surface, --app-hover-overlay);
  Windows-red close hover. Drag regions get right padding through a
  body-level frameless-platform class.
- Gated by RuntimeCapabilitiesService.usesCustomWindowControls; PWA and
  macOS never mount the controls.

Includes unit specs for the component and IPC handlers, an Electron E2E
suite (window-controls.e2e.ts), and a window-chrome section in
docs/architecture/workspace-shell.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(linux): upgrade Electron to 41 for frameless window decorations on Wayland

With the native title bar hidden, Linux windows lost the WM-drawn shadow
and rounded corners. Electron draws client-side decorations only on
native Wayland, and frameless-window CSD (GTK drop shadow + extended
resize boundaries) landed in Electron 41 - before that, frameless
windows render as plain rectangles.

- electron ^39.8.5 -> ^41.7.2 (Wayland auto-detected since 38.2; X11
  sessions remain undecorated, matching other frameless Electron apps;
  Windows keeps its DWM shadow and rounded corners).
- better-sqlite3 pinned to exactly 12.9.0: the last release shipping
  prebuilt binaries for both Node 20 (ABI 115, Jest) and Electron 41
  (ABI 145, runtime). 12.10.0 dropped the Node 20 prebuilds, forcing a
  from-source build that fails without a C++ toolchain.
- pnpm override node-abi 3.85.0 -> 3.92.0 so electron-builder
  install-app-deps can map Electron 41 to ABI 145.

Reviewed Electron 40/41 breaking changes: only the renderer clipboard
deprecation, which this app does not use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(e2e): address review feedback and window-managerless Linux CI

- Skip the three window-manager-dependent E2E assertions (maximize
  toggle, main-process state sync, minimize) on Linux CI: GitHub's
  ubuntu runners drive Electron under xvfb without a window manager, so
  maximize/minimize state never materializes there. Windows CI and
  local Linux/macOS runs keep the coverage.
- WINDOW:TOGGLE_MAXIMIZE now returns the requested state instead of
  re-reading isMaximized() right after the call, which races on Linux
  window managers where maximize()/unmaximize() complete
  asynchronously; the WINDOW:STATE_CHANGED push stays authoritative.
- Skip attaching window-state push listeners on macOS, where the
  custom controls never mount and the IPC traffic had no subscriber.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): gate custom window controls on the full bridge surface

Include getWindowState and onWindowStateChange in the
usesCustomWindowControls capability check — the controls rely on both
for initial state and for keeping the maximize/restore glyph in sync
with OS-triggered changes, so a partial bridge should not mount them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:03:27 +02:00

129 lines
4.3 KiB
TypeScript

import {
closeElectronApp,
expect,
launchElectronApp,
test,
} from './electron-test-fixtures';
// Custom window controls are only rendered on Windows/Linux; macOS keeps
// the native traffic lights.
//
// Linux CI runs Electron under xvfb, which has no window manager, so
// maximize/minimize never take effect there — those tests are skipped and
// rely on Windows CI plus local Linux/macOS runs for coverage.
const isWindowManagerlessCi =
process.platform === 'linux' && !!process.env['CI'];
test.describe('Custom window controls', () => {
test.skip(
process.platform === 'darwin',
'macOS uses native traffic lights instead of custom controls'
);
test('@electron renders the window control buttons', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await expect(
app.mainWindow.getByTestId('window-minimize')
).toBeVisible();
await expect(
app.mainWindow.getByTestId('window-maximize')
).toBeVisible();
await expect(
app.mainWindow.getByTestId('window-close')
).toBeVisible();
await expect(
app.mainWindow.getByTestId('window-maximize-glyph')
).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('@electron maximize button toggles the native window state', async ({
dataDir,
}) => {
test.skip(
isWindowManagerlessCi,
'xvfb on Linux CI has no window manager'
);
const app = await launchElectronApp(dataDir);
try {
const isMaximized = () =>
app.electronApp.evaluate(({ BrowserWindow }) => {
const mainWindow = BrowserWindow.getAllWindows()[0];
return mainWindow ? mainWindow.isMaximized() : false;
});
await app.mainWindow.getByTestId('window-maximize').click();
await expect.poll(isMaximized, { timeout: 10_000 }).toBe(true);
await expect(
app.mainWindow.getByTestId('window-restore-glyph')
).toBeVisible();
await app.mainWindow.getByTestId('window-maximize').click();
await expect.poll(isMaximized, { timeout: 10_000 }).toBe(false);
await expect(
app.mainWindow.getByTestId('window-maximize-glyph')
).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('@electron reflects window state changes triggered from the main process', async ({
dataDir,
}) => {
test.skip(
isWindowManagerlessCi,
'xvfb on Linux CI has no window manager'
);
const app = await launchElectronApp(dataDir);
try {
await app.electronApp.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0]?.maximize();
});
await expect(
app.mainWindow.getByTestId('window-restore-glyph')
).toBeVisible({ timeout: 10_000 });
await app.electronApp.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0]?.unmaximize();
});
await expect(
app.mainWindow.getByTestId('window-maximize-glyph')
).toBeVisible({ timeout: 10_000 });
} finally {
await closeElectronApp(app);
}
});
test('@electron minimize button minimizes the window', async ({
dataDir,
}) => {
test.skip(
isWindowManagerlessCi,
'xvfb on Linux CI has no window manager'
);
const app = await launchElectronApp(dataDir);
try {
const isMinimized = () =>
app.electronApp.evaluate(({ BrowserWindow }) => {
const mainWindow = BrowserWindow.getAllWindows()[0];
return mainWindow ? mainWindow.isMinimized() : false;
});
await app.mainWindow.getByTestId('window-minimize').click();
await expect.poll(isMinimized, { timeout: 10_000 }).toBe(true);
} finally {
await closeElectronApp(app);
}
});
});