From bfad82c26c8c64b7689180c8a9c37bad899320cf Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:14:30 +0200 Subject: [PATCH] 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 --- .changes/settings-persistence-feedback.md | 10 ++ AGENTS.md | 1 + CLAUDE.md | 2 + .../src/electron-test-fixtures.ts | 117 ++++++++++++- apps/electron-backend-e2e/src/settings.e2e.ts | 65 +++++++ apps/electron-backend/src/app/app.spec.ts | 42 +++++ apps/electron-backend/src/app/app.ts | 53 +++++- .../src/app/services/single-instance.spec.ts | 163 ++++++++++++++++++ .../src/app/services/single-instance.ts | 114 ++++++++++++ apps/electron-backend/src/main.ts | 23 ++- .../src/app/settings/settings-form.facade.ts | 20 ++- .../app/settings/settings-snackbar.service.ts | 32 ++++ ...settings.component.storage-failure.spec.ts | 128 ++++++++++++++ .../src/app/settings/settings.component.ts | 25 ++- .../test-stubs/settings-test-harness.stub.ts | 3 + apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/hu.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + apps/web/src/styles.scss | 8 + .../src/lib/settings-store.service.spec.ts | 107 ++++++++++++ .../src/lib/settings-store.service.ts | 28 ++- 37 files changed, 956 insertions(+), 23 deletions(-) create mode 100644 .changes/settings-persistence-feedback.md create mode 100644 apps/electron-backend/src/app/services/single-instance.spec.ts create mode 100644 apps/electron-backend/src/app/services/single-instance.ts create mode 100644 apps/web/src/app/settings/settings.component.storage-failure.spec.ts diff --git a/.changes/settings-persistence-feedback.md b/.changes/settings-persistence-feedback.md new file mode 100644 index 000000000..896e3ee77 --- /dev/null +++ b/.changes/settings-persistence-feedback.md @@ -0,0 +1,10 @@ +--- +type: fix +area: settings +issues: [1156, 102] +--- + +Launching IPTVnator a second time now brings the running window to the front +instead of starting a rival copy that could not save anything — a common reason +settings appeared to reset on restart. If saving or loading settings does fail, +the app now says so instead of silently showing defaults. diff --git a/AGENTS.md b/AGENTS.md index eb424f72b..78001ad29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ This file provides guidance to coding agents working in this repository. - Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`. - If DevTools is open, `agent-browser --cdp 9222 ...` may attach to the DevTools page instead of the IPTVnator window. Symptoms: `tab list` shows `about:blank`, snapshots are empty, and screenshots are black. - If that happens, inspect targets with `curl http://127.0.0.1:9222/json/list` and connect directly to the IPTVnator page websocket from the `webSocketDebuggerUrl` field. +- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. ### Trace / Debug Startup diff --git a/CLAUDE.md b/CLAUDE.md index 23ba46552..f628770f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,6 +126,7 @@ nx run electron-backend:make - When the task is Electron automation/debugging, use the `electron` skill - Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`. - If DevTools is open, `agent-browser --cdp 9222 ...` may attach to the DevTools page instead of the IPTVnator window (symptoms: `tab list` shows `about:blank`, empty snapshots, black screenshots). Inspect targets with `curl http://127.0.0.1:9222/json/list` and connect directly to the app page's `webSocketDebuggerUrl`. +- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. For startup tracing or white-screen debugging: @@ -590,6 +591,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Bootstraps Electron app and initializes database - Registers event handlers for IPC communication +- Holds a single-instance lock (`app/services/single-instance.ts`), requested after the `userData` override so E2E runs with their own data dir keep independent locks. A second launch quits and focuses the running window; concurrent instances would otherwise share a Chromium profile whose IndexedDB only one of them can lock, silently breaking renderer-side settings persistence. `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` opts out for local debugging. **Database**: diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index df1f1f025..87ab9351c 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -7,6 +7,7 @@ import { Page, test as base, } from '@playwright/test'; +import { spawn } from 'child_process'; import { createServer, Server } from 'http'; import { accessSync, @@ -119,6 +120,38 @@ export type LaunchedElectronApp = { mainWindow: Page; }; +export type CompetingElectronInstanceResult = { + exitCode: number | null; + signal: NodeJS.Signals | null; + stderr: string; + timedOut: boolean; +}; + +const competingInstanceStderrLimit = 4000; + +/** + * Removes a run's data directory, tolerating handles the OS has not released + * yet. + * + * Electron's single-instance lock keeps `SingletonLock`/`SingletonSocket` + * (`lockfile` on Windows) open for the whole process lifetime, and Windows + * releases those handles asynchronously as the process dies — so a removal + * issued right after `closeElectronApp` can still hit EBUSY. This is throwaway + * temp state, so a stubborn directory is a warning, never a test failure. + */ +function removeDataDir(dataDir: string): void { + try { + rmSync(dataDir, { + force: true, + maxRetries: 20, + recursive: true, + retryDelay: 250, + }); + } catch (error) { + console.warn(`Could not remove E2E data dir ${dataDir}:`, error); + } +} + export const test = base.extend({ dataDir: async ({ browserName }, use) => { void browserName; @@ -126,12 +159,29 @@ export const test = base.extend({ await use(dataDir); - rmSync(dataDir, { force: true, recursive: true }); + removeDataDir(dataDir); }, }); export { expect }; +/** + * Builds the argv every locally launched Electron process must share. + * + * Headless Linux CI has no usable sandbox or GPU, and an Electron started + * without these flags there dies on a signal instead of running — so any + * helper that spawns the app itself has to use the same list. + */ +function buildElectronLaunchArgs(extraArgs: readonly string[] = []): string[] { + const args = [...extraArgs, electronMainPath]; + + if (process.platform === 'linux' && process.env['CI']) { + args.unshift('--no-sandbox', '--disable-gpu'); + } + + return args; +} + export async function launchElectronApp( dataDir: string, options: LaunchElectronAppOptions = {} @@ -143,11 +193,7 @@ export async function launchElectronApp( } assertPackagedRendererBuildIsElectronSafe(); - const args = [...(options.args ?? []), electronMainPath]; - - if (process.platform === 'linux' && process.env['CI']) { - args.unshift('--no-sandbox', '--disable-gpu'); - } + const args = buildElectronLaunchArgs(options.args); const electronApp = await electron.launch({ args, @@ -333,6 +379,65 @@ function attachElectronProcessDiagnostics( }); } +/** + * Starts a raw second Electron process against an already-running instance's + * data directory and reports how it terminated. + * + * Deliberately not `launchElectronApp`: the expected outcome is that no window + * is ever created, because the single-instance guard hands the launch over to + * the running app. Two live instances would share a Chromium profile whose + * IndexedDB only one of them can lock, which is how settings silently stopped + * persisting (issues #102, #1156). + */ +export async function launchCompetingElectronInstance( + dataDir: string, + timeoutMs = 30000 +): Promise { + // In a Node context the `electron` package resolves to its binary path. + const electronBinaryPath = require('electron') as unknown as string; + const child = spawn(electronBinaryPath, buildElectronLaunchArgs(), { + env: { + ...process.env, + ELECTRON_IS_DEV: '0', + IPTVNATOR_E2E_DATA_DIR: dataDir, + NODE_ENV: 'test', + }, + stdio: ['ignore', 'ignore', 'pipe'], + }); + + // Kept for the assertion message: a competing launch that dies for an + // unrelated reason (missing sandbox, missing GPU) looks exactly like a + // refused one from the outside. + let stderr = ''; + child.stderr?.on('data', (chunk: Buffer) => { + stderr = `${stderr}${chunk.toString()}`.slice( + -competingInstanceStderrLimit + ); + }); + + return new Promise((resolvePromise) => { + const timer = setTimeout(() => { + child.kill(); + resolvePromise({ + exitCode: null, + signal: null, + stderr, + timedOut: true, + }); + }, timeoutMs); + + child.once('exit', (code, signal) => { + clearTimeout(timer); + resolvePromise({ + exitCode: code, + signal, + stderr, + timedOut: false, + }); + }); + }); +} + export async function closeElectronApp( app: LaunchedElectronApp ): Promise { diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index c803ab8ed..7e974cfcf 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -10,6 +10,7 @@ import { expect, goToDashboard, importM3uPlaylistFromNativeDialog, + launchCompetingElectronInstance, launchElectronApp, LaunchedElectronApp, m3uFixturePath, @@ -145,6 +146,70 @@ test.describe('Electron Settings', () => { } }); + test('@settings @persistence @electron refuses a second instance so it cannot break settings storage', async ({ + dataDir, + }) => { + const runningApp = await launchElectronApp(dataDir); + + try { + const competing = await launchCompetingElectronInstance(dataDir); + + expect( + { + exitCode: competing.exitCode, + signal: competing.signal, + timedOut: competing.timedOut, + }, + competing.stderr + ).toEqual({ exitCode: 0, signal: null, timedOut: false }); + // The running instance keeps its window and its storage lock. + expect(runningApp.electronApp.windows().length).toBeGreaterThan(0); + await expect(runningApp.mainWindow.locator('body')).toBeVisible(); + } finally { + await closeElectronApp(runningApp); + } + + // The lock is released on exit, so a later launch starts normally. + const relaunch = await launchElectronApp(dataDir); + await closeElectronApp(relaunch); + }); + + test('@settings @electron re-opens a window when a second launch arrives with none open', async ({ + dataDir, + }) => { + // Only macOS keeps the process alive after its last window closes; + // elsewhere `window-all-closed` quits and the next launch is a plain + // cold start. + test.skip( + process.platform !== 'darwin', + 'macOS-only windowless-process behaviour' + ); + const runningApp = await launchElectronApp(dataDir); + + try { + await runningApp.electronApp.evaluate(({ BrowserWindow }) => { + for (const window of BrowserWindow.getAllWindows()) { + window.close(); + } + }); + await expect + .poll(() => runningApp.electronApp.windows().length) + .toBe(0); + + const recreatedWindow = + runningApp.electronApp.waitForEvent('window'); + const competing = await launchCompetingElectronInstance(dataDir); + + expect(competing.timedOut).toBe(false); + expect(competing.exitCode).toBe(0); + // Without this the guard would quit the launch into nothing and + // leave the user staring at no window at all. + await expect((await recreatedWindow).locator('body')).toBeVisible(); + } finally { + await closeElectronApp(runningApp); + } + }); + test('@settings @persistence @electron persists changed desktop settings across app restart', async ({ dataDir, }) => { diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index 173c59d60..2a663d941 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -59,6 +59,7 @@ type MockMainWindow = { getNormalBounds: jest.Mock; // Read once by attachWindowStateEvents to seed the tracked window // state; only reached off macOS, where the custom controls exist. + isDestroyed: jest.Mock; isFullScreen: jest.Mock; isMaximized: jest.Mock; loadFile: jest.Mock, [string]>; @@ -84,6 +85,7 @@ function createMockMainWindow(): MockMainWindow { loadURL: jest.fn, [string]>().mockResolvedValue(), on: jest.fn void]>(), once: jest.fn void]>(), + isDestroyed: jest.fn().mockReturnValue(false), setMenu: jest.fn(), show: jest.fn(), webContents: { @@ -97,6 +99,7 @@ function createMockMainWindow(): MockMainWindow { type AppInternals = { loadedMainWindow: MockMainWindow | null; mainWindow: MockMainWindow | null; + mainWindowListeners: Array<(mainWindow: MockMainWindow) => void>; mainWindowLoadPromise: Promise | null; onReady: () => void; rendererLoadingEnabled: boolean; @@ -120,6 +123,7 @@ describe('Electron app security helpers', () => { appInternals.mainWindow = null; appInternals.mainWindowLoadPromise = null; appInternals.rendererLoadingEnabled = false; + appInternals.mainWindowListeners.length = 0; (electronApp as unknown as { isPackaged: boolean }).isPackaged = false; (screen.getPrimaryDisplay as jest.Mock).mockReturnValue({ workAreaSize: { height: 720, width: 1280 }, @@ -322,6 +326,44 @@ describe('Electron app security helpers', () => { expect(mainWindow.loadFile).not.toHaveBeenCalled(); }); + it('runs a main-window listener immediately when a window already exists', () => { + const mainWindow = createMockMainWindow(); + (BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow); + (electronApp.isReady as jest.Mock).mockReturnValue(true); + App.main(electronApp, BrowserWindow); + const listener = jest.fn(); + + App.onMainWindowCreated( + listener as unknown as (mainWindow: Electron.BrowserWindow) => void + ); + + expect(listener).toHaveBeenCalledWith(mainWindow); + }); + + // macOS keeps the process alive without windows, so anything caching the + // window (download-broadcast) must be handed the rebuilt one. + it('re-runs main-window listeners when the window is rebuilt', () => { + const firstWindow = createMockMainWindow(); + (BrowserWindow as unknown as jest.Mock).mockReturnValue(firstWindow); + (electronApp.isReady as jest.Mock).mockReturnValue(true); + App.main(electronApp, BrowserWindow); + const listener = jest.fn(); + App.onMainWindowCreated( + listener as unknown as (mainWindow: Electron.BrowserWindow) => void + ); + listener.mockClear(); + + // Simulate the macOS 'closed' handler clearing the reference. + getAppInternals().mainWindow = null; + const secondWindow = createMockMainWindow(); + (BrowserWindow as unknown as jest.Mock).mockReturnValue(secondWindow); + + App.ensureMainWindow(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(secondWindow); + }); + it('clears only service worker registrations and cache storage', async () => { await clearElectronServiceWorkerStorage(); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index 7aef4a9ae..77a63d2d7 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -216,6 +216,9 @@ 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; @@ -268,9 +271,46 @@ export default class App { } } - 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. + /** + * 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(); } @@ -279,6 +319,12 @@ export default class App { } } + 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 @@ -383,6 +429,7 @@ export default class App { App.mainWindow.setMenu(null); attachWindowTrace(App.mainWindow); App.attachWindowStateEvents(App.mainWindow); + App.notifyMainWindowCreated(App.mainWindow); if (!savedWindowBounds) { App.mainWindow.center(); } diff --git a/apps/electron-backend/src/app/services/single-instance.spec.ts b/apps/electron-backend/src/app/services/single-instance.spec.ts new file mode 100644 index 000000000..6baec2e37 --- /dev/null +++ b/apps/electron-backend/src/app/services/single-instance.spec.ts @@ -0,0 +1,163 @@ +import { + ALLOW_MULTIPLE_INSTANCES_ENV, + acquireSingleInstanceLock, + allowsMultipleInstances, + focusExistingWindow, + type SingleInstanceApp, + type SingleInstanceWindow, +} from './single-instance'; + +function createApp(hasLock: boolean): jest.Mocked { + return { + quit: jest.fn(), + on: jest.fn(), + requestSingleInstanceLock: jest.fn().mockReturnValue(hasLock), + }; +} + +function createWindow( + overrides: Partial = {} +): jest.Mocked { + return { + focus: jest.fn(), + isDestroyed: jest.fn().mockReturnValue(false), + isMinimized: jest.fn().mockReturnValue(false), + isVisible: jest.fn().mockReturnValue(true), + restore: jest.fn(), + show: jest.fn(), + ...overrides, + } as jest.Mocked; +} + +describe('allowsMultipleInstances', () => { + it.each(['1', 'true', 'YES', ' on '])( + 'treats %p as an opt-in', + (value) => { + expect( + allowsMultipleInstances({ + [ALLOW_MULTIPLE_INSTANCES_ENV]: value, + }) + ).toBe(true); + } + ); + + it.each([undefined, '', '0', 'false', 'nope'])( + 'treats %p as opted out', + (value) => { + expect( + allowsMultipleInstances( + value === undefined + ? {} + : { [ALLOW_MULTIPLE_INSTANCES_ENV]: value } + ) + ).toBe(false); + } + ); +}); + +describe('acquireSingleInstanceLock', () => { + it('continues startup and registers the focus handler when the lock is free', () => { + const app = createApp(true); + + expect( + acquireSingleInstanceLock(app, () => null, jest.fn(), {}) + ).toBe(true); + expect(app.quit).not.toHaveBeenCalled(); + expect(app.on).toHaveBeenCalledWith( + 'second-instance', + expect.any(Function) + ); + }); + + it('quits and stops startup when another instance owns the profile', () => { + const app = createApp(false); + + expect( + acquireSingleInstanceLock(app, () => null, jest.fn(), {}) + ).toBe(false); + expect(app.quit).toHaveBeenCalledTimes(1); + expect(app.on).not.toHaveBeenCalled(); + }); + + it('focuses the existing window when a second instance is launched', () => { + const app = createApp(true); + const window = createWindow({ + isMinimized: jest.fn().mockReturnValue(true), + isVisible: jest.fn().mockReturnValue(false), + }); + + const createMainWindow = jest.fn(); + acquireSingleInstanceLock(app, () => window, createMainWindow, {}); + const [, handler] = app.on.mock.calls[0]; + (handler as () => void)(); + + expect(window.restore).toHaveBeenCalledTimes(1); + expect(window.show).toHaveBeenCalledTimes(1); + expect(window.focus).toHaveBeenCalledTimes(1); + expect(createMainWindow).not.toHaveBeenCalled(); + }); + + // macOS keeps the process alive after the last window closes, so a second + // launch must rebuild a window instead of quitting into nothing. + it.each([ + ['no window exists', null], + ['the window was destroyed', 'destroyed'], + ])('re-creates the main window when %s', (_label, windowState) => { + const app = createApp(true); + const window = + windowState === 'destroyed' + ? createWindow({ + isDestroyed: jest.fn().mockReturnValue(true), + }) + : null; + const createMainWindow = jest.fn(); + + acquireSingleInstanceLock(app, () => window, createMainWindow, {}); + const [, handler] = app.on.mock.calls[0]; + (handler as () => void)(); + + expect(createMainWindow).toHaveBeenCalledTimes(1); + if (window) { + expect(window.focus).not.toHaveBeenCalled(); + } + }); + + it('skips the lock entirely when multiple instances are allowed', () => { + const app = createApp(false); + + expect( + acquireSingleInstanceLock(app, () => null, jest.fn(), { + [ALLOW_MULTIPLE_INSTANCES_ENV]: '1', + }) + ).toBe(true); + expect(app.requestSingleInstanceLock).not.toHaveBeenCalled(); + expect(app.quit).not.toHaveBeenCalled(); + }); +}); + +describe('focusExistingWindow', () => { + it('does nothing without a window', () => { + expect(() => focusExistingWindow(null)).not.toThrow(); + }); + + it('does not touch a destroyed window', () => { + const window = createWindow({ + isDestroyed: jest.fn().mockReturnValue(true), + }); + + focusExistingWindow(window); + + expect(window.focus).not.toHaveBeenCalled(); + expect(window.restore).not.toHaveBeenCalled(); + }); + + it('only focuses an already visible window', () => { + const window = createWindow(); + + focusExistingWindow(window); + + expect(window.restore).not.toHaveBeenCalled(); + expect(window.show).not.toHaveBeenCalled(); + expect(window.focus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/services/single-instance.ts b/apps/electron-backend/src/app/services/single-instance.ts new file mode 100644 index 000000000..8186ed2ce --- /dev/null +++ b/apps/electron-backend/src/app/services/single-instance.ts @@ -0,0 +1,114 @@ +/** + * Single-instance guard. + * + * Every Electron instance that shares a `userData` directory also shares the + * Chromium storage profile behind it. Only the first process gets the + * IndexedDB LevelDB lock, so a second concurrent instance silently loses + * renderer-side persistence: the settings store falls back to defaults on read + * and its writes never reach disk (issues #102, #1156). Holding a single + * instance lock is the fix — a second launch focuses the window that already + * owns the profile instead of opening a broken twin. + * + * The lock is scoped to `userData`, so it must be requested *after* + * `app.setPath('userData', ...)`; otherwise E2E runs with a dedicated data + * directory would contend on the default profile's lock instead of their own. + */ + +/** Minimal Electron `App` surface used by the guard, kept narrow for tests. */ +export interface SingleInstanceApp { + quit(): void; + on(event: 'second-instance', listener: () => void): unknown; + requestSingleInstanceLock(): boolean; +} + +/** Minimal `BrowserWindow` surface used when re-focusing the existing window. */ +export interface SingleInstanceWindow { + focus(): void; + isDestroyed(): boolean; + isMinimized(): boolean; + isVisible(): boolean; + restore(): void; + show(): void; +} + +export const ALLOW_MULTIPLE_INSTANCES_ENV = 'IPTVNATOR_ALLOW_MULTIPLE_INSTANCES'; + +const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']); + +/** + * Escape hatch for local debugging (e.g. attaching a second CDP-enabled + * instance to the same database). Off by default so packaged users always get + * the guard. + */ +export function allowsMultipleInstances( + env: NodeJS.ProcessEnv = process.env +): boolean { + return TRUTHY_VALUES.has( + (env[ALLOW_MULTIPLE_INSTANCES_ENV] ?? '').trim().toLowerCase() + ); +} + +/** Brings an existing main window back to the foreground. */ +export function focusExistingWindow( + window: SingleInstanceWindow | null | undefined +): void { + if (!window || window.isDestroyed()) { + return; + } + + if (window.isMinimized()) { + window.restore(); + } + + if (!window.isVisible()) { + window.show(); + } + + window.focus(); +} + +/** True when there is no live window left to bring forward. */ +function needsNewWindow( + window: SingleInstanceWindow | null | undefined +): boolean { + return !window || window.isDestroyed(); +} + +/** + * Acquires the single instance lock. + * + * @param createMainWindow re-creates the main window when the lock owner has + * none left. On macOS closing the last window keeps the process alive, so + * without this a second launch would quit silently and leave the user with no + * window at all. + * @returns `true` when this process owns the profile and startup may continue, + * `false` when another instance already owns it and this one is quitting. + */ +export function acquireSingleInstanceLock( + app: SingleInstanceApp, + getMainWindow: () => SingleInstanceWindow | null | undefined, + createMainWindow: () => void, + env: NodeJS.ProcessEnv = process.env +): boolean { + if (allowsMultipleInstances(env)) { + return true; + } + + if (!app.requestSingleInstanceLock()) { + app.quit(); + return false; + } + + app.on('second-instance', () => { + const mainWindow = getMainWindow(); + + if (needsNewWindow(mainWindow)) { + createMainWindow(); + return; + } + + focusExistingWindow(mainWindow); + }); + + return true; +} diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index ea9b08923..b745ad244 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -37,6 +37,7 @@ import { } from './app/services/embedded-mpv-frame-copy-platform.util'; import { isEmbeddedMpvFeatureEnabled } from './app/services/embedded-mpv-runtime-policy.util'; import { runEmbeddedMpvRuntimeDiagnosticOrContinue } from './app/services/embedded-mpv-runtime-diagnostic'; +import { acquireSingleInstanceLock } from './app/services/single-instance'; import { EMBEDDED_MPV_FRAME_COPY, store } from './app/services/store.service'; app.setName('iptvnator'); @@ -148,10 +149,10 @@ export default class Main { EpgEvents.bootstrapEpgEvents(); RemoteControlEvents.bootstrapRemoteControlEvents(); - // Set main window for downloads and reset stale downloads - if (App.mainWindow) { - setDownloadsMainWindow(App.mainWindow); - } + // Keep the downloads broadcaster bound to the live window. macOS can + // rebuild the window while the process runs, and a stale reference + // silently swallows every DOWNLOADS_UPDATE_EVENT. + App.onMainWindowCreated(setDownloadsMainWindow); // Load the renderer only after IPC handlers are registered. On slower // Linux CI hosts the renderer can otherwise invoke Electron bridge IPC @@ -194,6 +195,20 @@ runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => { // handle setup events as quickly as possible Main.initialize(); + // A second instance sharing this userData directory cannot take the + // Chromium storage lock, so its renderer silently loses every settings + // write. Focus the window that already owns the profile instead — or + // re-create it, since on macOS the process outlives its last window. + if ( + !acquireSingleInstanceLock( + app, + () => App.mainWindow, + () => App.ensureMainWindow() + ) + ) { + return; + } + // bootstrap app Main.bootstrapApp(); diff --git a/apps/web/src/app/settings/settings-form.facade.ts b/apps/web/src/app/settings/settings-form.facade.ts index a1b4a31a1..445b8e215 100644 --- a/apps/web/src/app/settings/settings-form.facade.ts +++ b/apps/web/src/app/settings/settings-form.facade.ts @@ -10,6 +10,7 @@ import { Theme, } from '@iptvnator/shared/interfaces'; import { TranslateService } from '@ngx-translate/core'; +import { SettingsSnackbarService } from './settings-snackbar.service'; import { SettingsStore } from '../services/settings-store.service'; import { SettingsService } from '../services/settings.service'; import { @@ -33,6 +34,7 @@ export class SettingsFormFacade { private readonly formBuilder = inject(FormBuilder); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsService = inject(SettingsService); + private readonly settingsSnackbar = inject(SettingsSnackbarService); private readonly settingsStore = inject(SettingsStore); private readonly translate = inject(TranslateService); @@ -53,8 +55,14 @@ export class SettingsFormFacade { } /** Waits for the persisted settings to be available */ - loadSettings(): Promise { - return this.settingsStore.loadSettings(); + async loadSettings(): Promise { + await this.settingsStore.loadSettings(); + + if (this.settingsStore.storageFailure() === 'load') { + // The form is about to show defaults that are not the user's saved + // values — say so instead of letting them look genuine. + this.settingsSnackbar.storageFailure('load'); + } } /** @@ -97,7 +105,9 @@ export class SettingsFormFacade { } this.patchAndMarkDirty({ coverSize }, 'coverSize'); - void this.settingsStore.updateSettings({ coverSize }); + this.settingsStore.updateSettings({ coverSize }).catch(() => { + this.settingsSnackbar.storageFailure('save'); + }); } selectEpgViewMode(epgViewMode: EpgViewMode): void { @@ -106,7 +116,9 @@ export class SettingsFormFacade { } this.patchAndMarkDirty({ epgViewMode }, 'epgViewMode'); - void this.settingsStore.updateSettings({ epgViewMode }); + this.settingsStore.updateSettings({ epgViewMode }).catch(() => { + this.settingsSnackbar.storageFailure('save'); + }); } setRecordingFolder(recordingFolder: string): void { diff --git a/apps/web/src/app/settings/settings-snackbar.service.ts b/apps/web/src/app/settings/settings-snackbar.service.ts index 2b5a40549..1b1166d7b 100644 --- a/apps/web/src/app/settings/settings-snackbar.service.ts +++ b/apps/web/src/app/settings/settings-snackbar.service.ts @@ -1,9 +1,12 @@ import { inject, Injectable } from '@angular/core'; import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar'; +import { SettingsStorageFailure } from '@iptvnator/services'; +import { TranslateService } from '@ngx-translate/core'; @Injectable() export class SettingsSnackbarService { private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); open(message: string, config: MatSnackBarConfig = {}): void { this.snackBar.open(message, undefined, { @@ -14,4 +17,33 @@ export class SettingsSnackbarService { ...config, }); } + + /** + * Failures need more reading time than the 2s confirmation toast, and a + * dismiss action so the message can stay up until it is acknowledged. + */ + error(message: string, dismissLabel: string): void { + this.snackBar.open(message, dismissLabel, { + duration: 10000, + horizontalPosition: 'center', + verticalPosition: 'bottom', + panelClass: ['settings-snackbar', 'settings-snackbar--error'], + }); + } + + /** + * Reports a failed settings read/write. Both mean the same thing to the + * user — what is on screen will not survive a restart — so they get a + * dismissible warning instead of the silent fallback to defaults. + */ + storageFailure(failure: SettingsStorageFailure): void { + this.error( + this.translate.instant( + failure === 'save' + ? 'SETTINGS.SETTINGS_SAVE_FAILED' + : 'SETTINGS.SETTINGS_LOAD_FAILED' + ), + this.translate.instant('CLOSE') + ); + } } diff --git a/apps/web/src/app/settings/settings.component.storage-failure.spec.ts b/apps/web/src/app/settings/settings.component.storage-failure.spec.ts new file mode 100644 index 000000000..94fe3a9a6 --- /dev/null +++ b/apps/web/src/app/settings/settings.component.storage-failure.spec.ts @@ -0,0 +1,128 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { SettingsStore } from '../services/settings-store.service'; +import { SettingsComponent } from './settings.component'; +import { + configureSettingsComponentTestBed, + createElectronStub, + createEpgBridgeStub, + MatSnackBarStub, + MockSettingsStore, + stubSettingsSideEffects, +} from './test-stubs/settings-test-harness.stub'; + +/** Matches the snackbar config used by `SettingsSnackbarService.error`. */ +const ERROR_SNACKBAR_CONFIG = expect.objectContaining({ + panelClass: ['settings-snackbar', 'settings-snackbar--error'], +}); + +/** + * Assertions target the MatDialog the component actually calls: spying on the + * one returned by `TestBed.inject(MatDialog)` does not observe it. + */ +interface SettingsComponentPrivateTestApi { + matDialog: MatDialog; +} + +/** + * Settings live in the renderer's IndexedDB, and both halves of the round trip + * can fail while the UI keeps looking healthy: a failed read shows defaults as + * if they were saved, and a failed write is applied in memory so it survives + * until the next restart. These cover the user-visible reporting of both. + */ +describe('SettingsComponent storage failures', () => { + let component: SettingsComponent; + let fixture: ComponentFixture; + let settingsStore: MockSettingsStore; + let snackBar: MatSnackBarStub; + let epgBridge: Partial; + const originalElectron = window.electron; + const componentMatDialog = (): MatDialog => + (component as unknown as SettingsComponentPrivateTestApi).matDialog; + + beforeEach(waitForAsync(() => { + epgBridge = createEpgBridgeStub(); + configureSettingsComponentTestBed(epgBridge); + })); + + beforeEach(() => { + window.electron = createElectronStub(); + + fixture = TestBed.createComponent(SettingsComponent); + settingsStore = TestBed.inject( + SettingsStore + ) as unknown as MockSettingsStore; + snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub; + + component = fixture.componentInstance; + stubSettingsSideEffects(component); + fixture.detectChanges(); + }); + + afterEach(() => { + window.electron = originalElectron; + }); + + it('warns when the persisted settings could not be read', async () => { + settingsStore.storageFailure.set('load'); + + await component.ngOnInit(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.SETTINGS_LOAD_FAILED', + 'CLOSE', + ERROR_SNACKBAR_CONFIG + ); + }); + + it('warns and keeps the dialog open when the settings write fails', async () => { + settingsStore.updateSettings.mockRejectedValue( + new Error('storage unavailable') + ); + const closeAll = jest.spyOn(componentMatDialog(), 'closeAll'); + component.isDialog = true; + + component.onSubmit(); + await fixture.whenStable(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.SETTINGS_SAVE_FAILED', + 'CLOSE', + ERROR_SNACKBAR_CONFIG + ); + expect(closeAll).not.toHaveBeenCalled(); + }); + + it('closes the dialog only after the settings write succeeded', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + const closeAll = jest.spyOn(componentMatDialog(), 'closeAll'); + jest.spyOn(component.epg, 'fetchConfiguredEpg').mockImplementation(); + component.isDialog = true; + + component.onSubmit(); + // save() resolves a tick after the store write: the callback and + // the Electron mirror run first, so the close lands on the next + // turn of the microtask queue. + await fixture.whenStable(); + await fixture.whenStable(); + + expect(closeAll).toHaveBeenCalledTimes(1); + }); + + it('warns when a section write fails without leaving an unhandled rejection', async () => { + settingsStore.updateSettings.mockRejectedValue( + new Error('storage unavailable') + ); + + component.form.selectCoverSize('large'); + await fixture.whenStable(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.SETTINGS_SAVE_FAILED', + 'CLOSE', + ERROR_SNACKBAR_CONFIG + ); + }); +}); diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 5535b9c76..f93a24fac 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -199,11 +199,26 @@ export class SettingsComponent implements OnInit, OnDestroy { * the indexed db store */ onSubmit(): void { - void this.form.save(() => this.applyChangedSettings()); - - if (this.isDialog) { - this.matDialog.closeAll(); - } + this.form + .save(() => this.applyChangedSettings()) + .then(() => { + if (this.isDialog) { + this.matDialog.closeAll(); + } + }) + .catch(() => { + // The store already applied the change in memory, so without + // this the save looks successful until the next restart. The + // dialog stays open so it can be retried. + // + // The Electron-side pushes in SettingsFormFacade.save() stay in + // the success branch on purpose: main keeps its own copy of the + // player paths and remote-control state, and applying half the + // form while telling the user nothing was saved is worse than + // applying none of it. Once settings live in the main process + // (issue #1273) this split disappears. + this.settingsSnackbar.storageFailure('save'); + }); } /** diff --git a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts index 2a4013874..abac1e9bd 100644 --- a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts +++ b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts @@ -49,6 +49,7 @@ import { MockModule, MockProvider } from 'ng-mocks'; import { NgxIndexedDBService } from 'ngx-indexed-db'; import { from, of } from 'rxjs'; import { ElectronServiceStub } from '../../services/electron.service.stub'; +import { SettingsStorageFailure } from '@iptvnator/services'; import { SettingsStore } from '../../services/settings-store.service'; import { SettingsService } from '../../services/settings.service'; import { SettingsComponent } from '../settings.component'; @@ -148,6 +149,8 @@ export class MockSettingsStore { updateSettings = jest.fn().mockResolvedValue(undefined); + storageFailure = signal(null); + // Helper method for tests to modify settings _setSettings(newSettings: Partial) { this._settings.set({ diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 9c2ef982f..1c99e218b 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "يتطلب النقر المزدوج على صفوف القنوات قبل بدء التشغيل عبر MPV أو VLC.", "LANGUAGE": "اللغة", "SETTINGS_SAVED": "نجاح! تم حفظ الإعدادات.", + "SETTINGS_SAVE_FAILED": "تعذّر حفظ الإعدادات. ستفقد تغييراتك عند إعادة تشغيل التطبيق.", + "SETTINGS_LOAD_FAILED": "تعذّر تحميل الإعدادات المحفوظة — يتم عرض القيم الافتراضية. تأكّد من عدم تشغيل نسخة أخرى من التطبيق.", "THEME": "السمة البصرية", "SHOW_CAPTIONS": "عرض الترجمات", "SHOW_DASHBOARD": "عرض لوحة التحكم", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 6f8a3ca06..10ee715e0 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "خاص دير دوبل كليك على صفوف القنوات قبل ما يبدا التشغيل بـ MPV ولا VLC.", "LANGUAGE": "اللغة", "SETTINGS_SAVED": "نجاح! الإعدادات تحفضو.", + "SETTINGS_SAVE_FAILED": "ماتحفضاتش الإعدادات. التبديلات ديالك غادي تضيع منين تعاود تشعل التطبيق.", + "SETTINGS_LOAD_FAILED": "ماتحملاتش الإعدادات المحفوظة — كنوريو القيم الافتراضية. تأكد بلي ماكايناش نسخة أخرى من التطبيق خدامة.", "THEME": "المظهر المرئي", "SHOW_CAPTIONS": "وري الترجمة", "SHOW_DASHBOARD": "وري لوحة التحكم", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 12ef0bfb5..16c89096b 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Патрабаваць падвойны націск па радках каналаў перад пачаткам прайгравання праз MPV або VLC.", "LANGUAGE": "Мова", "SETTINGS_SAVED": "Новыя налады былі паспяхова захаваны!", + "SETTINGS_SAVE_FAILED": "Не ўдалося захаваць налады. Змены будуць страчаны пасля перазапуску праграмы.", + "SETTINGS_LOAD_FAILED": "Не ўдалося загрузіць захаваныя налады — паказаны значэнні па змаўчанні. Пераканайцеся, што праграма не запушчана яшчэ раз.", "THEME": "Тэма афармлення", "SHOW_CAPTIONS": "Адлюстроўваць субтытры", "SHOW_DASHBOARD": "Паказаць панэль", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 8786a0736..79049e257 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Erfordert einen Doppelklick auf Kanalzeilen, bevor die Wiedergabe mit MPV oder VLC startet.", "LANGUAGE": "Sprache", "SETTINGS_SAVED": "Einstellungen wurden erfolgreich gespeichert!", + "SETTINGS_SAVE_FAILED": "Einstellungen konnten nicht gespeichert werden. Die Änderungen gehen beim Neustart der App verloren.", + "SETTINGS_LOAD_FAILED": "Gespeicherte Einstellungen konnten nicht geladen werden — es werden Standardwerte angezeigt. Stelle sicher, dass die App nicht bereits läuft.", "THEME": "Farbdesign", "SHOW_CAPTIONS": "Untertitel anzeigen", "SHOW_DASHBOARD": "Dashboard anzeigen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 8ca4913ce..ce7d8c585 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Απαιτεί διπλό κλικ στις γραμμές καναλιών πριν ξεκινήσει η αναπαραγωγή με MPV ή VLC.", "LANGUAGE": "Γλώσσα", "SETTINGS_SAVED": "Επιτυχία! Η διαμόρφωση αποθηκεύτηκε.", + "SETTINGS_SAVE_FAILED": "Δεν ήταν δυνατή η αποθήκευση των ρυθμίσεων. Οι αλλαγές σας θα χαθούν μετά την επανεκκίνηση της εφαρμογής.", + "SETTINGS_LOAD_FAILED": "Δεν ήταν δυνατή η φόρτωση των αποθηκευμένων ρυθμίσεων — εμφανίζονται οι προεπιλογές. Βεβαιωθείτε ότι δεν εκτελείται άλλο αντίγραφο της εφαρμογής.", "THEME": "Οπτικό θέμα", "SHOW_CAPTIONS": "Εμφάνιση υπότιτλων", "SHOW_DASHBOARD": "Εμφάνιση πίνακα ελέγχου", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 130b118e9..505219f7d 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Require a double-click on channel rows before MPV or VLC playback starts.", "LANGUAGE": "Language", "SETTINGS_SAVED": "Success! Configuration was saved.", + "SETTINGS_SAVE_FAILED": "Settings could not be saved. Your changes will be lost when the app is restarted.", + "SETTINGS_LOAD_FAILED": "Saved settings could not be loaded — showing defaults. Make sure no other copy of the app is running.", "THEME": "Visual theme", "SHOW_CAPTIONS": "Show subtitles", "SHOW_DASHBOARD": "Show dashboard", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 256f10042..5e20f9cb3 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Requiere doble clic en las filas de canales antes de iniciar la reproducción con MPV o VLC.", "LANGUAGE": "Idioma", "SETTINGS_SAVED": "¡Completado! La configuración se guardó.", + "SETTINGS_SAVE_FAILED": "No se pudo guardar la configuración. Los cambios se perderán al reiniciar la aplicación.", + "SETTINGS_LOAD_FAILED": "No se pudo cargar la configuración guardada: se muestran los valores predeterminados. Asegúrate de que no haya otra copia de la aplicación abierta.", "THEME": "Tema visual", "SHOW_CAPTIONS": "Mostrar subtítulos", "SHOW_DASHBOARD": "Mostrar panel", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index cb86e2daa..5998517d3 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Exiger un double-clic sur les lignes de chaînes avant de lancer la lecture avec MPV ou VLC.", "LANGUAGE": "Langue", "SETTINGS_SAVED": "La configuration a été enregistrée avec succès.", + "SETTINGS_SAVE_FAILED": "Les paramètres n'ont pas pu être enregistrés. Vos modifications seront perdues au redémarrage de l'application.", + "SETTINGS_LOAD_FAILED": "Les paramètres enregistrés n'ont pas pu être chargés — les valeurs par défaut sont affichées. Vérifiez qu'aucune autre copie de l'application n'est ouverte.", "THEME": "Thème", "SHOW_CAPTIONS": "Afficher les sous-titres", "SHOW_DASHBOARD": "Afficher le tableau de bord", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 83183c4a4..686784f15 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Az MPV- vagy VLC-lejátszás csak a csatornasorra történő dupla kattintással induljon el.", "LANGUAGE": "Nyelv", "SETTINGS_SAVED": "A beállítások mentése sikerült.", + "SETTINGS_SAVE_FAILED": "A beállítások mentése nem sikerült. A módosítások az alkalmazás újraindításakor elvesznek.", + "SETTINGS_LOAD_FAILED": "A mentett beállítások betöltése nem sikerült — az alapértelmezett értékek láthatók. Ellenőrizd, hogy nem fut-e az alkalmazás másik példánya.", "THEME": "Megjelenési téma", "SHOW_CAPTIONS": "Feliratok megjelenítése", "SHOW_DASHBOARD": "Vezérlőpult megjelenítése", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index a57ea5146..14522bbf9 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Richiede un doppio clic sulle righe dei canali prima di avviare la riproduzione con MPV o VLC.", "LANGUAGE": "Lingua", "SETTINGS_SAVED": "La configurazione è stata salvata.", + "SETTINGS_SAVE_FAILED": "Impossibile salvare le impostazioni. Le modifiche andranno perse al riavvio dell'applicazione.", + "SETTINGS_LOAD_FAILED": "Impossibile caricare le impostazioni salvate: vengono mostrati i valori predefiniti. Assicurati che non ci sia un'altra copia dell'app in esecuzione.", "THEME": "Tema visuale", "SHOW_CAPTIONS": "Visualizza sottotitoli", "SHOW_DASHBOARD": "Mostra dashboard", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index ccbdfeddb..ac1184b39 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "MPV または VLC の再生を開始する前に、チャンネル行をダブルクリックする必要があります。", "LANGUAGE": "言語", "SETTINGS_SAVED": "成功!設定が保存されました。", + "SETTINGS_SAVE_FAILED": "設定を保存できませんでした。アプリを再起動すると変更は失われます。", + "SETTINGS_LOAD_FAILED": "保存された設定を読み込めませんでした。既定値を表示しています。アプリが二重に起動していないか確認してください。", "THEME": "視覚テーマ", "SHOW_CAPTIONS": "字幕を表示", "SHOW_DASHBOARD": "ダッシュボードを表示", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 1c7351c29..ba5191362 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "MPV 또는 VLC 재생을 시작하기 전에 채널 행을 두 번 클릭하도록 합니다.", "LANGUAGE": "언어", "SETTINGS_SAVED": "설정이 저장되었습니다.", + "SETTINGS_SAVE_FAILED": "설정을 저장하지 못했습니다. 앱을 다시 시작하면 변경 사항이 사라집니다.", + "SETTINGS_LOAD_FAILED": "저장된 설정을 불러오지 못했습니다. 기본값을 표시합니다. 앱이 중복 실행되고 있지 않은지 확인하세요.", "THEME": "테마", "SHOW_CAPTIONS": "Show subtitles", "SHOW_DASHBOARD": "대시보드 표시", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index c28d9c7e3..7d2621ee5 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Vereist een dubbelklik op kanaalrijen voordat MPV- of VLC-weergave start.", "LANGUAGE": "Taal", "SETTINGS_SAVED": "Succes! De instellingen zijn opgeslagen.", + "SETTINGS_SAVE_FAILED": "De instellingen konden niet worden opgeslagen. Je wijzigingen gaan verloren bij het herstarten van de app.", + "SETTINGS_LOAD_FAILED": "De opgeslagen instellingen konden niet worden geladen — er worden standaardwaarden getoond. Controleer of de app niet al draait.", "THEME": "Visueel thema", "SHOW_CAPTIONS": "Ondertiteling weergeven", "SHOW_DASHBOARD": "Dashboard tonen", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 46f5db3c9..e3990a36f 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Wymagaj podwójnego kliknięcia wiersza kanału przed rozpoczęciem odtwarzania w MPV lub VLC.", "LANGUAGE": "Język", "SETTINGS_SAVED": "Sukces! Konfiguracja została zapisana.", + "SETTINGS_SAVE_FAILED": "Nie udało się zapisać ustawień. Zmiany zostaną utracone po ponownym uruchomieniu aplikacji.", + "SETTINGS_LOAD_FAILED": "Nie udało się wczytać zapisanych ustawień — wyświetlane są wartości domyślne. Upewnij się, że aplikacja nie jest już uruchomiona.", "THEME": "Motyw wizualny", "SHOW_CAPTIONS": "Pokaż napisy", "SHOW_DASHBOARD": "Pokaż pulpit", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index da02cce09..5ce03f2fa 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Exige um duplo clique nas linhas de canais antes de iniciar a reprodução no MPV ou VLC.", "LANGUAGE": "Idioma", "SETTINGS_SAVED": "Sucesso! A configuração foi salva.", + "SETTINGS_SAVE_FAILED": "Não foi possível guardar as definições. As alterações serão perdidas ao reiniciar a aplicação.", + "SETTINGS_LOAD_FAILED": "Não foi possível carregar as definições guardadas — a mostrar os valores predefinidos. Certifique-se de que a aplicação não está já em execução.", "THEME": "Tema visual", "SHOW_CAPTIONS": "Mostrar legendas", "SHOW_DASHBOARD": "Mostrar dashboard", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index c2eb29748..3ba76249e 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Требовать двойной щелчок по строке канала перед запуском воспроизведения через MPV или VLC.", "LANGUAGE": "Язык", "SETTINGS_SAVED": "Новые настройки были успешно сохранены!", + "SETTINGS_SAVE_FAILED": "Не удалось сохранить настройки. Изменения будут потеряны после перезапуска приложения.", + "SETTINGS_LOAD_FAILED": "Не удалось загрузить сохранённые настройки — показаны значения по умолчанию. Убедитесь, что приложение не запущено ещё раз.", "THEME": "Тема оформления", "SHOW_CAPTIONS": "Отображать субтитры", "SHOW_DASHBOARD": "Показывать панель", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 5df212dc2..f82fa81cd 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "MPV veya VLC oynatımı başlamadan önce kanal satırlarına çift tıklanmasını gerektirir.", "LANGUAGE": "Dil", "SETTINGS_SAVED": "Başarılı! Yapılandırma kaydedildi.", + "SETTINGS_SAVE_FAILED": "Ayarlar kaydedilemedi. Değişiklikleriniz uygulama yeniden başlatıldığında kaybolacak.", + "SETTINGS_LOAD_FAILED": "Kaydedilmiş ayarlar yüklenemedi — varsayılanlar gösteriliyor. Uygulamanın başka bir kopyasının çalışmadığından emin olun.", "THEME": "Görünüm teması", "SHOW_CAPTIONS": "Altyazıları göster", "SHOW_DASHBOARD": "Panoyu göster", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 3f0dc3ac2..7f85bedbb 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "在 MPV 或 VLC 开始播放前,要求双击频道行。", "LANGUAGE": "语言", "SETTINGS_SAVED": "配置已成功保存", + "SETTINGS_SAVE_FAILED": "无法保存设置。重启应用后所做的更改将会丢失。", + "SETTINGS_LOAD_FAILED": "无法加载已保存的设置,正在显示默认值。请确认没有重复运行的应用实例。", "THEME": "主题", "SHOW_CAPTIONS": "显示字幕", "SHOW_DASHBOARD": "显示仪表盘", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 042b7c831..0fae55d20 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "在 MPV 或 VLC 開始播放前,要求雙擊頻道列。", "LANGUAGE": "語言", "SETTINGS_SAVED": "成功!設定已儲存。", + "SETTINGS_SAVE_FAILED": "無法儲存設定。重新啟動應用程式後所做的變更將會遺失。", + "SETTINGS_LOAD_FAILED": "無法載入已儲存的設定,正在顯示預設值。請確認沒有重複執行的應用程式。", "THEME": "視覺主題", "SHOW_CAPTIONS": "顯示字幕", "SHOW_DASHBOARD": "顯示儀表板", diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 4e9fc76c3..9d1759c7f 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -237,6 +237,14 @@ textarea, margin-bottom: calc(76px + env(safe-area-inset-bottom, 0px)); } +// Failures (settings could not be saved/loaded) must not read as the usual +// neutral confirmation toast. +.mat-mdc-snack-bar-container.settings-snackbar--error { + --mdc-snackbar-container-color: var(--mat-sys-error-container); + --mdc-snackbar-supporting-text-color: var(--mat-sys-on-error-container); + --mat-snack-bar-button-color: var(--mat-sys-on-error-container); +} + // WCAG-friendly visually-hidden helper — content stays in the // accessibility tree and DOM query results but occupies no layout box. // Used where a visible heading would duplicate adjacent navigation diff --git a/libs/services/src/lib/settings-store.service.spec.ts b/libs/services/src/lib/settings-store.service.spec.ts index b3362edb7..80062db2d 100644 --- a/libs/services/src/lib/settings-store.service.spec.ts +++ b/libs/services/src/lib/settings-store.service.spec.ts @@ -287,3 +287,110 @@ describe('SettingsStore dashboard rail settings', () => { ); }); }); + +describe('SettingsStore storage failure reporting', () => { + let injector: Injector; + let storage: { + get: jest.Mock; + set: jest.Mock; + }; + let consoleError: jest.SpyInstance; + + beforeEach(() => { + storage = { + get: jest.fn(() => of(null)), + set: jest.fn(() => of(undefined)), + }; + consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + injector = Injector.create({ + providers: [ + SettingsStore, + { + provide: StorageMap, + useValue: storage, + }, + ], + }); + }); + + afterEach(() => { + consoleError.mockRestore(); + }); + + it('reports no failure while storage works', async () => { + const store = injector.get(SettingsStore); + + await store.loadSettings(); + await store.updateSettings({ language: Language.FRENCH }); + + expect(store.storageFailure()).toBeNull(); + }); + + it('flags a failed initial load so defaults are not mistaken for saved values', async () => { + const failingSettings = new Subject | null>(); + storage.get.mockReturnValueOnce(failingSettings.asObservable()); + const store = injector.get(SettingsStore); + + const initialLoad = store.loadSettings(); + failingSettings.error(new Error('storage unavailable')); + await initialLoad; + + expect(store.storageFailure()).toBe('load'); + expect(store.getSettings().language).toBe(Language.ENGLISH); + }); + + it('flags a failed save and rethrows instead of silently keeping the in-memory change', async () => { + storage.set.mockImplementationOnce(() => { + throw new Error('quota exceeded'); + }); + const store = injector.get(SettingsStore); + await store.loadSettings(); + + await expect( + store.updateSettings({ language: Language.FRENCH }) + ).rejects.toThrow('quota exceeded'); + + expect(store.storageFailure()).toBe('save'); + // The in-memory patch still applied — that is exactly why the flag + // matters: the UI shows French but nothing reached disk. + expect(store.getSettings().language).toBe(Language.FRENCH); + }); + + it('clears the failure once a later save succeeds', async () => { + storage.set.mockImplementationOnce(() => { + throw new Error('quota exceeded'); + }); + const store = injector.get(SettingsStore); + await store.loadSettings(); + + await expect( + store.updateSettings({ language: Language.FRENCH }) + ).rejects.toThrow('quota exceeded'); + expect(store.storageFailure()).toBe('save'); + + await store.updateSettings({ language: Language.GERMAN }); + + expect(store.storageFailure()).toBeNull(); + }); + + it('clears a load failure once the retried load succeeds', async () => { + const failingSettings = new Subject | null>(); + storage.get + .mockReturnValueOnce(failingSettings.asObservable()) + .mockReturnValueOnce(of({ language: Language.FRENCH })); + const store = injector.get(SettingsStore); + + const initialLoad = store.loadSettings(); + failingSettings.error(new Error('storage unavailable')); + await initialLoad; + expect(store.storageFailure()).toBe('load'); + + await store.loadSettings(); + + expect(store.storageFailure()).toBeNull(); + expect(store.getSettings().language).toBe(Language.FRENCH); + }); +}); diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index 447db58a2..af65ae281 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -59,6 +59,23 @@ const DEFAULT_SETTINGS: Settings = { tmdb: DEFAULT_TMDB_SETTINGS, }; +/** + * Which half of the settings persistence round-trip failed, if any. + * + * Settings live in the renderer's IndexedDB, which can be unavailable for + * reasons the app cannot control (a second instance holding the Chromium + * storage lock, a corrupted profile, storage blocked by security software). + * Both failures used to be swallowed: `updateSettings` patches the in-memory + * state before persisting, so a failed write still looked applied until the + * next restart (issue #1156). Recording the failure lets the settings UI say + * so instead of pretending the change stuck. + */ +export type SettingsStorageFailure = 'load' | 'save'; + +interface SettingsStorageState { + storageFailure: SettingsStorageFailure | null; +} + let embeddedMpvPrepareScheduled = false; function scheduleEmbeddedMpvPrepare(): void { @@ -101,6 +118,7 @@ function scheduleEmbeddedMpvPrepare(): void { export const SettingsStore = signalStore( { providedIn: 'root' }, withState(DEFAULT_SETTINGS), + withState({ storageFailure: null }), withComputed((store) => ({ /** * Live EPG panel layout with the `'timeline'` default applied — the @@ -124,6 +142,7 @@ export const SettingsStore = signalStore( const stored = await firstValueFrom( storage.get(STORE_KEY.Settings) ); + patchState(store, { storageFailure: null }); if (stored) { const storedSettings = stored as Partial; patchState(store, { @@ -147,7 +166,10 @@ export const SettingsStore = signalStore( })().catch((error) => { settingsLoadPromise = undefined; console.error('Failed to load settings:', error); - // Keep default settings if loading fails + // Keep default settings if loading fails, but remember + // that they are defaults-by-failure rather than by choice + // so the settings UI can warn about it. + patchState(store, { storageFailure: 'load' }); }); return settingsLoadPromise; @@ -176,11 +198,15 @@ export const SettingsStore = signalStore( await firstValueFrom( storage.set(STORE_KEY.Settings, completeSettings) ); + patchState(store, { storageFailure: null }); if (completeSettings.player === VideoPlayer.EmbeddedMpv) { scheduleEmbeddedMpvPrepare(); } } catch (error) { console.error('Failed to save settings:', error); + // The in-memory patch above already applied, so without + // this flag the change looks saved until the next restart. + patchState(store, { storageFailure: 'save' }); throw error; } },