mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
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:
1 parent
24f0dee6f0
commit
917691b80c
35 files changed
+633
-10
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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": "عرض لوحة التحكم",
|
||||
|
||||
@@ -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": "وري لوحة التحكم",
|
||||
|
||||
@@ -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": "Паказаць панэль",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Εμφάνιση πίνακα ελέγχου",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ダッシュボードを表示",
|
||||
|
||||
@@ -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": "대시보드 표시",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Показывать панель",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "显示仪表盘",
|
||||
|
||||
@@ -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": "顯示儀表板",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user