Files
iptvnator/apps/web/src/app/services/electron.service.ts
T
4grayandClaude Opus 5.5 5dbad2383f perf(web): keep channel lists, EPG views and the Stalker layer off the initial path (#1712)
The root shell imported WindowControlsComponent and DialogService through the @iptvnator/ui/components barrel, and esbuild keeps every Angular component module a barrel re-exports, so channel lists, EPG views, @angular/forms, date-fns and the whole Stalker data layer sat in main.js. The shell now uses file-level entries, the Stalker connection editor is a lazy proxy, and the release-notes and external-player info dialogs load on demand with a handled failure path.

renderer.initialBytes 2,714,336 -> 1,626,019 bytes (-40%); the baseline is lowered to the ubuntu ratchet measurement and the production/PWA initial budgets drop to 1.8/2 MB. J1: did-finish-load about -16 ms, first card within noise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:48:01 +02:00

785 lines
26 KiB
TypeScript

import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { DialogService } from '@iptvnator/ui/components/confirm-dialog';
import {
DataService,
SettingsStore,
SourceActivityService,
} from '@iptvnator/services';
import {
AUTO_UPDATE_PLAYLISTS,
AutoUpdatePlaylistsResult,
CONNECTIVITY_GUARD_RESET,
ELECTRON_BRIDGE_SECURITY_ERROR_CODES,
ERROR,
normalizeHost,
parseSecurityPolicyError,
PlayerContentInfo,
Playlist,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_UPDATE,
XTREAM_REQUEST,
XTREAM_RESPONSE,
XtreamCodeActions,
SourceProbeContext,
} from '@iptvnator/shared/interfaces';
import {
measureRendererPerformancePhase,
RENDERER_PERFORMANCE_PHASE,
} from '@iptvnator/shared/logging';
import { AppConfig } from '../../environments/environment';
import { buildAutoUpdatePlaylistsFeedback } from './auto-update-playlists-feedback';
import {
createLogger,
createPortalDebugRequestContext,
logPortalDebugEvent,
} from '@iptvnator/portal/shared/util';
interface PlayerLaunchPayload {
readonly headers?: Record<string, string>;
readonly origin?: string;
readonly referer?: string;
readonly startTime?: number;
readonly thumbnail?: string;
readonly title?: string;
readonly url: string;
readonly ['user-agent']?: string;
readonly contentInfo?: PlayerContentInfo;
}
interface ErrorStatus {
readonly message?: string;
readonly status?: number;
}
@Injectable({
providedIn: 'root',
})
export class ElectronService extends DataService {
private eventListeners: { [key: string]: () => void } = {};
private messageListeners = new Map<string, EventListener>();
private readonly sourceActivity = inject(SourceActivityService);
private readonly snackBar = inject(MatSnackBar);
private readonly dialogService = inject(DialogService);
private readonly store = inject(Store);
private readonly settingsStore = inject(SettingsStore);
private readonly translateService = inject(TranslateService);
private readonly logger = createLogger('ElectronService');
private readonly silentXtreamActions = new Set<string>([
XtreamCodeActions.GetAccountInfo,
XtreamCodeActions.GetLiveCategories,
XtreamCodeActions.GetVodCategories,
XtreamCodeActions.GetSeriesCategories,
XtreamCodeActions.GetShortEpg,
XtreamCodeActions.GetSimpleDataTable,
XtreamCodeActions.GetSimpleDateTable,
]);
constructor() {
super();
this.setupPlayerErrorListener();
this.setupPortalDebugListener();
}
private setupPlayerErrorListener() {
// Listen for player errors from the backend
if (window.electron?.onPlayerError) {
window.electron.onPlayerError(
(data: {
player: string;
error: string;
originalError: string;
}) => {
this.logger.error(
`${data.player} Error:`,
data.originalError
);
this.snackBar.open(
`${data.player} Error: ${data.error}`,
'Close',
{
duration: 7000,
panelClass: ['error-snackbar'],
}
);
}
);
}
}
private setupPortalDebugListener() {
const onPortalDebugEvent = (
window.electron as {
onPortalDebugEvent?: (
callback: (
event: Parameters<typeof logPortalDebugEvent>[0]
) => void
) => void;
}
).onPortalDebugEvent;
if (AppConfig.production || !onPortalDebugEvent) {
return;
}
onPortalDebugEvent((event) => {
logPortalDebugEvent(
event as Parameters<typeof logPortalDebugEvent>[0]
);
});
}
getAppVersion(): string {
return AppConfig.version;
}
async sendIpcEvent<T = unknown>(
type: string,
payload?: unknown
): Promise<T> {
if (type === PLAYLIST_PARSE_BY_URL) {
this.fetchM3uPlaylistFromUrl(payload as Partial<Playlist>);
return undefined as T;
}
if (type === PLAYLIST_UPDATE) {
this.updateM3uPlaylistFromFile(
payload as {
id: string;
filePath?: string;
url?: string;
userAgent?: string;
title: string;
}
);
return undefined as T;
}
if (type === XTREAM_REQUEST) {
return (await this.forwardXtreamRequest(
payload as { url: string; params: Record<string, string> }
)) as T;
}
if (type === 'STALKER_REQUEST') {
return (await this.fetchStalkerData(
payload as {
url: string;
macAddress: string;
params: Record<string, string>;
}
)) as T;
}
if (type === CONNECTIVITY_GUARD_RESET) {
const { url } = payload as { url?: string };
if (url) {
await window.electron.resetHostConnectivityGuard(url);
}
return undefined as T;
}
if (type === 'OPEN_MPV_PLAYER') {
const data = payload as PlayerLaunchPayload;
try {
return (await window.electron.openInMpv(
data.url,
data.title ?? '',
data.thumbnail ?? '',
data['user-agent'],
data.referer ?? undefined,
data.origin ?? undefined,
data.contentInfo,
data.startTime,
data.headers ?? undefined
)) as T;
} catch (error: unknown) {
const errorMessage =
this.getErrorDetails(error)?.message ?? String(error);
this.snackBar.open(
`Error launching MPV: ${errorMessage}`,
'Close',
{
duration: 5000,
}
);
this.logger.error('MPV launch error:', error);
throw error;
}
}
if (type === 'OPEN_VLC_PLAYER') {
const data = payload as PlayerLaunchPayload;
try {
return (await window.electron.openInVlc(
data.url,
data.title ?? '',
data.thumbnail ?? '',
data['user-agent'],
data.referer ?? undefined,
data.origin ?? undefined,
data.contentInfo,
data.startTime,
data.headers ?? undefined
)) as T;
} catch (error: unknown) {
const errorMessage =
this.getErrorDetails(error)?.message ?? String(error);
this.snackBar.open(
`Error launching VLC: ${errorMessage}`,
'Close',
{
duration: 5000,
}
);
this.logger.error('VLC launch error:', error);
throw error;
}
}
if (type === AUTO_UPDATE_PLAYLISTS) {
const data = payload as Playlist[];
const release = this.sourceActivity.begin(data.map((p) => p._id));
try {
const result = await window.electron.autoUpdatePlaylists(
data,
this.settingsStore.getTrustOptions()
);
this.store.dispatch(
PlaylistActions.updateManyPlaylists({
playlists: result.playlists,
})
);
this.reportAutoUpdatePlaylistsResult(result);
return result as T;
} finally {
release();
}
}
this.logger.debug('Unknown IPC event type:', type);
return undefined as T;
}
private reportAutoUpdatePlaylistsResult(
result: AutoUpdatePlaylistsResult
): void {
const unresolved = result.outcomes.filter(
(outcome) => outcome.status !== 'updated'
);
if (unresolved.length > 0) {
this.logger.warn(
'Playlist auto-refresh did not update every playlist:',
unresolved
.map((outcome) => `${outcome.title} (${outcome.status})`)
.join(', ')
);
}
const feedback = buildAutoUpdatePlaylistsFeedback(result);
this.snackBar.open(
this.translateService.instant(feedback.messageKey, feedback.params),
feedback.isError
? this.translateService.instant('CLOSE')
: undefined,
feedback.isError
? { duration: 6000, panelClass: ['error-snackbar'] }
: { duration: 2000 }
);
}
private async fetchStalkerData(payload: {
url: string;
macAddress: string;
params: Record<string, string>;
requestId?: string;
token?: string;
serialNumber?: string;
/** Endpoint-discovery probes expect failures; no error snackbar. */
silent?: boolean;
/** Endpoint-discovery probes are exempt from the connectivity guard. */
skipConnectionGuard?: boolean;
}) {
const context = createPortalDebugRequestContext({
provider: 'stalker',
operation: payload.params?.action ?? 'unknown',
transport: 'electron-renderer',
request: payload,
});
try {
// Use Electron IPC to make the Stalker request
const response = await window.electron.stalkerRequest({
...payload,
requestId: context.requestId,
});
return response;
} catch (err: unknown) {
const errorInfo = this.getErrorDetails(err);
this.logger.error('Stalker request error:', err);
if (!payload.silent) {
this.snackBar.open(
`Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`,
'Close',
{
duration: 5000,
}
);
}
throw err;
}
}
private async fetchM3uPlaylistFromUrl(payload?: Partial<Playlist>) {
if (!payload?.url) {
return;
}
const title = payload.title?.trim() || undefined;
window.electron
.fetchPlaylistByUrl(payload.url, title, {
...this.settingsStore.getTrustOptions(),
userAgent: payload.userAgent,
})
.then((result) => {
measureRendererPerformancePhase(
RENDERER_PERFORMANCE_PHASE.M3U_IMPORT_DISPATCH,
() =>
this.store.dispatch(
PlaylistActions.handleAddingPlaylistByUrl({
isTemporary: !!payload?.isTemporary,
playlist: result,
})
)
);
})
.catch((error: unknown) => {
if (
this.handlePlaylistSecurityError(error, () =>
this.fetchM3uPlaylistFromUrl(payload)
)
) {
return;
}
const statusCode = this.extractHttpStatusCode(error);
let messageKey = 'HOME.URL_UPLOAD.ERROR_FETCH_FAILED';
if (statusCode === 403) {
messageKey = 'HOME.URL_UPLOAD.ERROR_403';
} else if (statusCode === 404) {
messageKey = 'HOME.URL_UPLOAD.ERROR_404';
} else if (statusCode === 401) {
messageKey = 'HOME.URL_UPLOAD.ERROR_401';
}
this.snackBar.open(
this.translateService.instant(messageKey),
this.translateService.instant('CLOSE'),
{ duration: 5000 }
);
});
}
private extractHttpStatusCode(error: unknown): number | null {
if (
error &&
typeof error === 'object' &&
'response' in error &&
error.response &&
typeof error.response === 'object' &&
'status' in error.response
) {
return error.response.status as number;
}
// Parse status from error message string (IPC serialization)
const msg = String((error as { message?: string })?.message ?? error);
const match = msg.match(/status code (\d{3})/);
return match ? parseInt(match[1], 10) : null;
}
private async updateM3uPlaylistFromFile(data: {
id: string;
url?: string;
filePath?: string;
userAgent?: string;
title: string;
}) {
try {
let playlistObject: Playlist;
if (data.url && !data.filePath) {
playlistObject = await window.electron.fetchPlaylistByUrl(
data.url,
data.title,
{
...this.settingsStore.getTrustOptions(),
userAgent: data.userAgent,
}
);
} else if (data.filePath && !data.url) {
playlistObject =
await window.electron.updatePlaylistFromFilePath(
data.filePath,
data.title
);
} else {
this.logger.error(
'Either url or filePath must be provided, but not both.'
);
return;
}
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist: {
...playlistObject,
_id: data.id,
},
playlistId: data.id,
refreshEpg: true,
})
);
this.snackBar.open(
this.translateService.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
undefined,
{ duration: 2000 }
);
} catch (error: unknown) {
this.logger.error('Playlist refresh error:', error);
if (
data.url &&
this.handlePlaylistSecurityError(error, () => {
void this.updateM3uPlaylistFromFile(data);
})
) {
return;
}
this.snackBar.open(
this.getPlaylistRefreshErrorMessage(error, data),
this.translateService.instant('CLOSE'),
{ duration: 5000 }
);
}
}
private getPlaylistRefreshErrorMessage(
error: unknown,
data: { url?: string; filePath?: string }
): string {
if (data.filePath) {
const errorMessage = String(
this.getErrorDetails(error)?.message ?? error ?? ''
);
if (
/(ENOENT|no such file or directory|not found)/i.test(
errorMessage
)
) {
return this.translateWithFallback(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND',
'Playlist refresh failed. The local file is no longer available. Check the file path or re-import the playlist.'
);
}
if (/(EACCES|EPERM|permission denied)/i.test(errorMessage)) {
return this.translateWithFallback(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR',
'Playlist refresh failed. The app can no longer access the local file.'
);
}
return this.translateService.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR'
);
}
const statusCode = this.extractHttpStatusCode(error);
if (statusCode === 404) {
return this.translateService.instant('HOME.URL_UPLOAD.ERROR_404');
}
if (statusCode === 403) {
return this.translateService.instant('HOME.URL_UPLOAD.ERROR_403');
}
if (statusCode === 401) {
return this.translateService.instant('HOME.URL_UPLOAD.ERROR_401');
}
return this.translateService.instant(
'HOME.URL_UPLOAD.ERROR_FETCH_FAILED'
);
}
private translateWithFallback(key: string, fallback: string): string {
const translated = this.translateService.instant(key);
return translated === key ? fallback : translated;
}
private handlePlaylistSecurityError(
error: unknown,
retry: () => void
): boolean {
const securityError = parseSecurityPolicyError(error);
if (
securityError?.code !==
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate
) {
return false;
}
const ref = this.snackBar.open(
this.translateWithFallback(
'HOME.URL_UPLOAD.ERROR_INVALID_TLS',
'Certificate for this playlist host is invalid.'
),
this.translateWithFallback(
'HOME.URL_UPLOAD.TRUST_TLS_HOST',
'Trust host'
),
{ duration: 10000 }
);
ref.onAction().subscribe(() => {
this.confirmTrustPlaylistHost(securityError.host, retry);
});
return true;
}
private confirmTrustPlaylistHost(
host: string | undefined,
retry: () => void
): void {
if (!host) {
this.snackBar.open(
this.translateWithFallback(
'HOME.URL_UPLOAD.ERROR_TLS_HOST_UNKNOWN',
'Could not determine the playlist host. Please retry manually.'
),
this.translateService.instant('CLOSE'),
{ duration: 5000 }
);
return;
}
this.dialogService.openConfirmDialog({
title: this.translateWithFallback(
'HOME.URL_UPLOAD.TRUST_TLS_HOST_TITLE',
'Trust invalid certificate?'
),
message: this.translateWithFallback(
'HOME.URL_UPLOAD.TRUST_TLS_HOST_WARNING',
'Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.'
),
confirmLabel: this.translateWithFallback(
'HOME.URL_UPLOAD.TRUST_TLS_HOST',
'Trust host'
),
width: '420px',
onConfirm: () => {
void this.trustPlaylistHost(host).then(retry);
},
});
}
private async trustPlaylistHost(host: string): Promise<void> {
const settings = this.settingsStore.getSettings();
const trustedHosts = new Set(
(settings.trustedInsecureTlsHosts ?? []).map((item) =>
normalizeHost(item)
)
);
trustedHosts.add(normalizeHost(host));
await this.settingsStore.updateSettings({
trustedInsecureTlsHosts: Array.from(trustedHosts),
});
}
/* private getErrorMessageByStatusCode(status: number) {
let message = 'Something went wrong';
switch (status) {
case 0:
message = 'The backend is not reachable';
break;
case 413:
message =
'This file is too big. Use standalone or self-hosted version of the app.';
break;
default:
break;
}
return message;
} */
private async forwardXtreamRequest(payload: {
probe?: SourceProbeContext;
connectionTest?: boolean;
url: string;
params: Record<string, string>;
requestId?: string;
sessionId?: string;
suppressErrorLog?: boolean;
}) {
const context = createPortalDebugRequestContext({
provider: 'xtream',
operation: payload.params?.action ?? 'unknown',
transport: 'electron-renderer',
request: payload,
});
try {
// Use Electron IPC to make the Xtream request
const response = await window.electron.xtreamRequest({
...payload,
requestId: context.requestId,
});
if (payload.connectionTest || payload.probe) return response;
const result = {
type: XTREAM_RESPONSE,
payload: response.payload,
action: response.action,
};
window.postMessage(result);
return result;
} catch (error: unknown) {
if (payload.probe) throw error;
const action = payload.params?.action;
const isSilentAction =
payload.suppressErrorLog === true ||
(action ? this.silentXtreamActions.has(action) : false);
const normalizedMessage = this.getReadableXtreamErrorMessage(error);
const errorInfo = this.getErrorDetails(error);
// Log error to console
if (isSilentAction) {
this.logger.debug(
`Background Xtream action failed (${action ?? 'unknown'}):`,
normalizedMessage
);
} else {
this.logger.error('Xtream request error:', normalizedMessage);
}
// Only show snackbar for user-triggered Xtream requests
if (!isSilentAction) {
this.snackBar.open(
`Xtream request failed: ${normalizedMessage}`,
'Close',
{
duration: 5000,
}
);
}
return {
type: ERROR,
status: errorInfo?.status ?? 500,
message: normalizedMessage,
};
}
}
private getReadableXtreamErrorMessage(error: unknown): string {
const fallback = 'Failed to connect to Xtream server';
if (!error) {
return fallback;
}
const maybeError = error as {
message?: unknown;
statusText?: unknown;
status?: unknown;
error?: unknown;
};
if (typeof maybeError.message === 'string') {
if (maybeError.message.includes('[object Object]')) {
if (typeof maybeError.error === 'string') {
return maybeError.error;
}
if (
maybeError.error &&
typeof maybeError.error === 'object' &&
'message' in
(maybeError.error as Record<string, unknown>) &&
typeof (maybeError.error as Record<string, unknown>)
.message === 'string'
) {
return (maybeError.error as Record<string, string>).message;
}
return fallback;
}
return maybeError.message;
}
if (typeof maybeError.statusText === 'string') {
return maybeError.statusText;
}
if (typeof error === 'string') {
return error;
}
return fallback;
}
private getErrorDetails(error: unknown): ErrorStatus | null {
if (error && typeof error === 'object') {
return error as ErrorStatus;
}
return null;
}
removeAllListeners(type: string): void {
if (type === 'all') {
// Unsubscribe from all event listeners
Object.values(this.eventListeners).forEach((unsubscribe) =>
unsubscribe()
);
this.eventListeners = {};
// Remove all tracked window message listeners
this.messageListeners.forEach((listener) =>
window.removeEventListener('message', listener)
);
this.messageListeners.clear();
return;
}
if (this.eventListeners[type]) {
// Unsubscribe from a specific event
this.eventListeners[type]();
delete this.eventListeners[type];
}
// Remove the window message listener registered for this command
const messageListener = this.messageListeners.get(type);
if (messageListener) {
window.removeEventListener('message', messageListener);
this.messageListeners.delete(type);
}
}
listenOn(command: string, callback: (...args: unknown[]) => void): void {
// Drop any existing listener for this command so calling listenOn()
// again rebinds rather than accumulating duplicates.
const existing = this.messageListeners.get(command);
if (existing) {
window.removeEventListener('message', existing);
}
const listener = callback as EventListener;
window.addEventListener('message', listener);
this.messageListeners.set(command, listener);
}
getAppEnvironment(): string {
return 'electron';
}
}