From 917691b80c6de1142dc2dcf42d9cc29593e15d70 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 19:27:16 +0200 Subject: [PATCH] fix(settings): stop settings silently reverting on restart Settings live in the renderer's IndexedDB. 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 onSubmit() 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 via a dismissible error snackbar; the dialog stays open on failure so the save can be retried. Closes #1156 Closes #102 Co-Authored-By: Claude Opus 5 --- .changes/settings-persistence-feedback.md | 10 ++ AGENTS.md | 1 + CLAUDE.md | 2 + .../src/electron-test-fixtures.ts | 40 ++++++ apps/electron-backend-e2e/src/settings.e2e.ts | 23 +++ .../src/app/services/single-instance.spec.ts | 132 ++++++++++++++++++ .../src/app/services/single-instance.ts | 95 +++++++++++++ apps/electron-backend/src/main.ts | 8 ++ .../src/app/settings/settings-form.facade.ts | 20 ++- .../app/settings/settings-snackbar.service.ts | 45 ++++++ .../settings/settings.component.form.spec.ts | 65 +++++++++ .../src/app/settings/settings.component.ts | 18 ++- .../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 +++- 35 files changed, 633 insertions(+), 10 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 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 cdf03a35e..bb628978a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,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 3bc0945f8..7ea2722eb 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: @@ -581,6 +582,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..f5e9b6189 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, @@ -333,6 +334,45 @@ 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<{ exitCode: number | null; timedOut: boolean }> { + // In a Node context the `electron` package resolves to its binary path. + const electronBinaryPath = require('electron') as unknown as string; + const child = spawn(electronBinaryPath, [electronMainPath], { + env: { + ...process.env, + ELECTRON_IS_DEV: '0', + IPTVNATOR_E2E_DATA_DIR: dataDir, + NODE_ENV: 'test', + }, + stdio: 'ignore', + }); + + return new Promise((resolvePromise) => { + const timer = setTimeout(() => { + child.kill(); + resolvePromise({ exitCode: null, timedOut: true }); + }, timeoutMs); + + child.once('exit', (code) => { + clearTimeout(timer); + resolvePromise({ exitCode: code, 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..0525a0ceb 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,28 @@ 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(competing.timedOut).toBe(false); + expect(competing.exitCode).toBe(0); + // 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 @persistence @electron persists changed desktop settings across app restart', async ({ dataDir, }) => { 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..0a66ad5f8 --- /dev/null +++ b/apps/electron-backend/src/app/services/single-instance.spec.ts @@ -0,0 +1,132 @@ +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, {})).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, {})).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), + }); + + acquireSingleInstanceLock(app, () => window, {}); + 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); + }); + + it('skips the lock entirely when multiple instances are allowed', () => { + const app = createApp(false); + + expect( + acquireSingleInstanceLock(app, () => null, { + [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..1c2922108 --- /dev/null +++ b/apps/electron-backend/src/app/services/single-instance.ts @@ -0,0 +1,95 @@ +/** + * 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(); +} + +/** + * Acquires the single instance lock. + * + * @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, + env: NodeJS.ProcessEnv = process.env +): boolean { + if (allowsMultipleInstances(env)) { + return true; + } + + if (!app.requestSingleInstanceLock()) { + app.quit(); + return false; + } + + app.on('second-instance', () => { + focusExistingWindow(getMainWindow()); + }); + + return true; +} diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index ea9b08923..120c4ce62 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'); @@ -194,6 +195,13 @@ 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. + if (!acquireSingleInstanceLock(app, () => App.mainWindow)) { + 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..e4e8fb985 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,46 @@ 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'], + }); + } + + /** + * 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.form.spec.ts b/apps/web/src/app/settings/settings.component.form.spec.ts index c11db5c82..d158d11a6 100644 --- a/apps/web/src/app/settings/settings.component.form.spec.ts +++ b/apps/web/src/app/settings/settings.component.form.spec.ts @@ -1,7 +1,9 @@ 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 { + CoverSize, Language, StartupBehavior, Theme, @@ -21,6 +23,11 @@ import { 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'], +}); + /** * Everything the rendered settings form does: hydration from the store, the * section outputs that write straight through, and the submit path. @@ -71,6 +78,18 @@ describe('SettingsComponent form', () => { expect(component.settingsForm.value).toEqual(DEFAULT_SETTINGS); }); + 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('should get and apply custom settings', () => { settingsStore._setSettings({ ...DEFAULT_SETTINGS, @@ -248,6 +267,52 @@ describe('SettingsComponent form', () => { }); }); + it('warns and keeps the dialog open when the settings write fails', async () => { + settingsStore.updateSettings.mockRejectedValue( + new Error('storage unavailable') + ); + const matDialog = TestBed.inject(MatDialog); + const closeAll = jest.spyOn(matDialog, '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 matDialog = TestBed.inject(MatDialog); + const closeAll = jest.spyOn(matDialog, 'closeAll'); + component.isDialog = true; + + component.onSubmit(); + 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(CoverSize.Large); + await fixture.whenStable(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.SETTINGS_SAVE_FAILED', + 'CLOSE', + ERROR_SNACKBAR_CONFIG + ); + }); + it('saves the shared web controls setting on submit', async () => { settingsStore.updateSettings.mockResolvedValue(undefined); component.settingsForm diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 5535b9c76..d8699ba26 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -199,11 +199,19 @@ 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. + 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; } },