Merge pull request #965 from 4gray/agent/runtime-capabilities

refactor(runtime): centralize platform capabilities
This commit is contained in:
4gray authored and GitHub committed 2026-05-22 11:08:40 +03:00
commit 6a4c4c49c2
27 files changed
+314 -71

No files matched your search

+7
View File
@@ -64,6 +64,13 @@ The PWA continues to use `PwaService`; only the backend base URL is resolved at
runtime. Electron routes remain owned by the Electron backend and preload
bridge.
Renderer code that needs to branch by runtime should use
`RuntimeCapabilitiesService` from `@iptvnator/services` instead of adding new
direct `window.electron` or `DataService.getAppEnvironment()` checks. Keep
feature decisions expressed as capabilities such as `supportsEpg`,
`supportsSqlite`, `supportsDownloads`, or `supportsManagedExternalPlayers` so
PWA and Electron behavior stays auditable from one shared boundary.
## Runtime Limitations
The self-hosted build is the browser PWA, not the Electron desktop app. Keep
@@ -31,6 +31,7 @@ import {
import {
DataService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import {
@@ -255,9 +256,6 @@ describe('VideoPlayerComponent', () => {
),
};
const dataServiceMock = {
get supportsEpg() {
return Boolean(window.electron);
},
sendIpcEvent: jest.fn(),
};
@@ -336,6 +334,17 @@ describe('VideoPlayerComponent', () => {
provide: DataService,
useValue: dataServiceMock,
},
{
provide: RuntimeCapabilitiesService,
useValue: {
get supportsEpg() {
return Boolean(window.electron);
},
get isElectron() {
return Boolean(window.electron);
},
},
},
{
provide: PlaylistsService,
useValue: playlistsServiceMock,
@@ -80,6 +80,7 @@ import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components';
import {
DataService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import {
@@ -133,6 +134,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
private readonly playlistsService = inject(PlaylistsService);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly router = inject(Router);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
@@ -250,8 +252,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
showCaptions: false,
};
readonly isDesktop = !!window['electron'];
readonly supportsEpg = this.dataService.supportsEpg;
readonly isDesktop = this.runtime.isElectron;
readonly supportsEpg = this.runtime.supportsEpg;
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
/** EPG overlay reference */
@@ -36,6 +36,7 @@ import {
PlaylistsService,
PortalStatus,
PortalStatusService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { firstValueFrom, startWith } from 'rxjs';
@@ -80,6 +81,7 @@ export class PlaylistSwitcherComponent {
private readonly dialogService = inject(DialogService);
private readonly databaseService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private focusSearchTimeoutId: ReturnType<typeof setTimeout> | null = null;
@@ -313,7 +315,7 @@ export class PlaylistSwitcherComponent {
private async removePlaylistConfirmed(
playlist: PlaylistMeta
): Promise<void> {
const deleted = window.electron
const deleted = this.runtime.isElectron
? await this.deletePlaylistInElectron(playlist)
: await this.deletePlaylistInBrowser(playlist);
@@ -36,6 +36,7 @@ import {
PlaybackPositionService,
PlaylistRefreshService,
PlaylistsService,
RuntimeCapabilitiesService,
SortBy,
SortService,
XtreamPendingRestoreService,
@@ -83,6 +84,7 @@ export class RecentPlaylistsComponent {
private readonly snackBar = inject(MatSnackBar);
private readonly sortService = inject(SortService);
private readonly store = inject(Store);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly translate = inject(TranslateService);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly playlistsService = inject(PlaylistsService);
@@ -95,7 +97,7 @@ export class RecentPlaylistsComponent {
readonly playlistClicked = output<string>();
readonly addPlaylistClicked = output<PlaylistType | undefined>();
readonly isElectron = !!window.electron;
readonly isElectron = this.runtime.isElectron;
readonly allPlaylistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
@@ -5,7 +5,11 @@ import {
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import { DataService, PlaylistsService } from '@iptvnator/services';
import {
DataService,
PlaylistsService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import {
@@ -18,7 +22,7 @@ describe('StreamResolverService', () => {
let playlistsService: { getPlaylistById: jest.Mock };
let xtreamApi: { getShortEpg: jest.Mock };
let xtreamUrl: { constructLiveUrl: jest.Mock };
let dataService: { readonly supportsEpg: boolean; sendIpcEvent: jest.Mock };
let dataService: { sendIpcEvent: jest.Mock };
let stalkerSession: { makeAuthenticatedRequest: jest.Mock };
beforeEach(() => {
@@ -32,9 +36,6 @@ describe('StreamResolverService', () => {
constructLiveUrl: jest.fn(),
};
dataService = {
get supportsEpg() {
return Boolean(window.electron);
},
sendIpcEvent: jest.fn(),
};
stalkerSession = {
@@ -53,6 +54,14 @@ describe('StreamResolverService', () => {
{ provide: XtreamApiService, useValue: xtreamApi },
{ provide: XtreamUrlService, useValue: xtreamUrl },
{ provide: DataService, useValue: dataService },
{
provide: RuntimeCapabilitiesService,
useValue: {
get supportsEpg() {
return Boolean(window.electron);
},
},
},
{ provide: StalkerSessionService, useValue: stalkerSession },
],
});
@@ -1,6 +1,10 @@
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { DataService, PlaylistsService } from '@iptvnator/services';
import {
DataService,
PlaylistsService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
Channel,
EpgItem,
@@ -59,6 +63,7 @@ export class StreamResolverService {
private readonly xtreamApi = inject(XtreamApiService);
private readonly xtreamUrl = inject(XtreamUrlService);
private readonly dataService = inject(DataService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly m3uEpgTimeoutMs = 3000;
private readonly portalEpgTimeoutMs = 3000;
@@ -68,7 +73,7 @@ export class StreamResolverService {
private readonly xtreamEpgFailureCooldownMs = 60 * 1000;
private get supportsEpg(): boolean {
return this.dataService.supportsEpg;
return this.runtime.supportsEpg;
}
private async getElectronPlaylist(
@@ -23,7 +23,10 @@ import {
shiftEpgDateKey,
} from '@iptvnator/ui/epg';
import { ResizableDirective } from '@iptvnator/ui/components';
import { DataService, SettingsStore } from '@iptvnator/services';
import {
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import {
Channel,
EpgItem,
@@ -193,7 +196,7 @@ describe('UnifiedLiveTabComponent', () => {
{ provide: StreamResolverService, useValue: streamResolver },
{ provide: UnifiedRecentDataService, useValue: recentData },
{
provide: DataService,
provide: RuntimeCapabilitiesService,
useValue: {
get supportsEpg() {
return Boolean(window.electron);
@@ -45,7 +45,7 @@ import {
WebPlayerViewComponent,
} from '@iptvnator/ui/playback';
import { ResizableDirective } from '@iptvnator/ui/components';
import { DataService, SettingsStore } from '@iptvnator/services';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces';
import {
EpgViewComponent,
@@ -92,13 +92,13 @@ export class UnifiedLiveTabComponent {
private readonly streamResolver = inject(StreamResolverService);
private readonly recentData = inject(UnifiedRecentDataService);
private readonly dataService = inject(DataService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly destroyRef = inject(DestroyRef);
readonly player = this.settingsStore.player;
readonly supportsEpg = this.dataService.supportsEpg;
readonly supportsEpg = this.runtime.supportsEpg;
readonly isEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
);
@@ -1,6 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { signalStore, withState } from '@ngrx/signals';
import { DataService } from '@iptvnator/services';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import { EpgItem, Playlist } from '@iptvnator/shared/interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
import { withStalkerEpg } from './with-stalker-epg.feature';
@@ -36,18 +36,16 @@ const TestStalkerEpgStore = signalStore(
describe('withStalkerEpg', () => {
let store: InstanceType<typeof TestStalkerEpgStore>;
let dataService: {
getAppEnvironment: jest.Mock<string, []>;
supportsEpg: boolean;
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
};
let runtimeSupportsEpg: boolean;
let stalkerSessionService: {
makeAuthenticatedRequest: jest.Mock<Promise<unknown>, unknown[]>;
};
beforeEach(() => {
runtimeSupportsEpg = true;
dataService = {
getAppEnvironment: jest.fn(() => 'electron'),
supportsEpg: true,
sendIpcEvent: jest.fn(),
};
stalkerSessionService = {
@@ -58,6 +56,14 @@ describe('withStalkerEpg', () => {
providers: [
TestStalkerEpgStore,
{ provide: DataService, useValue: dataService },
{
provide: RuntimeCapabilitiesService,
useValue: {
get supportsEpg() {
return runtimeSupportsEpg;
},
},
},
{
provide: StalkerSessionService,
useValue: stalkerSessionService,
@@ -98,8 +104,7 @@ describe('withStalkerEpg', () => {
});
it('does not request short EPG in browser/PWA mode', async () => {
dataService.getAppEnvironment.mockReturnValue('pwa');
dataService.supportsEpg = false;
runtimeSupportsEpg = false;
const result = await store.fetchChannelEpg('10001');
@@ -7,7 +7,7 @@ import {
withState,
} from '@ngrx/signals';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService } from '@iptvnator/services';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import {
EpgItem,
EpgProgram,
@@ -98,7 +98,8 @@ export function withStalkerEpg() {
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
stalkerSession = inject(StalkerSessionService),
runtime = inject(RuntimeCapabilitiesService)
) => {
const storeContext = store as typeof store &
StalkerEpgFeatureStoreContract;
@@ -106,6 +107,8 @@ export function withStalkerEpg() {
dataService,
stalkerSession,
};
const supportsEpg = (): boolean => runtime.supportsEpg;
const requestEpg = async (
playlist: NonNullable<
ReturnType<
@@ -125,7 +128,7 @@ export function withStalkerEpg() {
channelId: number | string,
size: number
): Promise<EpgItem[]> => {
if (!dataService.supportsEpg) {
if (!supportsEpg()) {
return [];
}
@@ -182,7 +185,7 @@ export function withStalkerEpg() {
}
const playlistId = String(playlist._id);
if (!dataService.supportsEpg) {
if (!supportsEpg()) {
patchState(store, {
bulkItvEpgByChannel: {},
bulkItvEpgLoaded: true,
@@ -17,8 +17,8 @@ import { ChannelListItemComponent } from '@iptvnator/ui/components';
import { MockPipe } from 'ng-mocks';
import { of } from 'rxjs';
import {
DataService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
@@ -313,11 +313,14 @@ describe('StalkerLiveStreamLayoutComponent', () => {
providers: [
{ provide: StalkerStore, useValue: stalkerStore },
{
provide: DataService,
provide: RuntimeCapabilitiesService,
useValue: {
get supportsEpg() {
return Boolean(window.electron);
},
get isElectron() {
return Boolean(window.electron);
},
},
},
{ provide: PlaylistsService, useValue: playlistService },
@@ -26,8 +26,8 @@ import {
ResizableDirective,
} from '@iptvnator/ui/components';
import {
DataService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import {
@@ -100,8 +100,8 @@ type StalkerPlayableChannel = StalkerPortalItem & {
})
export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly stalkerStore = inject(StalkerStore);
private readonly dataService = inject(DataService);
private readonly playlistService = inject(PlaylistsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly snackBar = inject(MatSnackBar);
@@ -149,8 +149,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly selectedChannelId = this.stalkerStore.selectedItvId;
protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId;
readonly isElectron = Boolean(window.electron);
readonly supportsEpg = this.dataService.supportsEpg;
readonly isElectron = this.runtime.isElectron;
readonly supportsEpg = this.runtime.supportsEpg;
readonly openStreamOnDoubleClick = computed(() =>
this.settingsStore.openStreamOnDoubleClick()
);
@@ -1,5 +1,8 @@
import { inject, Provider } from '@angular/core';
import { PLAYLIST_DELETE_CLEANUP } from '@iptvnator/services';
import {
PLAYLIST_DELETE_CLEANUP,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { ElectronXtreamDataSource } from './electron-xtream-data-source';
import { PwaXtreamDataSource } from './pwa-xtream-data-source';
import {
@@ -18,12 +21,12 @@ export { PwaXtreamDataSource } from './pwa-xtream-data-source';
* - PWA: Uses API-only with in-memory caching and localStorage for user data
*/
export function xtreamDataSourceFactory(): IXtreamDataSource {
// Check if we're in Electron environment
if (typeof window !== 'undefined' && window.electron) {
const runtime = inject(RuntimeCapabilitiesService);
if (runtime.isElectron) {
return inject(ElectronXtreamDataSource);
}
// Default to PWA implementation
return inject(PwaXtreamDataSource);
}
@@ -44,9 +47,10 @@ export function provideXtreamDataSource(): Provider[] {
multi: true,
useFactory: () => {
const dataSource = inject(XTREAM_DATA_SOURCE);
const runtime = inject(RuntimeCapabilitiesService);
return (playlistId: string) => {
if (typeof window !== 'undefined' && window.electron) {
if (runtime.isElectron) {
// Electron playlist deletion must use DatabaseService so
// SQLite content and sidecars are cleaned together; do
// not invoke this cleanup path through PlaylistsService.
@@ -1,6 +1,10 @@
import { TestBed } from '@angular/core/testing';
import { signalStore, withState } from '@ngrx/signals';
import { DataService, SettingsStore } from '@iptvnator/services';
import {
DataService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { EpgItem } from '@iptvnator/shared/interfaces';
import { XtreamApiService } from '../../services/xtream-api.service';
import { XtreamXmltvFallbackService } from '../../services/xtream-xmltv-fallback.service';
@@ -90,6 +94,14 @@ function configureStore(setup: TestStoreSetup) {
(setup.isElectron ?? true),
},
},
{
provide: RuntimeCapabilitiesService,
useValue: {
get supportsEpg() {
return (setup.appEnvironment ?? 'electron') !== 'pwa';
},
},
},
{ provide: XtreamApiService, useValue: xtreamApiService },
{
provide: XtreamXmltvFallbackService,
@@ -7,7 +7,11 @@ import {
withState,
} from '@ngrx/signals';
import { EpgItem } from '@iptvnator/shared/interfaces';
import { DataService, SettingsStore } from '@iptvnator/services';
import {
DataService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import {
XtreamApiService,
XtreamCredentials,
@@ -82,8 +86,11 @@ export function withEpg() {
const apiService = inject(XtreamApiService);
const dataService = inject(DataService);
const fallbackService = inject(XtreamXmltvFallbackService);
const runtime = inject(RuntimeCapabilitiesService);
const settingsStore = inject(SettingsStore);
const supportsEpg = (): boolean => runtime.supportsEpg;
/**
* Helper to get credentials from parent store
*/
@@ -126,7 +133,7 @@ export function withEpg() {
* sets `preferUploadedEpgOverXtream`.
*/
async loadEpg(): Promise<EpgItem[]> {
if (!dataService.supportsEpg) {
if (!supportsEpg()) {
patchState(store, {
epgItems: [],
isLoadingEpg: false,
@@ -182,7 +189,7 @@ export function withEpg() {
streamId: number,
epgChannelId?: string | null
): Promise<EpgItem[]> {
if (!dataService.supportsEpg) {
if (!supportsEpg()) {
return [];
}
@@ -28,7 +28,10 @@ import {
import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
import { DataService, SettingsStore } from '@iptvnator/services';
import {
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@@ -225,11 +228,14 @@ describe('LiveStreamLayoutComponent', () => {
{ provide: FavoritesService, useValue: favoritesService },
{ provide: XtreamUrlService, useValue: xtreamUrlService },
{
provide: DataService,
provide: RuntimeCapabilitiesService,
useValue: {
get supportsEpg() {
return Boolean(window.electron);
},
get isElectron() {
return Boolean(window.electron);
},
},
},
{ provide: SettingsStore, useValue: settingsStore },
@@ -63,7 +63,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { ActivatedRoute } from '@angular/router';
import { DataService, SettingsStore } from '@iptvnator/services';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@@ -104,7 +104,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
private readonly favoritesService = inject(FavoritesService);
private readonly xtreamStore = inject(XtreamStore);
private readonly xtreamUrlService = inject(XtreamUrlService);
private readonly dataService = inject(DataService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly liveSidebarStateService = inject(
@@ -120,8 +120,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
readonly isLoadingEpg = this.xtreamStore.isLoadingEpg;
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
readonly liveChannelSortMode = signal<PortalChannelSortMode>('server');
readonly isElectron = Boolean(window.electron);
readonly supportsEpg = this.dataService.supportsEpg;
readonly isElectron = this.runtime.isElectron;
readonly supportsEpg = this.runtime.supportsEpg;
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
private readonly routeSearchTerm = queryParamSignal(
this.route,
@@ -10,7 +10,10 @@ import {
FavoritesService,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { DataService, SettingsStore } from '@iptvnator/services';
import {
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { PortalChannelsListComponent } from './portal-channels-list.component';
function buildEpgItem(params: {
@@ -129,7 +132,7 @@ describe('PortalChannelsListComponent', () => {
useValue: epgQueueService,
},
{
provide: DataService,
provide: RuntimeCapabilitiesService,
useValue: {
get supportsEpg() {
return Boolean(window.electron);
@@ -38,7 +38,7 @@ import { EpgQueueService } from '@iptvnator/portal/xtream/data-access';
import { XtreamCredentials } from '@iptvnator/portal/xtream/data-access';
import { FavoritesService } from '@iptvnator/portal/xtream/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { DataService } from '@iptvnator/services';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
export interface XtreamChannelListItem {
readonly category_id?: string | number;
@@ -78,11 +78,11 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
readonly searchTermInput = input('');
readonly xtreamStore = inject(XtreamStore);
private readonly dataService = inject(DataService);
private readonly favoritesService = inject(FavoritesService);
private readonly epgQueueService = inject(EpgQueueService);
private readonly route = inject(ActivatedRoute);
readonly supportsEpg = this.dataService.supportsEpg;
private readonly runtime = inject(RuntimeCapabilitiesService);
readonly supportsEpg = this.runtime.supportsEpg;
readonly isSelectedTypeContentLoading =
this.xtreamStore.selectedTypeContentLoading;
readonly channels = computed(() => {
+1
View File
@@ -7,6 +7,7 @@ export * from './lib/playlist-backup.service';
export * from './lib/playlist-refresh.service';
export * from './lib/playlists.service';
export * from './lib/portal-status.service';
export * from './lib/runtime-capabilities.service';
export * from './lib/settings-store.service';
export * from './lib/sort.service';
export * from './lib/xtream-pending-restore.service';
-3
View File
@@ -33,9 +33,6 @@ export abstract class DataService {
const currentWindow = window as ElectronProcessWindow;
return Boolean(currentWindow.process?.type);
}
get supportsEpg(): boolean {
return typeof window !== 'undefined' && Boolean(window.electron);
}
get remote(): ElectronRemote | null {
return null;
}
@@ -36,6 +36,20 @@ describe('PlaylistsService', () => {
translateService: {
instant: jest.fn((key: string) => key),
},
runtime: {
get supportsSqlite() {
const electron = testWindow.electron as
| Record<string, unknown>
| undefined;
return (
!!electron &&
typeof electron['dbGetAppPlaylists'] === 'function' &&
typeof electron['dbUpsertAppPlaylist'] === 'function' &&
typeof electron['dbGetAppState'] === 'function' &&
typeof electron['dbSetAppState'] === 'function'
);
},
},
electronMigrationPromise: null,
indexedDbMigrationPromise: null,
playlistDeleteCleanups: [],
+3 -9
View File
@@ -31,6 +31,7 @@ import {
normalizeStalkerDate,
} from '@iptvnator/shared/interfaces';
import { PLAYLIST_DELETE_CLEANUP } from './playlist-delete-cleanup.token';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG =
@@ -82,6 +83,7 @@ export class PlaylistsService {
private readonly dbService = inject(NgxIndexedDBService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly playlistDeleteCleanups =
inject(PLAYLIST_DELETE_CLEANUP, { optional: true }) ?? [];
private electronMigrationPromise: Promise<void> | null = null;
@@ -96,15 +98,7 @@ export class PlaylistsService {
}
private get isElectronStorageAvailable(): boolean {
const electron = this.electronApi;
return (
!!electron &&
typeof electron.dbGetAppPlaylists === 'function' &&
typeof electron.dbUpsertAppPlaylist === 'function' &&
typeof electron.dbGetAppState === 'function' &&
typeof electron.dbSetAppState === 'function'
);
return this.runtime.supportsSqlite;
}
private runOnSqlite<T>(operation: () => Promise<T>) {
@@ -0,0 +1,81 @@
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
describe('RuntimeCapabilitiesService', () => {
const testWindow = window as unknown as {
electron?: Record<string, unknown>;
};
const originalElectron = testWindow.electron;
afterEach(() => {
testWindow.electron = originalElectron;
});
it('reports browser PWA capabilities when the Electron bridge is absent', () => {
testWindow.electron = undefined;
const service = new RuntimeCapabilitiesService();
expect(service.environment).toBe('pwa');
expect(service.isPwa).toBe(true);
expect(service.isElectron).toBe(false);
expect(service.supportsEpg).toBe(false);
expect(service.supportsSqlite).toBe(false);
expect(service.supportsDownloads).toBe(false);
expect(service.supportsManagedExternalPlayers).toBe(false);
expect(service.supportsEmbeddedMpv).toBe(false);
expect(service.supportsRemoteControl).toBe(false);
});
it('reports Electron capabilities from the available preload bridge methods', () => {
testWindow.electron = {
dbGetAppPlaylists: jest.fn(),
dbUpsertAppPlaylist: jest.fn(),
dbGetAppState: jest.fn(),
dbSetAppState: jest.fn(),
downloadsGetList: jest.fn(),
prepareEmbeddedMpv: jest.fn(),
updateRemoteControlStatus: jest.fn(),
onChannelChange: jest.fn(),
onRemoteControlCommand: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.environment).toBe('electron');
expect(service.isElectron).toBe(true);
expect(service.isPwa).toBe(false);
expect(service.supportsEpg).toBe(true);
expect(service.supportsSqlite).toBe(true);
expect(service.supportsDownloads).toBe(true);
expect(service.supportsManagedExternalPlayers).toBe(true);
expect(service.supportsEmbeddedMpv).toBe(true);
expect(service.supportsRemoteControl).toBe(true);
});
it('keeps feature-specific capabilities false when an Electron bridge is partial', () => {
testWindow.electron = {
updateRemoteControlStatus: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.environment).toBe('electron');
expect(service.supportsEpg).toBe(true);
expect(service.supportsSqlite).toBe(false);
expect(service.supportsDownloads).toBe(false);
expect(service.supportsEmbeddedMpv).toBe(false);
expect(service.supportsRemoteControl).toBe(false);
});
it('reads the bridge dynamically so tests and late preload setup stay accurate', () => {
testWindow.electron = undefined;
const service = new RuntimeCapabilitiesService();
expect(service.isPwa).toBe(true);
testWindow.electron = {};
expect(service.isElectron).toBe(true);
expect(service.environment).toBe('electron');
});
});
@@ -0,0 +1,69 @@
import { Injectable } from '@angular/core';
export type RuntimeEnvironment = 'electron' | 'pwa';
type RuntimeElectronBridge = Record<string, unknown>;
type RuntimeWindow = Window & {
electron?: RuntimeElectronBridge;
};
@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 supportsEpg(): boolean {
return this.isElectron;
}
get supportsSqlite(): boolean {
return (
this.hasElectronMethod('dbGetAppPlaylists') &&
this.hasElectronMethod('dbUpsertAppPlaylist') &&
this.hasElectronMethod('dbGetAppState') &&
this.hasElectronMethod('dbSetAppState')
);
}
get supportsDownloads(): boolean {
return this.hasElectronMethod('downloadsGetList');
}
get supportsManagedExternalPlayers(): boolean {
return this.isElectron;
}
get supportsEmbeddedMpv(): boolean {
return this.hasElectronMethod('prepareEmbeddedMpv');
}
get supportsRemoteControl(): boolean {
return (
this.hasElectronMethod('updateRemoteControlStatus') &&
this.hasElectronMethod('onChannelChange') &&
this.hasElectronMethod('onRemoteControlCommand')
);
}
private hasElectronMethod(methodName: string): boolean {
return typeof this.electronBridge?.[methodName] === 'function';
}
private get electronBridge(): RuntimeElectronBridge | undefined {
if (typeof window === 'undefined') {
return undefined;
}
return (window as RuntimeWindow).electron;
}
}
@@ -30,7 +30,11 @@ import {
} from '@iptvnator/workspace/shell/util';
import { DialogService } from '@iptvnator/ui/components';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import {
DatabaseService,
PlaylistsService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
DashboardDataService,
DashboardFavoriteItem,
@@ -428,11 +432,12 @@ export class WorkspaceDashboardRailsComponent {
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly epgService = inject(EpgService);
private readonly playlistsService = inject(PlaylistsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
readonly hasPlaylists = computed(() => this.data.playlists().length > 0);
readonly ready = this.data.dashboardReady;
readonly xtreamPlaylistCount = this.data.xtreamPlaylistCount;
readonly isElectron = !!window.electron;
readonly isElectron = this.runtime.isElectron;
readonly skeletonSlots = SKELETON_CARDS_PER_RAIL;
readonly skeletonRails = SKELETON_RAILS;
@@ -776,7 +781,7 @@ export class WorkspaceDashboardRailsComponent {
}
private async removePlaylist(playlist: PlaylistMeta): Promise<void> {
const deleted = window.electron
const deleted = this.runtime.isElectron
? await this.deletePlaylistInElectron(playlist)
: await this.deletePlaylistInBrowser(playlist);