Files
iptvnator/apps/electron-backend/src/app/app.ts
T
4gray bfad82c26c fix(settings): stop settings silently reverting on restart (#1272)
Settings live in the renderer's IndexedDB, and two failure modes made them look
saved while nothing reached disk.

A second app instance sharing the same userData directory cannot take the
Chromium storage lock, so its renderer reads defaults and every write is
dropped. The app now holds a single-instance lock and focuses the running window
instead of starting a rival copy. The lock is requested after the userData
override so E2E runs with their own data dir keep independent locks, and after
Squirrel event handling. IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1 opts out for local
CDP debugging.

updateSettings() patches in-memory state before persisting and the submit path
had no rejection handler, so a failed write produced an unhandled rejection and
no user-visible feedback. SettingsStore now records which half of the round trip
failed, and the settings page surfaces it through a dismissible error snackbar;
the dialog stays open on failure so the save can be retried.

Two follow-ups from review, both wider than the report:

- a second launch now re-creates the main window when the lock owner has none
  left, so closing the last window on macOS no longer leaves a second launch
  quitting silently with nothing on screen
- App.onMainWindowCreated() re-runs window-owned bindings for every rebuilt
  window, so the downloads broadcaster stops holding a destroyed window. This
  also fixes the same bug on the pre-existing dock `activate` path.

Closes #1156
Closes #102
2026-07-27 23:14:30 +02:00

578 lines
19 KiB
TypeScript

import { app, BrowserWindow, Menu, screen, session, shell } from 'electron';
import {
ElectronBridgeWindowState,
WINDOW_STATE_CHANGED,
} from '@iptvnator/shared/interfaces';
import { join, resolve } from 'path';
import { fileURLToPath } from 'url';
import { rendererAppName, rendererAppPort } from './constants';
import {
isStartupTraceEnabled,
isRendererConsoleTraceEnabled,
isWindowTraceEnabled,
trace,
} from './services/debug-trace';
import { store, WINDOW_BOUNDS } from './services/store.service';
import { isFrameCopyRuntimeUsable } from './services/embedded-mpv-frame-copy-platform.util';
import { isEmbeddedMpvFeatureEnabled } from './services/embedded-mpv-runtime-policy.util';
const externalBrowserProtocols = new Set(['http:', 'https:']);
const trustedDevRendererHosts = new Set([
'localhost',
'127.0.0.1',
'[::1]',
'::1',
]);
function parseUrl(url: string): URL | null {
try {
return new URL(url);
} catch {
return null;
}
}
function getPackagedRendererIndexPath(): string {
return resolve(__dirname, '..', rendererAppName, 'index.html');
}
function getFilePathFromUrl(url: URL): string | null {
try {
return fileURLToPath(url);
} catch {
return null;
}
}
export function isExternalBrowserUrl(url: string): boolean {
const parsedUrl = parseUrl(url);
return Boolean(
parsedUrl && externalBrowserProtocols.has(parsedUrl.protocol)
);
}
export function isTrustedRendererNavigationUrl(
url: string,
isDevelopmentMode: boolean,
packagedRendererIndexPath = getPackagedRendererIndexPath()
): boolean {
const parsedUrl = parseUrl(url);
if (!parsedUrl) {
return false;
}
if (parsedUrl.protocol === 'file:') {
const filePath = getFilePathFromUrl(parsedUrl);
if (isDevelopmentMode || !filePath) {
return false;
}
return resolve(filePath) === resolve(packagedRendererIndexPath);
}
if (!isDevelopmentMode) {
return false;
}
return (
parsedUrl.protocol === 'http:' &&
trustedDevRendererHosts.has(parsedUrl.hostname) &&
parsedUrl.port === String(rendererAppPort)
);
}
export function getMainWindowWebPreferences(): Electron.BrowserWindowConstructorOptions['webPreferences'] {
// The frame-copy embedded MPV experiment needs the preload script to
// load the shm frame-reader native addon, which the renderer sandbox
// forbids. Only that opt-in flag relaxes the sandbox; context isolation
// and nodeIntegration:false stay on either way, so page code never
// gains Node access. Revisit before the engine can become a default.
const frameCopyExperiment =
isEmbeddedMpvFeatureEnabled() &&
['1', 'true', 'yes', 'on'].includes(
(process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY ?? '')
.trim()
.toLowerCase()
) && isFrameCopyRuntimeUsable();
return {
contextIsolation: true,
nodeIntegration: false,
sandbox: !frameCopyExperiment,
webSecurity: true,
backgroundThrottling: false,
preload: join(__dirname, 'main.preload.js'),
};
}
export async function clearElectronServiceWorkerStorage(
electronSession: Pick<Electron.Session, 'clearStorageData'> = session.defaultSession
): Promise<void> {
try {
await electronSession.clearStorageData({
storages: ['serviceworkers', 'cachestorage'],
});
if (isStartupTraceEnabled()) {
trace('startup', 'electron-service-worker-storage:cleared');
}
} catch (error) {
console.warn('Failed to clear Electron service worker storage:', error);
if (isStartupTraceEnabled()) {
trace(
'startup',
'electron-service-worker-storage:clear-failed',
error
);
}
}
}
function attachWindowTrace(mainWindow: Electron.BrowserWindow): void {
if (!isWindowTraceEnabled()) {
return;
}
const webContents = mainWindow.webContents;
trace('window', 'created', {
id: mainWindow.id,
});
mainWindow.on('unresponsive', () => {
trace('window', 'unresponsive', {
id: mainWindow.id,
url: webContents.getURL(),
});
});
mainWindow.on('responsive', () => {
trace('window', 'responsive', {
id: mainWindow.id,
url: webContents.getURL(),
});
});
webContents.on('did-start-loading', () => {
trace('window', 'did-start-loading', {
id: mainWindow.id,
url: webContents.getURL(),
});
});
webContents.on('dom-ready', () => {
trace('window', 'dom-ready', {
id: mainWindow.id,
url: webContents.getURL(),
});
});
webContents.on('did-finish-load', () => {
trace('window', 'did-finish-load', {
id: mainWindow.id,
url: webContents.getURL(),
});
});
webContents.on(
'did-fail-load',
(_event, errorCode, errorDescription, validatedURL) => {
trace('window', 'did-fail-load', {
errorCode,
errorDescription,
id: mainWindow.id,
validatedURL,
});
}
);
webContents.on('did-navigate', (_event, url) => {
trace('window', 'did-navigate', {
id: mainWindow.id,
url,
});
});
webContents.on('render-process-gone', (_event, details) => {
trace('window', 'render-process-gone', {
details,
id: mainWindow.id,
url: webContents.getURL(),
});
});
if (isRendererConsoleTraceEnabled()) {
webContents.on(
'console-message',
(_event, level, message, line, sourceId) => {
trace('renderer-console', 'message', {
level,
line,
message,
sourceId,
});
}
);
}
}
export default class App {
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
static mainWindow: Electron.BrowserWindow | null = null;
private static readonly mainWindowListeners: Array<
(mainWindow: Electron.BrowserWindow) => void
> = [];
static application: Electron.App;
static BrowserWindow;
private static loadedMainWindow: Electron.BrowserWindow | null = null;
private static mainWindowLoadPromise: Promise<void> | null = null;
private static rendererLoadingEnabled = false;
private static shouldOpenDevTools() {
return process.env.ELECTRON_OPEN_DEVTOOLS === '1';
}
public static isDevelopmentMode() {
// First check ELECTRON_IS_DEV environment variable (used by E2E tests)
// This allows E2E tests to run in production mode without packaging
if ('ELECTRON_IS_DEV' in process.env) {
return parseInt(process.env.ELECTRON_IS_DEV, 10) === 1;
}
// Fall back to Electron's built-in app.isPackaged
// This is the most reliable way to detect if the app is packaged
return !app.isPackaged;
}
private static onWindowAllClosed() {
if (process.platform !== 'darwin') {
App.application.quit();
}
}
private static onClose() {
// Dereference the window object, usually you would store windows
// in an array if your app supports multi windows, this is the time
// when you should delete the corresponding element.
App.mainWindow = null;
}
private static startMainWindowLoad(): void {
void App.loadMainWindow().catch((error) => {
console.error('Failed to load main window:', error);
});
}
private static onReady() {
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
if (rendererAppName) {
App.initMainWindow();
if (App.rendererLoadingEnabled) {
App.startMainWindowLoad();
}
}
}
/**
* Registers a listener that needs the current main window, and re-runs it
* whenever a new one is created.
*
* The main window is not created once per process: on macOS the window can
* be closed and rebuilt (dock `activate`, or a second launch handed over by
* the single-instance guard) while the process lives on. Anything that
* caches the window — `download-broadcast`'s module-level reference, for
* one — would otherwise keep pointing at a destroyed window and silently
* stop delivering to the renderer. Fires immediately when a window already
* exists, so callers registering after startup do not miss the first one.
*/
static onMainWindowCreated(
listener: (mainWindow: Electron.BrowserWindow) => void
): void {
App.mainWindowListeners.push(listener);
if (App.mainWindow && !App.mainWindow.isDestroyed()) {
listener(App.mainWindow);
}
}
private static notifyMainWindowCreated(
mainWindow: Electron.BrowserWindow
): void {
for (const listener of App.mainWindowListeners) {
listener(mainWindow);
}
}
/**
* Brings the app back to a windowed state, re-creating the main window if
* it is gone.
*
* On macOS closing the last window deliberately keeps the process alive
* (`onWindowAllClosed`), so this is the recovery path for both the dock
* `activate` event and a second launch that the single-instance guard
* hands over to this process.
*/
static ensureMainWindow() {
if (App.mainWindow === null) {
App.onReady();
}
if (App.rendererLoadingEnabled) {
App.startMainWindowLoad();
}
}
private static onActivate() {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
App.ensureMainWindow();
}
private static handleRendererNavigation(
event: Electron.Event,
url: string
): void {
if (isTrustedRendererNavigationUrl(url, App.isDevelopmentMode())) {
return;
}
event.preventDefault();
if (isExternalBrowserUrl(url)) {
shell.openExternal(url);
}
}
/**
* Hide the native title bar on every desktop platform. macOS keeps the
* system traffic lights (overlay), while Windows/Linux rely on the
* renderer-drawn window controls (`app-window-controls`) wired up via the
* WINDOW:* IPC channels. `frame` stays untouched so native resize borders
* and snapping keep working.
*/
private static getPlatformTitleBarOptions(): Electron.BrowserWindowConstructorOptions {
if (process.platform === 'darwin') {
return {
titleBarStyle: 'hidden',
titleBarOverlay: true,
trafficLightPosition: { x: 16, y: 20 },
};
}
return { titleBarStyle: 'hidden' };
}
private static attachWindowStateEvents(win: Electron.BrowserWindow): void {
// Only Windows/Linux render custom window controls that subscribe
// to these pushes; macOS keeps the native traffic lights, so
// sending state updates there would be dead IPC traffic.
if (process.platform === 'darwin') {
return;
}
// Window state is never re-read at event time: on Windows both
// isFullScreen() and isMaximized() can still report the
// pre-transition value while the matching event fires (notably for
// HTML-element fullscreen, i.e. the video player). Since the
// renderer replaces both flags on every push and no later event
// corrects a stale one, polling left the controls hidden forever
// after leaving fullscreen — and, for the companion flag, the
// maximize/restore glyph stuck on the wrong icon.
//
// Instead the state is seeded once here (window creation, so no
// transition is in flight) and each event patches only the flag it
// names.
const state: ElectronBridgeWindowState = {
isMaximized: win.isMaximized(),
isFullScreen: win.isFullScreen(),
};
const push = (patch: Partial<ElectronBridgeWindowState>) => {
Object.assign(state, patch);
if (win.isDestroyed()) {
return;
}
// A copy per push: the renderer must not observe later
// mutations of the tracked state.
win.webContents.send(WINDOW_STATE_CHANGED, { ...state });
};
win.on('maximize', () => push({ isMaximized: true }));
win.on('unmaximize', () => push({ isMaximized: false }));
// The html variants cover HTML-element fullscreen; not every
// platform/trigger emits both pairs, and duplicate pushes with the
// same payload are harmless.
win.on('enter-full-screen', () => push({ isFullScreen: true }));
win.on('enter-html-full-screen', () => push({ isFullScreen: true }));
win.on('leave-full-screen', () => push({ isFullScreen: false }));
win.on('leave-html-full-screen', () => push({ isFullScreen: false }));
}
private static initMainWindow() {
const workAreaSize = screen.getPrimaryDisplay().workAreaSize;
const width = Math.min(1280, workAreaSize.width || 1280);
const height = Math.min(720, workAreaSize.height || 720);
const savedWindowBounds = store.get(WINDOW_BOUNDS);
// Create the browser window.
App.mainWindow = new BrowserWindow({
title: 'IPTVnator',
width: width,
height: height,
show: false,
webPreferences: getMainWindowWebPreferences(),
...savedWindowBounds,
minHeight: 600,
minWidth: 900,
...App.getPlatformTitleBarOptions(),
});
App.mainWindow.setMenu(null);
attachWindowTrace(App.mainWindow);
App.attachWindowStateEvents(App.mainWindow);
App.notifyMainWindowCreated(App.mainWindow);
if (!savedWindowBounds) {
App.mainWindow.center();
}
// if main window is ready to show, close the splash window and show the main window
App.mainWindow.once('ready-to-show', () => {
App.mainWindow.show();
});
// Route target="_blank" / window.open() to the OS default browser
App.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (isExternalBrowserUrl(url)) {
shell.openExternal(url);
}
return { action: 'deny' };
});
App.mainWindow.webContents.on(
'will-navigate',
App.handleRendererNavigation
);
App.mainWindow.webContents.on(
'will-redirect',
App.handleRendererNavigation
);
// Emitted when the window is closed.
App.mainWindow.on('closed', () => {
// Dereference the window object, usually you would store windows
// in an array if your app supports multi windows, this is the time
// when you should delete the corresponding element.
App.mainWindow = null;
App.loadedMainWindow = null;
App.mainWindowLoadPromise = null;
});
App.mainWindow.on('close', () => {
if (App.mainWindow) {
store.set(WINDOW_BOUNDS, App.mainWindow.getNormalBounds());
}
});
// Enable context menu for input fields only
App.mainWindow.webContents.on('context-menu', (event, params) => {
const { isEditable, editFlags } = params;
// Check if this is an editable field (input, textarea, contenteditable)
// editFlags.canPaste is a good indicator of an input field
if (isEditable && editFlags.canPaste) {
const menu = Menu.buildFromTemplate([
{
label: 'Cut',
role: 'cut',
enabled: editFlags.canCut,
},
{
label: 'Copy',
role: 'copy',
enabled: editFlags.canCopy,
},
{
label: 'Paste',
role: 'paste',
enabled: editFlags.canPaste,
},
{
type: 'separator',
},
{
label: 'Select All',
role: 'selectAll',
enabled: editFlags.canSelectAll,
},
]);
menu.popup();
}
});
}
private static async loadMainWindowContent(
mainWindow: Electron.BrowserWindow
): Promise<void> {
// load the index.html of the app.
if (App.isDevelopmentMode()) {
const loadPromise = mainWindow.loadURL(
`http://localhost:${rendererAppPort}`
);
if (App.shouldOpenDevTools()) {
mainWindow.webContents.openDevTools();
}
await loadPromise;
} else {
await clearElectronServiceWorkerStorage();
await mainWindow.loadFile(getPackagedRendererIndexPath());
}
}
static async loadMainWindow(): Promise<void> {
App.rendererLoadingEnabled = true;
if (!rendererAppName || !App.mainWindow) {
return;
}
if (App.loadedMainWindow === App.mainWindow) {
return;
}
if (!App.mainWindowLoadPromise) {
const mainWindow = App.mainWindow;
App.mainWindowLoadPromise = App.loadMainWindowContent(mainWindow)
.then(() => {
App.loadedMainWindow = mainWindow;
})
.finally(() => {
App.mainWindowLoadPromise = null;
});
}
await App.mainWindowLoadPromise;
}
static main(app: Electron.App, browserWindow: typeof BrowserWindow) {
// we pass the Electron.App object and the
// Electron.BrowserWindow into this function
// so this class has no dependencies. This
// makes the code easier to write tests for
App.BrowserWindow = browserWindow;
App.application = app;
App.application.on('window-all-closed', App.onWindowAllClosed); // Quit when all windows are closed.
if (App.application.isReady()) {
App.onReady();
} else {
App.application.on('ready', App.onReady); // App is ready to load data
}
App.application.on('activate', App.onActivate); // App is activated
App.application.on('before-quit', () => {
if (App.mainWindow)
store.set(WINDOW_BOUNDS, App.mainWindow.getNormalBounds());
});
}
}