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>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-06-10 20:46:21 +02:00
1 parent 2be42257ab
commit 24c2da48ae
18 files changed
+939 -25

No files matched your search

@@ -0,0 +1,109 @@
import {
closeElectronApp,
expect,
launchElectronApp,
test,
} from './electron-test-fixtures';
// Custom window controls are only rendered on Windows/Linux; macOS keeps
// the native traffic lights.
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,
}) => {
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,
}) => {
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,
}) => {
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);
}
});
});
@@ -13,6 +13,7 @@ import type {
ElectronBridgePlaylistUpsertInput,
ElectronBridgeRemoteControlCommand,
ElectronBridgeRemoteControlStatus,
ElectronBridgeWindowState,
ElectronBridgeXtreamContentStream,
ExternalPlayerSession,
PlaybackPositionData,
@@ -37,6 +38,11 @@ const EXTERNAL_PLAYER_SESSION_UPDATE = 'EXTERNAL_PLAYER_SESSION_UPDATE';
const EMBEDDED_MPV_SESSION_UPDATE = 'EMBEDDED_MPV_SESSION_UPDATE';
const DB_OPERATION_EVENT = 'DB_OPERATION_EVENT';
const PLAYLIST_REFRESH_EVENT = 'PLAYLIST:REFRESH_EVENT';
const WINDOW_MINIMIZE = 'WINDOW:MINIMIZE';
const WINDOW_TOGGLE_MAXIMIZE = 'WINDOW:TOGGLE_MAXIMIZE';
const WINDOW_CLOSE = 'WINDOW:CLOSE';
const WINDOW_GET_STATE = 'WINDOW:GET_STATE';
const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED';
const dbSaveContentProgressListeners = new Set<
(
@@ -282,6 +288,20 @@ const electronApi: ElectronBridgeApi = {
},
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
platform: process.platform,
minimizeWindow: () => ipcRenderer.invoke(WINDOW_MINIMIZE),
toggleMaximizeWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE),
closeWindow: () => ipcRenderer.invoke(WINDOW_CLOSE),
getWindowState: () => ipcRenderer.invoke(WINDOW_GET_STATE),
onWindowStateChange: (
callback: (state: ElectronBridgeWindowState) => void
) => {
const handler = (
_event: Electron.IpcRendererEvent,
state: ElectronBridgeWindowState
) => callback(state);
ipcRenderer.on(WINDOW_STATE_CHANGED, handler);
return () => ipcRenderer.off(WINDOW_STATE_CHANGED, handler);
},
fetchPlaylistByUrl: (url: string, title?: string) =>
ipcRenderer.invoke('fetch-playlist-by-url', url, title),
updatePlaylistFromFilePath: (filePath: string, title: string) =>
+40 -7
View File
@@ -1,4 +1,5 @@
import { app, BrowserWindow, Menu, screen, shell } from 'electron';
import { WINDOW_STATE_CHANGED } from '@iptvnator/shared/interfaces';
import { join, resolve } from 'path';
import { fileURLToPath } from 'url';
import { rendererAppName, rendererAppPort } from './constants';
@@ -237,6 +238,43 @@ export default class App {
}
}
/**
* Hide the native title bar on every desktop platform. macOS keeps the
* system traffic lights (overlay), while Windows/Linux rely on the
* renderer-drawn window controls (`app-window-controls`) wired up via the
* WINDOW:* IPC channels. `frame` stays untouched so native resize borders
* and snapping keep working.
*/
private static getPlatformTitleBarOptions(): Electron.BrowserWindowConstructorOptions {
if (process.platform === 'darwin') {
return {
titleBarStyle: 'hidden',
titleBarOverlay: true,
trafficLightPosition: { x: 16, y: 20 },
};
}
return { titleBarStyle: 'hidden' };
}
private static attachWindowStateEvents(win: Electron.BrowserWindow): void {
const sendWindowState = () => {
if (win.isDestroyed()) {
return;
}
win.webContents.send(WINDOW_STATE_CHANGED, {
isMaximized: win.isMaximized(),
isFullScreen: win.isFullScreen(),
});
};
win.on('maximize', sendWindowState);
win.on('unmaximize', sendWindowState);
win.on('enter-full-screen', sendWindowState);
win.on('leave-full-screen', sendWindowState);
}
private static initMainWindow() {
const workAreaSize = screen.getPrimaryDisplay().workAreaSize;
const width = Math.min(1280, workAreaSize.width || 1280);
@@ -254,16 +292,11 @@ export default class App {
...savedWindowBounds,
minHeight: 600,
minWidth: 900,
...(process.platform === 'darwin'
? {
titleBarStyle: 'hidden',
titleBarOverlay: true,
trafficLightPosition: { x: 16, y: 20 },
}
: {}),
...App.getPlatformTitleBarOptions(),
});
App.mainWindow.setMenu(null);
attachWindowTrace(App.mainWindow);
App.attachWindowStateEvents(App.mainWindow);
if (!savedWindowBounds) {
App.mainWindow.center();
}
@@ -0,0 +1,140 @@
const mockHandlers = new Map<
string,
(event: unknown, ...args: unknown[]) => unknown
>();
const mockFromWebContents = jest.fn();
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(
(
channel: string,
handler: (event: unknown, ...args: unknown[]) => unknown
) => {
mockHandlers.set(channel, handler);
}
),
},
BrowserWindow: {
fromWebContents: (...args: unknown[]) => mockFromWebContents(...args),
},
}));
function createFakeWindow(
initial: { maximized?: boolean; fullScreen?: boolean } = {}
) {
const state = {
maximized: initial.maximized ?? false,
fullScreen: initial.fullScreen ?? false,
};
return {
isDestroyed: jest.fn(() => false),
isMaximized: jest.fn(() => state.maximized),
isFullScreen: jest.fn(() => state.fullScreen),
minimize: jest.fn(),
maximize: jest.fn(() => {
state.maximized = true;
}),
unmaximize: jest.fn(() => {
state.maximized = false;
}),
close: jest.fn(),
};
}
const fakeEvent = { sender: {} };
describe('WindowEvents', () => {
beforeAll(async () => {
await import('./window.events');
});
beforeEach(() => {
mockFromWebContents.mockReset();
});
it('registers handlers for all window control channels', () => {
expect([...mockHandlers.keys()].sort()).toEqual([
'WINDOW:CLOSE',
'WINDOW:GET_STATE',
'WINDOW:MINIMIZE',
'WINDOW:TOGGLE_MAXIMIZE',
]);
});
it('minimizes the sender window', () => {
const win = createFakeWindow();
mockFromWebContents.mockReturnValue(win);
mockHandlers.get('WINDOW:MINIMIZE')!(fakeEvent);
expect(win.minimize).toHaveBeenCalledTimes(1);
});
it('maximizes an unmaximized window and returns the new state', () => {
const win = createFakeWindow({ maximized: false });
mockFromWebContents.mockReturnValue(win);
const result = mockHandlers.get('WINDOW:TOGGLE_MAXIMIZE')!(fakeEvent);
expect(win.maximize).toHaveBeenCalledTimes(1);
expect(win.unmaximize).not.toHaveBeenCalled();
expect(result).toEqual({ isMaximized: true, isFullScreen: false });
});
it('unmaximizes a maximized window and returns the new state', () => {
const win = createFakeWindow({ maximized: true });
mockFromWebContents.mockReturnValue(win);
const result = mockHandlers.get('WINDOW:TOGGLE_MAXIMIZE')!(fakeEvent);
expect(win.unmaximize).toHaveBeenCalledTimes(1);
expect(win.maximize).not.toHaveBeenCalled();
expect(result).toEqual({ isMaximized: false, isFullScreen: false });
});
it('closes the sender window', () => {
const win = createFakeWindow();
mockFromWebContents.mockReturnValue(win);
mockHandlers.get('WINDOW:CLOSE')!(fakeEvent);
expect(win.close).toHaveBeenCalledTimes(1);
});
it('returns the current window state', () => {
const win = createFakeWindow({ maximized: true, fullScreen: true });
mockFromWebContents.mockReturnValue(win);
const result = mockHandlers.get('WINDOW:GET_STATE')!(fakeEvent);
expect(result).toEqual({ isMaximized: true, isFullScreen: true });
});
it('is a safe no-op when the sender has no window', () => {
mockFromWebContents.mockReturnValue(null);
expect(() =>
mockHandlers.get('WINDOW:MINIMIZE')!(fakeEvent)
).not.toThrow();
expect(mockHandlers.get('WINDOW:TOGGLE_MAXIMIZE')!(fakeEvent)).toEqual({
isMaximized: false,
isFullScreen: false,
});
expect(mockHandlers.get('WINDOW:GET_STATE')!(fakeEvent)).toEqual({
isMaximized: false,
isFullScreen: false,
});
});
it('treats a destroyed window like a missing window', () => {
const win = createFakeWindow();
win.isDestroyed.mockReturnValue(true);
mockFromWebContents.mockReturnValue(win);
mockHandlers.get('WINDOW:MINIMIZE')!(fakeEvent);
expect(win.minimize).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,67 @@
/**
* IPC handlers for the renderer-drawn window controls used with the custom
* title bar on Windows/Linux (`titleBarStyle: 'hidden'`). Each handler
* resolves the window from the calling WebContents so it works without
* coupling to the static main-window reference.
*/
import { BrowserWindow, ipcMain } from 'electron';
import {
WINDOW_CLOSE,
WINDOW_GET_STATE,
WINDOW_MINIMIZE,
WINDOW_TOGGLE_MAXIMIZE,
} from '@iptvnator/shared/interfaces';
interface WindowState {
isMaximized: boolean;
isFullScreen: boolean;
}
function getSenderWindow(
event: Electron.IpcMainInvokeEvent
): Electron.BrowserWindow | null {
const win = BrowserWindow.fromWebContents(event.sender);
return win && !win.isDestroyed() ? win : null;
}
function getWindowState(win: Electron.BrowserWindow | null): WindowState {
return {
isMaximized: !!win?.isMaximized(),
isFullScreen: !!win?.isFullScreen(),
};
}
export default class WindowEvents {
static bootstrapWindowEvents(): Electron.IpcMain {
return ipcMain;
}
}
ipcMain.handle(WINDOW_MINIMIZE, (event) => {
getSenderWindow(event)?.minimize();
});
ipcMain.handle(WINDOW_TOGGLE_MAXIMIZE, (event): WindowState => {
const win = getSenderWindow(event);
if (win) {
if (win.isMaximized()) {
win.unmaximize();
} else {
win.maximize();
}
}
return getWindowState(win);
});
// win.close() (instead of app.quit()) so the window's 'close' handler still
// persists the window bounds before shutdown.
ipcMain.handle(WINDOW_CLOSE, (event) => {
getSenderWindow(event)?.close();
});
ipcMain.handle(WINDOW_GET_STATE, (event): WindowState => {
return getWindowState(getSenderWindow(event));
});
+2
View File
@@ -24,6 +24,7 @@ import SquirrelEvents from './app/events/squirrel.events';
import StalkerEvents from './app/events/stalker.events';
import { isStartupTraceEnabled, trace } from './app/services/debug-trace';
import { databaseWorkerClient } from './app/services/database-worker-client';
import WindowEvents from './app/events/window.events';
import XtreamEvents from './app/events/xtream.events';
app.setName('iptvnator');
@@ -90,6 +91,7 @@ export default class Main {
}
ElectronEvents.bootstrapElectronEvents();
WindowEvents.bootstrapWindowEvents();
EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents();
PlaylistEvents.bootstrapPlaylistEvents();
SharedEvents.bootstrapSharedEvents();
+4
View File
@@ -1,3 +1,7 @@
<router-outlet />
<app-epg-progress-panel />
@if (usesCustomWindowControls) {
<app-window-controls />
}
+15 -2
View File
@@ -4,9 +4,13 @@ import { Router, RouterOutlet } from '@angular/router';
import { Actions, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access';
import {
EpgRuntimeBridgeService,
EpgService,
} from '@iptvnator/epg/data-access';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
import { EpgProgressPanelComponent } from '@iptvnator/ui/epg/progress-panel';
import { WindowControlsComponent } from '@iptvnator/ui/components';
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { filter, take } from 'rxjs';
import {
@@ -30,12 +34,15 @@ const debugAppComponent = createDevLogger('AppComponent');
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
imports: [EpgProgressPanelComponent, RouterOutlet],
imports: [EpgProgressPanelComponent, RouterOutlet, WindowControlsComponent],
})
export class AppComponent implements OnInit {
@HostBinding('class.macos-platform') get isMacOS() {
return this.runtime.isMacOS;
}
get usesCustomWindowControls() {
return this.runtime.usesCustomWindowControls;
}
private actions$ = inject(Actions);
private dataService = inject(DataService);
private epgBridge = inject(EpgRuntimeBridgeService);
@@ -53,6 +60,12 @@ export class AppComponent implements OnInit {
private readonly DEFAULT_LANG = Language.ENGLISH;
constructor() {
// Body-level class (like 'dark-theme') so layout adjustments also
// reach content rendered outside app-root, e.g. cdk-overlay content.
if (this.runtime.usesCustomWindowControls) {
document.body.classList.add('frameless-platform');
}
const electronProcess = this.dataService.remote?.process;
if (
this.dataService.isElectron &&
+11
View File
@@ -100,6 +100,17 @@ app-root.macos-platform app-navigation .portal-status-container {
padding-top: 30px;
}
// Windows/Linux frameless title bar: reserve room in top-aligned drag
// regions for the renderer-drawn window controls (3 × 46px buttons).
// Body-level class so it also reaches cdk-overlay content (multi-EPG).
body.frameless-platform .workspace-header {
padding-right: 150px;
}
body.frameless-platform #epg-navigation {
padding-right: 150px;
}
// ─── Base ─────────────────────────────────────────────────────────────────────
body,
html {
+101 -16
View File
@@ -74,20 +74,20 @@ Provider route integration:
The shell is intentionally split into four persistent regions:
1. Left rail:
1. Static workspace links for dashboard, sources, global favorites, and recently viewed.
2. Provider-aware context links derived from the active or current playlist.
3. Settings remains a persistent footer shortcut in the rail.
1. Static workspace links for dashboard, sources, global favorites, and recently viewed.
2. Provider-aware context links derived from the active or current playlist.
3. Settings remains a persistent footer shortcut in the rail.
2. Top header:
1. Playlist switcher.
2. Route-aware search input and command palette trigger.
3. Add source action.
4. Optional playlist refresh and route-specific shortcut actions.
5. Downloads shortcut in Electron.
1. Playlist switcher.
2. Route-aware search input and command palette trigger.
3. Add source action.
4. Optional playlist refresh and route-specific shortcut actions.
5. Downloads shortcut in Electron.
3. Main body:
1. Optional left context panel.
2. Main router outlet content.
1. Optional left context panel.
2. Main router outlet content.
4. Optional footer:
1. External playback session bar when a docked session is visible.
1. External playback session bar when a docked session is visible.
`WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is
kept as a thin template-facing API and delegates ownership to component-scoped
@@ -116,15 +116,15 @@ for the template unless the template contract itself intentionally changes.
The shell decides which secondary panel to show from the current route:
1. `/workspace/sources`
1. `WorkspaceSourcesFiltersPanelComponent`
1. `WorkspaceSourcesFiltersPanelComponent`
2. Xtream category sections (`live`, `vod`, `series`)
1. `WorkspaceContextPanelComponent`
1. `WorkspaceContextPanelComponent`
3. Stalker category sections (`itv`, `radio`, `vod`, `series`)
1. `WorkspaceContextPanelComponent`
1. `WorkspaceContextPanelComponent`
4. `/workspace/settings`
1. `WorkspaceSettingsContextPanelComponent`
1. `WorkspaceSettingsContextPanelComponent`
5. Downloads sections
1. `WorkspaceCollectionContextPanelComponent`
1. `WorkspaceCollectionContextPanelComponent`
The context panel is part of the shell contract. New workspace-level routes
should explicitly decide whether they need one rather than adding local
@@ -197,6 +197,91 @@ Keyboard shortcut help is shell-owned:
added. Do not include native browser/editor behavior such as `Tab` or
platform text editing shortcuts.
## Window Chrome And Custom Title Bar
The Electron window hides the native title bar on all desktop platforms
(`titleBarStyle: 'hidden'` in `apps/electron-backend/src/app/app.ts`):
1. macOS keeps the native traffic lights (`titleBarOverlay: true`,
`trafficLightPosition`); the renderer draws no window buttons.
2. Windows and Linux use renderer-drawn window controls
(`app-window-controls`, `libs/ui/components/src/lib/window-controls/`).
`frame` is intentionally left untouched so native resize borders and
window snapping keep working.
The controls are mounted once in `app-root` (not inside the workspace
header) as a `position: fixed` top-right overlay so they stay clickable
above full-window content such as the multi-EPG cdk overlay and Material
dialog backdrops — the same behavior as the macOS traffic lights. Because
CDK overlays render as popovers in the browser top layer (above any
z-index), the component host is itself a `popover="manual"` element: it
enters the top layer on init and re-enters it (hide + show) whenever
another popover opens, so the controls always paint last. The
`z-index: 10000` remains only as a fallback when the popover API is
unavailable. They render only when
`RuntimeCapabilitiesService.usesCustomWindowControls` is true (Windows/Linux
Electron with the window-control bridge methods available); the PWA and
macOS never mount them.
IPC contract (constants in `libs/shared/interfaces/src/lib/ipc-commands.ts`,
handlers in `apps/electron-backend/src/app/events/window.events.ts`):
1. `WINDOW:MINIMIZE`, `WINDOW:TOGGLE_MAXIMIZE`, `WINDOW:CLOSE`,
`WINDOW:GET_STATE` are `ipcMain.handle` channels resolved from the sender
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.
Layout integration:
1. `document.body` gets a `frameless-platform` class (set in
`AppComponent`, same mechanism as `dark-theme`) — body-level so rules
also reach cdk-overlay content rendered outside `app-root`.
2. `apps/web/src/styles.scss` reserves `padding-right: 150px` in
top-aligned drag regions (`.workspace-header`, multi-EPG
`#epg-navigation`) for the 3 × 46px button strip.
3. Button colors follow the theme via CSS variables (`--app-on-surface`,
`--app-hover-overlay`); the close button uses the Windows-style red
hover (`#e81123`). No theme IPC is involved.
Window decorations on Linux (shadows, corners):
1. Hiding the title bar removes the window manager's decorations, so the
shadow/rounded corners must come from client-side decorations (CSD).
Electron only draws CSD on native Wayland, and frameless-window CSD
(GTK drop shadow + extended resize boundaries, `hasShadow: true` by
default) requires **Electron >= 41** — the reason the dependency was
bumped from 39. Electron picks Wayland automatically on Wayland
sessions since 38.2.
2. On X11 sessions frameless windows stay undecorated (square, no
shadow) — an upstream platform limitation shared by e.g. VS Code.
3. Rounded corners for frameless Linux windows are not yet supported by
Electron (tracked upstream as planned work); Windows 11 keeps its DWM
rounded corners and shadow because the standard frame is retained.
Toolchain notes for the Electron 41 upgrade:
1. `better-sqlite3` is pinned to exactly `12.9.0` — the last release that
ships prebuilt binaries for BOTH Node 20 (ABI 115, used by Jest) and
Electron 41 (ABI 145, used at runtime). `12.10.0` dropped the Node 20
prebuilds, which forces a from-source build that fails on machines
without a C++ toolchain.
2. The pnpm override `node-abi@3.85.0 -> 3.92.0` is required so
`@electron/rebuild` (via `electron-builder install-app-deps`) can map
Electron 41 to its ABI.
Known caveats:
1. DIY buttons cannot show the Windows 11 Snap Layouts flyout (only native
caption buttons or the Window Controls Overlay get that).
2. Double-click-to-maximize on drag regions is handled natively by
Electron/Chromium; on Linux the exact behavior depends on the window
manager.
## Maintenance Guidance
Use this document as the source of truth when changing workspace shell behavior.
@@ -38,6 +38,28 @@ export class RuntimeCapabilitiesService {
return this.platform === 'darwin';
}
get isWindows(): boolean {
return this.platform === 'win32';
}
get isLinux(): boolean {
return this.platform === 'linux';
}
/**
* True when the window has no native title bar and the renderer must
* draw its own window-management buttons (Windows/Linux). macOS keeps
* the native traffic lights instead.
*/
get usesCustomWindowControls(): boolean {
return (
(this.isWindows || this.isLinux) &&
['minimizeWindow', 'toggleMaximizeWindow', 'closeWindow'].every(
(methodName) => this.hasElectronMethod(methodName)
)
);
}
get supportsEpg(): boolean {
return (
this.supportsEpgImport &&
@@ -135,6 +135,11 @@ export interface ElectronBridgeDialogFilter {
extensions: string[];
}
export interface ElectronBridgeWindowState {
isMaximized: boolean;
isFullScreen: boolean;
}
export interface ElectronBridgeAiSettings {
aiProvider: string;
aiModelName: string;
@@ -425,6 +430,13 @@ export interface ElectronBridgeApi {
) => () => void;
getAppVersion: () => Promise<string>;
platform: string;
minimizeWindow: () => Promise<void>;
toggleMaximizeWindow: () => Promise<ElectronBridgeWindowState>;
closeWindow: () => Promise<void>;
getWindowState: () => Promise<ElectronBridgeWindowState>;
onWindowStateChange: (
callback: (state: ElectronBridgeWindowState) => void
) => () => void;
fetchPlaylistByUrl: (url: string, title?: string) => Promise<Playlist>;
updatePlaylistFromFilePath: (
filePath: string,
@@ -75,3 +75,10 @@ export const DELETE_ALL_PLAYLISTS = 'DELETE_ALL_PLAYLISTS';
// Remote Control
export const REMOTE_CONTROL_CHANGE_CHANNEL = 'REMOTE_CONTROL_CHANGE_CHANNEL';
// Window controls (custom title bar on Windows/Linux)
export const WINDOW_MINIMIZE = 'WINDOW:MINIMIZE';
export const WINDOW_TOGGLE_MAXIMIZE = 'WINDOW:TOGGLE_MAXIMIZE';
export const WINDOW_CLOSE = 'WINDOW:CLOSE';
export const WINDOW_GET_STATE = 'WINDOW:GET_STATE';
export const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED';
+1
View File
@@ -12,3 +12,4 @@ export * from './lib/progress-capsule/progress-capsule.component';
export * from './lib/resizable/resizable.directive';
export * from './lib/season-container/season-container.component';
export * from './lib/watched-badge/watched-badge.component';
export * from './lib/window-controls/window-controls.component';
@@ -0,0 +1,55 @@
<button
type="button"
class="window-control"
data-test-id="window-minimize"
aria-label="Minimize"
title="Minimize"
(click)="onMinimize()"
>
<svg viewBox="0 0 10 10" class="crisp" aria-hidden="true">
<path d="M0 5h10" />
</svg>
</button>
<button
type="button"
class="window-control"
data-test-id="window-maximize"
[attr.aria-label]="isMaximized() ? 'Restore' : 'Maximize'"
[attr.title]="isMaximized() ? 'Restore' : 'Maximize'"
(click)="onToggleMaximize()"
>
@if (isMaximized()) {
<svg
viewBox="0 0 10 10"
class="crisp"
data-test-id="window-restore-glyph"
aria-hidden="true"
>
<path d="M0.5 2.5h7v7h-7z" />
<path d="M2.5 2.5v-2h7v7h-2" />
</svg>
} @else {
<svg
viewBox="0 0 10 10"
class="crisp"
data-test-id="window-maximize-glyph"
aria-hidden="true"
>
<path d="M0.5 0.5h9v9h-9z" />
</svg>
}
</button>
<button
type="button"
class="window-control close"
data-test-id="window-close"
aria-label="Close"
title="Close"
(click)="onClose()"
>
<svg viewBox="0 0 10 10" aria-hidden="true">
<path d="M0.5 0.5l9 9m0 -9l-9 9" />
</svg>
</button>
@@ -0,0 +1,88 @@
// Floats above all app content so the window stays controllable while
// dialogs or the multi-EPG overlay are open — same behavior as the macOS
// traffic lights. The host is a manual popover (see component) because CDK
// overlays live in the browser top layer where z-index cannot compete; the
// declarations below also neutralize the UA popover styles (inset, margin,
// border, background) and keep the strip visible/positioned when the
// popover API is unavailable.
:host {
position: fixed;
inset: 0 0 auto auto;
z-index: 10000;
display: flex;
width: fit-content;
height: fit-content;
margin: 0;
padding: 0;
border: none;
background: transparent;
overflow: visible;
color: inherit;
app-region: no-drag;
-webkit-app-region: no-drag;
}
:host(.is-hidden) {
display: none;
}
.window-control {
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 32px;
margin: 0;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
color: var(--app-on-surface, #1d1b1e);
opacity: 0.85;
// Native caption buttons use the arrow cursor, not the hand.
cursor: default;
svg {
display: block;
width: 10px;
height: 10px;
}
svg.crisp {
shape-rendering: crispEdges;
}
path {
fill: none;
stroke: currentColor;
stroke-width: 1;
}
&:hover {
background: var(--app-hover-overlay, rgba(0, 0, 0, 0.06));
opacity: 1;
}
&:active {
background: color-mix(
in srgb,
var(--app-on-surface, #1d1b1e) 14%,
transparent
);
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: -2px;
}
&.close:hover {
background: #e81123;
color: #fff;
}
&.close:active {
background: #c50f1f;
color: #fff;
}
}
@@ -0,0 +1,119 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ElectronBridgeWindowState } from '@iptvnator/shared/interfaces';
import { WindowControlsComponent } from './window-controls.component';
describe('WindowControlsComponent', () => {
let fixture: ComponentFixture<WindowControlsComponent>;
let stateChangeCallback:
| ((state: ElectronBridgeWindowState) => void)
| undefined;
const unsubscribe = jest.fn();
const electronMock = {
getWindowState: jest.fn(),
onWindowStateChange: jest.fn(),
minimizeWindow: jest.fn(),
toggleMaximizeWindow: jest.fn(),
closeWindow: jest.fn(),
};
const query = (testId: string): HTMLElement | null =>
fixture.nativeElement.querySelector(`[data-test-id="${testId}"]`);
beforeEach(async () => {
jest.clearAllMocks();
stateChangeCallback = undefined;
electronMock.getWindowState.mockResolvedValue({
isMaximized: false,
isFullScreen: false,
});
electronMock.toggleMaximizeWindow.mockResolvedValue({
isMaximized: true,
isFullScreen: false,
});
electronMock.minimizeWindow.mockResolvedValue(undefined);
electronMock.closeWindow.mockResolvedValue(undefined);
electronMock.onWindowStateChange.mockImplementation(
(callback: (state: ElectronBridgeWindowState) => void) => {
stateChangeCallback = callback;
return unsubscribe;
}
);
(window as { electron?: unknown }).electron = electronMock;
await TestBed.configureTestingModule({
imports: [WindowControlsComponent],
}).compileComponents();
fixture = TestBed.createComponent(WindowControlsComponent);
fixture.detectChanges();
});
afterEach(() => {
delete (window as { electron?: unknown }).electron;
});
it('renders minimize, maximize and close buttons with the maximize glyph', () => {
expect(query('window-minimize')).not.toBeNull();
expect(query('window-maximize')).not.toBeNull();
expect(query('window-close')).not.toBeNull();
expect(query('window-maximize-glyph')).not.toBeNull();
expect(query('window-restore-glyph')).toBeNull();
expect(electronMock.getWindowState).toHaveBeenCalled();
});
it('calls the bridge methods when the buttons are clicked', () => {
query('window-minimize')?.click();
expect(electronMock.minimizeWindow).toHaveBeenCalledTimes(1);
query('window-maximize')?.click();
expect(electronMock.toggleMaximizeWindow).toHaveBeenCalledTimes(1);
query('window-close')?.click();
expect(electronMock.closeWindow).toHaveBeenCalledTimes(1);
});
it('swaps to the restore glyph when the window state reports maximized', () => {
stateChangeCallback?.({ isMaximized: true, isFullScreen: false });
fixture.detectChanges();
expect(query('window-restore-glyph')).not.toBeNull();
expect(query('window-maximize-glyph')).toBeNull();
expect(query('window-maximize')?.getAttribute('aria-label')).toBe(
'Restore'
);
});
it('applies the restore state returned by toggleMaximizeWindow', async () => {
query('window-maximize')?.click();
await fixture.whenStable();
fixture.detectChanges();
expect(query('window-restore-glyph')).not.toBeNull();
});
it('hides the host while the window is in fullscreen', () => {
stateChangeCallback?.({ isMaximized: false, isFullScreen: true });
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).classList.contains(
'is-hidden'
)
).toBe(true);
stateChangeCallback?.({ isMaximized: false, isFullScreen: false });
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).classList.contains(
'is-hidden'
)
).toBe(false);
});
it('unsubscribes from window state changes on destroy', () => {
fixture.destroy();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,126 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
inject,
signal,
} from '@angular/core';
import { ElectronBridgeWindowState } from '@iptvnator/shared/interfaces';
/**
* Renderer-drawn window-management buttons (minimize / maximize-restore /
* close) for the frameless title bar on Windows and Linux. macOS keeps the
* native traffic lights instead.
*
* Rendered once in the app root so the buttons stay reachable above
* full-window content such as the multi-EPG overlay and dialog backdrops —
* mirroring how the macOS traffic lights float above everything. CDK
* overlays render as popovers in the browser top layer (above any
* z-index), so the host is itself a manual popover and re-enters the top
* layer whenever another popover opens, keeping the controls topmost.
* Hidden while the window is in fullscreen, matching native title-bar
* behavior.
*/
@Component({
selector: 'app-window-controls',
templateUrl: './window-controls.component.html',
styleUrl: './window-controls.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
popover: 'manual',
'[class.is-hidden]': 'isFullScreen()',
},
})
export class WindowControlsComponent implements AfterViewInit {
readonly isMaximized = signal(false);
readonly isFullScreen = signal(false);
private readonly host =
inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
constructor() {
const bridge = window.electron;
void bridge
?.getWindowState?.()
.then((state) => this.applyState(state))
.catch(() => undefined);
const unsubscribe = bridge?.onWindowStateChange?.((state) =>
this.applyState(state)
);
document.addEventListener('toggle', this.onDocumentToggle, true);
inject(DestroyRef).onDestroy(() => {
unsubscribe?.();
document.removeEventListener('toggle', this.onDocumentToggle, true);
});
}
ngAfterViewInit(): void {
this.enterTopLayer();
}
onMinimize(): void {
void window.electron?.minimizeWindow?.();
}
onToggleMaximize(): void {
void window.electron
?.toggleMaximizeWindow?.()
.then((state) => this.applyState(state))
.catch(() => undefined);
}
onClose(): void {
void window.electron?.closeWindow?.();
}
private applyState(state: ElectronBridgeWindowState | undefined): void {
if (!state) {
return;
}
this.isMaximized.set(state.isMaximized);
this.isFullScreen.set(state.isFullScreen);
}
/**
* Re-enter the top layer after any other popover opens: top-layer
* elements paint in insertion order, so hiding and re-showing puts the
* controls back above freshly opened CDK overlays (dialogs, multi-EPG).
*/
private readonly onDocumentToggle = (event: Event): void => {
const newState = (event as { newState?: string }).newState;
if (event.target === this.host || newState !== 'open') {
return;
}
requestAnimationFrame(() => this.enterTopLayer(true));
};
private enterTopLayer(reassert = false): void {
if (
typeof this.host.showPopover !== 'function' ||
!this.host.isConnected
) {
return;
}
try {
if (this.host.matches(':popover-open')) {
if (!reassert) {
return;
}
this.host.hidePopover();
}
this.host.showPopover();
} catch {
// Stays a regular fixed element when the popover API is
// unavailable — still above non-top-layer content via z-index.
}
}
}