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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-27 20:08:12 +02:00
1 parent 24f0dee6f0
commit 917691b80c
35 files changed
+633 -10

No files matched your search

+10
View File
@@ -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.
+1
View File
@@ -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
+2
View File
@@ -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**:
@@ -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<void> {
@@ -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,
}) => {
@@ -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<SingleInstanceApp> {
return {
quit: jest.fn(),
on: jest.fn(),
requestSingleInstanceLock: jest.fn().mockReturnValue(hasLock),
};
}
function createWindow(
overrides: Partial<SingleInstanceWindow> = {}
): jest.Mocked<SingleInstanceWindow> {
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<SingleInstanceWindow>;
}
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);
});
});
@@ -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;
}
+8
View File
@@ -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();
@@ -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<void> {
return this.settingsStore.loadSettings();
async loadSettings(): Promise<void> {
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 {
@@ -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')
);
}
}
@@ -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
@@ -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');
});
}
/**
@@ -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<SettingsStorageFailure | null>(null);
// Helper method for tests to modify settings
_setSettings(newSettings: Partial<typeof DEFAULT_SETTINGS>) {
this._settings.set({
+2
View File
@@ -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": "عرض لوحة التحكم",
+2
View File
@@ -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": "وري لوحة التحكم",
+2
View File
@@ -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": "Паказаць панэль",
+2
View File
@@ -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",
+2
View File
@@ -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": "Εμφάνιση πίνακα ελέγχου",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "ダッシュボードを表示",
+2
View File
@@ -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": "대시보드 표시",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "Показывать панель",
+2
View File
@@ -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",
+2
View File
@@ -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": "显示仪表盘",
+2
View File
@@ -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": "顯示儀表板",
+8
View File
@@ -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
@@ -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<Partial<Settings> | 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<Partial<Settings> | 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);
});
});
@@ -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<Settings>(DEFAULT_SETTINGS),
withState<SettingsStorageState>({ 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<Settings>;
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;
}
},