mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
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:
1 parent
2be42257ab
commit
8e0abe6feb
20 files changed
+1041
-200
No files matched your search
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user