Files
iptvnator/libs/services/src/lib/runtime-capabilities.service.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

282 lines
8.2 KiB
TypeScript

import { Injectable } from '@angular/core';
import { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
export type RuntimeEnvironment = 'electron' | 'pwa';
type RuntimeElectronBridge = Partial<ElectronBridgeApi>;
const playbackPositionStorageMethods = [
'dbSavePlaybackPosition',
'dbGetPlaybackPosition',
'dbGetSeriesPlaybackPositions',
'dbGetRecentPlaybackPositions',
'dbGetAllPlaybackPositions',
'dbClearAllPlaybackPositions',
'dbClearPlaybackPosition',
];
@Injectable({ providedIn: 'root' })
export class RuntimeCapabilitiesService {
get environment(): RuntimeEnvironment {
return this.isElectron ? 'electron' : 'pwa';
}
get isElectron(): boolean {
return !!this.electronBridge;
}
get isPwa(): boolean {
return !this.isElectron;
}
get platform(): string | undefined {
const platform = this.electronBridge?.['platform'];
return typeof platform === 'string' ? platform : undefined;
}
get isMacOS(): boolean {
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',
'getWindowState',
'onWindowStateChange',
].every((methodName) => this.hasElectronMethod(methodName))
);
}
get supportsEpg(): boolean {
return (
this.supportsEpgImport &&
this.supportsEpgProgramLookup &&
this.supportsEpgSourceFreshness &&
this.supportsEpgDataManagement &&
this.supportsEpgChannelBrowser &&
this.supportsEpgProgramSearch
);
}
get supportsEpgImport(): boolean {
return this.hasElectronMethod('fetchEpg');
}
get supportsEpgProgress(): boolean {
return this.hasElectronMethod('onEpgProgress');
}
get supportsEpgProgramLookup(): boolean {
return this.hasElectronMethod('getChannelPrograms');
}
get supportsEpgCurrentProgramBatch(): boolean {
return this.hasElectronMethod('getCurrentProgramsBatch');
}
get supportsEpgChannelMetadata(): boolean {
return this.hasElectronMethod('getEpgChannelMetadata');
}
get supportsEpgSourceFreshness(): boolean {
return this.hasElectronMethod('checkEpgFreshness');
}
get supportsEpgDataManagement(): boolean {
return (
this.hasElectronMethod('forceFetchEpg') &&
this.hasElectronMethod('clearEpgData')
);
}
get supportsEpgChannelBrowser(): boolean {
return this.hasElectronMethod('getEpgChannelsByRange');
}
get supportsEpgProgramSearch(): boolean {
return this.hasElectronMethod('searchEpgPrograms');
}
get supportsSqlite(): boolean {
return [
'dbDeleteAllPlaylists',
'dbDeletePlaylist',
'dbGetAppPlaylist',
'dbGetAppPlaylists',
'dbGetAppState',
'dbSetAppState',
'dbUpsertAppPlaylist',
'dbUpsertAppPlaylists',
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsXtreamSqliteDataSource(): boolean {
return [
'dbGetPlaylist',
'dbCreatePlaylist',
'dbUpdatePlaylist',
'dbDeletePlaylist',
'dbHasCategories',
'dbGetCategories',
'dbSaveCategories',
'dbGetAllCategories',
'dbUpdateCategoryVisibility',
'dbHasContent',
'dbGetContent',
'dbSaveContent',
'dbGetAppState',
'dbSetAppState',
'dbSearchContent',
'dbGetFavorites',
'dbAddFavorite',
'dbRemoveFavorite',
'dbIsFavorite',
'dbGetRecentItems',
'dbAddRecentItem',
'dbRemoveRecentItem',
'dbClearPlaylistRecentItems',
'dbGetContentByXtreamId',
...playbackPositionStorageMethods,
'dbDeleteXtreamContent',
'dbRestoreXtreamUserData',
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsPlaybackPositionStorage(): boolean {
return playbackPositionStorageMethods.every((methodName) =>
this.hasElectronMethod(methodName)
);
}
get supportsPlaybackPositionUpdates(): boolean {
return this.hasElectronMethod('onPlaybackPositionUpdate');
}
get supportsDownloads(): boolean {
return [
'downloadsStart',
'downloadsCancel',
'downloadsRetry',
'downloadsRemove',
'downloadsGetList',
'downloadsGet',
'downloadsGetDefaultFolder',
'downloadsSelectFolder',
'downloadsRevealFile',
'downloadsPlayFile',
'downloadsClearCompleted',
'onDownloadsUpdate',
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsPortalActivityStorage(): boolean {
return [
'dbGetRecentlyViewed',
'dbClearRecentlyViewed',
'dbGetAllGlobalFavorites',
'dbGetGlobalRecentlyAdded',
'dbAddFavorite',
'dbRemoveFavorite',
'dbGetFavorites',
'dbReorderGlobalFavorites',
'dbGetRecentItems',
'dbAddRecentItem',
'dbClearPlaylistRecentItems',
'dbRemoveRecentItem',
'dbRemoveRecentItemsBatch',
'dbGetContentByXtreamId',
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsAppStateStorage(): boolean {
return ['dbGetAppState', 'dbSetAppState'].every((methodName) =>
this.hasElectronMethod(methodName)
);
}
get supportsStalkerPlaylistSqliteSync(): boolean {
return ['dbGetPlaylist', 'dbCreatePlaylist'].every((methodName) =>
this.hasElectronMethod(methodName)
);
}
get supportsPlaylistRefresh(): boolean {
return [
'refreshPlaylist',
'cancelPlaylistRefresh',
'onPlaylistRefreshEvent',
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsManagedExternalPlayers(): boolean {
return ['openInMpv', 'openInVlc'].every((methodName) =>
this.hasElectronMethod(methodName)
);
}
get supportsExternalPlayerPathSettings(): boolean {
return ['setMpvPlayerPath', 'setVlcPlayerPath'].every((methodName) =>
this.hasElectronMethod(methodName)
);
}
get supportsEmbeddedMpv(): boolean {
return this.hasElectronMethod('prepareEmbeddedMpv');
}
get supportsDesktopFileSave(): boolean {
return (
this.hasElectronMethod('saveFileDialog') &&
this.hasElectronMethod('writeFile')
);
}
get supportsRemoteControl(): boolean {
return (
this.hasElectronMethod('updateRemoteControlStatus') &&
this.hasElectronMethod('onChannelChange') &&
this.hasElectronMethod('onRemoteControlCommand')
);
}
get supportsXtreamSectionNavigation(): boolean {
return (
this.isPwa ||
this.supportsXtreamSqliteDataSource ||
this.hasElectronMethod('xtreamRequest')
);
}
private hasElectronMethod(methodName: string): boolean {
const bridge = this.electronBridge as
| Record<string, unknown>
| undefined;
return typeof bridge?.[methodName] === 'function';
}
private get electronBridge(): RuntimeElectronBridge | undefined {
if (typeof window === 'undefined') {
return undefined;
}
return window.electron;
}
}