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>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-06-11 12:03:27 +02:00
1 parent 2be42257ab
commit 8e0abe6feb
20 files changed
+1041 -200

No files matched your search

+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.
}
}
}