fix(settings): stop settings silently reverting on restart (#1272)

Settings live in the renderer's IndexedDB, and two failure modes made them look
saved while nothing reached disk.

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

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

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

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

Closes #1156
Closes #102
This commit is contained in:
4gray authored and GitHub committed 2026-07-27 23:14:30 +02:00
1 parent 0334296f15
commit bfad82c26c
37 files changed
+956 -23

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
@@ -70,6 +70,7 @@ This file provides guidance to coding agents working in this repository.
- Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`.
- If DevTools is open, `agent-browser --cdp 9222 ...` may attach to the DevTools page instead of the IPTVnator window. Symptoms: `tab list` shows `about:blank`, snapshots are empty, and screenshots are black.
- If that happens, inspect targets with `curl http://127.0.0.1:9222/json/list` and connect directly to the IPTVnator page websocket from the `webSocketDebuggerUrl` field.
- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost.
### Trace / Debug Startup
+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:
@@ -590,6 +591,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- Bootstraps Electron app and initializes database
- Registers event handlers for IPC communication
- Holds a single-instance lock (`app/services/single-instance.ts`), requested after the `userData` override so E2E runs with their own data dir keep independent locks. A second launch quits and focuses the running window; concurrent instances would otherwise share a Chromium profile whose IndexedDB only one of them can lock, silently breaking renderer-side settings persistence. `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` opts out for local debugging.
**Database**:
@@ -7,6 +7,7 @@ import {
Page,
test as base,
} from '@playwright/test';
import { spawn } from 'child_process';
import { createServer, Server } from 'http';
import {
accessSync,
@@ -119,6 +120,38 @@ export type LaunchedElectronApp = {
mainWindow: Page;
};
export type CompetingElectronInstanceResult = {
exitCode: number | null;
signal: NodeJS.Signals | null;
stderr: string;
timedOut: boolean;
};
const competingInstanceStderrLimit = 4000;
/**
* Removes a run's data directory, tolerating handles the OS has not released
* yet.
*
* Electron's single-instance lock keeps `SingletonLock`/`SingletonSocket`
* (`lockfile` on Windows) open for the whole process lifetime, and Windows
* releases those handles asynchronously as the process dies — so a removal
* issued right after `closeElectronApp` can still hit EBUSY. This is throwaway
* temp state, so a stubborn directory is a warning, never a test failure.
*/
function removeDataDir(dataDir: string): void {
try {
rmSync(dataDir, {
force: true,
maxRetries: 20,
recursive: true,
retryDelay: 250,
});
} catch (error) {
console.warn(`Could not remove E2E data dir ${dataDir}:`, error);
}
}
export const test = base.extend<ElectronFixtures>({
dataDir: async ({ browserName }, use) => {
void browserName;
@@ -126,12 +159,29 @@ export const test = base.extend<ElectronFixtures>({
await use(dataDir);
rmSync(dataDir, { force: true, recursive: true });
removeDataDir(dataDir);
},
});
export { expect };
/**
* Builds the argv every locally launched Electron process must share.
*
* Headless Linux CI has no usable sandbox or GPU, and an Electron started
* without these flags there dies on a signal instead of running — so any
* helper that spawns the app itself has to use the same list.
*/
function buildElectronLaunchArgs(extraArgs: readonly string[] = []): string[] {
const args = [...extraArgs, electronMainPath];
if (process.platform === 'linux' && process.env['CI']) {
args.unshift('--no-sandbox', '--disable-gpu');
}
return args;
}
export async function launchElectronApp(
dataDir: string,
options: LaunchElectronAppOptions = {}
@@ -143,11 +193,7 @@ export async function launchElectronApp(
}
assertPackagedRendererBuildIsElectronSafe();
const args = [...(options.args ?? []), electronMainPath];
if (process.platform === 'linux' && process.env['CI']) {
args.unshift('--no-sandbox', '--disable-gpu');
}
const args = buildElectronLaunchArgs(options.args);
const electronApp = await electron.launch({
args,
@@ -333,6 +379,65 @@ function attachElectronProcessDiagnostics(
});
}
/**
* Starts a raw second Electron process against an already-running instance's
* data directory and reports how it terminated.
*
* Deliberately not `launchElectronApp`: the expected outcome is that no window
* is ever created, because the single-instance guard hands the launch over to
* the running app. Two live instances would share a Chromium profile whose
* IndexedDB only one of them can lock, which is how settings silently stopped
* persisting (issues #102, #1156).
*/
export async function launchCompetingElectronInstance(
dataDir: string,
timeoutMs = 30000
): Promise<CompetingElectronInstanceResult> {
// In a Node context the `electron` package resolves to its binary path.
const electronBinaryPath = require('electron') as unknown as string;
const child = spawn(electronBinaryPath, buildElectronLaunchArgs(), {
env: {
...process.env,
ELECTRON_IS_DEV: '0',
IPTVNATOR_E2E_DATA_DIR: dataDir,
NODE_ENV: 'test',
},
stdio: ['ignore', 'ignore', 'pipe'],
});
// Kept for the assertion message: a competing launch that dies for an
// unrelated reason (missing sandbox, missing GPU) looks exactly like a
// refused one from the outside.
let stderr = '';
child.stderr?.on('data', (chunk: Buffer) => {
stderr = `${stderr}${chunk.toString()}`.slice(
-competingInstanceStderrLimit
);
});
return new Promise((resolvePromise) => {
const timer = setTimeout(() => {
child.kill();
resolvePromise({
exitCode: null,
signal: null,
stderr,
timedOut: true,
});
}, timeoutMs);
child.once('exit', (code, signal) => {
clearTimeout(timer);
resolvePromise({
exitCode: code,
signal,
stderr,
timedOut: false,
});
});
});
}
export async function closeElectronApp(
app: LaunchedElectronApp
): Promise<void> {
@@ -10,6 +10,7 @@ import {
expect,
goToDashboard,
importM3uPlaylistFromNativeDialog,
launchCompetingElectronInstance,
launchElectronApp,
LaunchedElectronApp,
m3uFixturePath,
@@ -145,6 +146,70 @@ test.describe('Electron Settings', () => {
}
});
test('@settings @persistence @electron refuses a second instance so it cannot break settings storage', async ({
dataDir,
}) => {
const runningApp = await launchElectronApp(dataDir);
try {
const competing = await launchCompetingElectronInstance(dataDir);
expect(
{
exitCode: competing.exitCode,
signal: competing.signal,
timedOut: competing.timedOut,
},
competing.stderr
).toEqual({ exitCode: 0, signal: null, timedOut: false });
// The running instance keeps its window and its storage lock.
expect(runningApp.electronApp.windows().length).toBeGreaterThan(0);
await expect(runningApp.mainWindow.locator('body')).toBeVisible();
} finally {
await closeElectronApp(runningApp);
}
// The lock is released on exit, so a later launch starts normally.
const relaunch = await launchElectronApp(dataDir);
await closeElectronApp(relaunch);
});
test('@settings @electron re-opens a window when a second launch arrives with none open', async ({
dataDir,
}) => {
// Only macOS keeps the process alive after its last window closes;
// elsewhere `window-all-closed` quits and the next launch is a plain
// cold start.
test.skip(
process.platform !== 'darwin',
'macOS-only windowless-process behaviour'
);
const runningApp = await launchElectronApp(dataDir);
try {
await runningApp.electronApp.evaluate(({ BrowserWindow }) => {
for (const window of BrowserWindow.getAllWindows()) {
window.close();
}
});
await expect
.poll(() => runningApp.electronApp.windows().length)
.toBe(0);
const recreatedWindow =
runningApp.electronApp.waitForEvent('window');
const competing = await launchCompetingElectronInstance(dataDir);
expect(competing.timedOut).toBe(false);
expect(competing.exitCode).toBe(0);
// Without this the guard would quit the launch into nothing and
// leave the user staring at no window at all.
await expect((await recreatedWindow).locator('body')).toBeVisible();
} finally {
await closeElectronApp(runningApp);
}
});
test('@settings @persistence @electron persists changed desktop settings across app restart', async ({
dataDir,
}) => {
+42
View File
@@ -59,6 +59,7 @@ type MockMainWindow = {
getNormalBounds: jest.Mock<object, []>;
// Read once by attachWindowStateEvents to seed the tracked window
// state; only reached off macOS, where the custom controls exist.
isDestroyed: jest.Mock<boolean, []>;
isFullScreen: jest.Mock<boolean, []>;
isMaximized: jest.Mock<boolean, []>;
loadFile: jest.Mock<Promise<void>, [string]>;
@@ -84,6 +85,7 @@ function createMockMainWindow(): MockMainWindow {
loadURL: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
once: jest.fn<void, [string, (...args: unknown[]) => void]>(),
isDestroyed: jest.fn<boolean, []>().mockReturnValue(false),
setMenu: jest.fn<void, [unknown]>(),
show: jest.fn<void, []>(),
webContents: {
@@ -97,6 +99,7 @@ function createMockMainWindow(): MockMainWindow {
type AppInternals = {
loadedMainWindow: MockMainWindow | null;
mainWindow: MockMainWindow | null;
mainWindowListeners: Array<(mainWindow: MockMainWindow) => void>;
mainWindowLoadPromise: Promise<void> | null;
onReady: () => void;
rendererLoadingEnabled: boolean;
@@ -120,6 +123,7 @@ describe('Electron app security helpers', () => {
appInternals.mainWindow = null;
appInternals.mainWindowLoadPromise = null;
appInternals.rendererLoadingEnabled = false;
appInternals.mainWindowListeners.length = 0;
(electronApp as unknown as { isPackaged: boolean }).isPackaged = false;
(screen.getPrimaryDisplay as jest.Mock).mockReturnValue({
workAreaSize: { height: 720, width: 1280 },
@@ -322,6 +326,44 @@ describe('Electron app security helpers', () => {
expect(mainWindow.loadFile).not.toHaveBeenCalled();
});
it('runs a main-window listener immediately when a window already exists', () => {
const mainWindow = createMockMainWindow();
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
(electronApp.isReady as jest.Mock).mockReturnValue(true);
App.main(electronApp, BrowserWindow);
const listener = jest.fn();
App.onMainWindowCreated(
listener as unknown as (mainWindow: Electron.BrowserWindow) => void
);
expect(listener).toHaveBeenCalledWith(mainWindow);
});
// macOS keeps the process alive without windows, so anything caching the
// window (download-broadcast) must be handed the rebuilt one.
it('re-runs main-window listeners when the window is rebuilt', () => {
const firstWindow = createMockMainWindow();
(BrowserWindow as unknown as jest.Mock).mockReturnValue(firstWindow);
(electronApp.isReady as jest.Mock).mockReturnValue(true);
App.main(electronApp, BrowserWindow);
const listener = jest.fn();
App.onMainWindowCreated(
listener as unknown as (mainWindow: Electron.BrowserWindow) => void
);
listener.mockClear();
// Simulate the macOS 'closed' handler clearing the reference.
getAppInternals().mainWindow = null;
const secondWindow = createMockMainWindow();
(BrowserWindow as unknown as jest.Mock).mockReturnValue(secondWindow);
App.ensureMainWindow();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(secondWindow);
});
it('clears only service worker registrations and cache storage', async () => {
await clearElectronServiceWorkerStorage();
+50 -3
View File
@@ -216,6 +216,9 @@ export default class App {
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
static mainWindow: Electron.BrowserWindow | null = null;
private static readonly mainWindowListeners: Array<
(mainWindow: Electron.BrowserWindow) => void
> = [];
static application: Electron.App;
static BrowserWindow;
private static loadedMainWindow: Electron.BrowserWindow | null = null;
@@ -268,9 +271,46 @@ export default class App {
}
}
private static onActivate() {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
/**
* Registers a listener that needs the current main window, and re-runs it
* whenever a new one is created.
*
* The main window is not created once per process: on macOS the window can
* be closed and rebuilt (dock `activate`, or a second launch handed over by
* the single-instance guard) while the process lives on. Anything that
* caches the window — `download-broadcast`'s module-level reference, for
* one — would otherwise keep pointing at a destroyed window and silently
* stop delivering to the renderer. Fires immediately when a window already
* exists, so callers registering after startup do not miss the first one.
*/
static onMainWindowCreated(
listener: (mainWindow: Electron.BrowserWindow) => void
): void {
App.mainWindowListeners.push(listener);
if (App.mainWindow && !App.mainWindow.isDestroyed()) {
listener(App.mainWindow);
}
}
private static notifyMainWindowCreated(
mainWindow: Electron.BrowserWindow
): void {
for (const listener of App.mainWindowListeners) {
listener(mainWindow);
}
}
/**
* Brings the app back to a windowed state, re-creating the main window if
* it is gone.
*
* On macOS closing the last window deliberately keeps the process alive
* (`onWindowAllClosed`), so this is the recovery path for both the dock
* `activate` event and a second launch that the single-instance guard
* hands over to this process.
*/
static ensureMainWindow() {
if (App.mainWindow === null) {
App.onReady();
}
@@ -279,6 +319,12 @@ export default class App {
}
}
private static onActivate() {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
App.ensureMainWindow();
}
private static handleRendererNavigation(
event: Electron.Event,
url: string
@@ -383,6 +429,7 @@ export default class App {
App.mainWindow.setMenu(null);
attachWindowTrace(App.mainWindow);
App.attachWindowStateEvents(App.mainWindow);
App.notifyMainWindowCreated(App.mainWindow);
if (!savedWindowBounds) {
App.mainWindow.center();
}
@@ -0,0 +1,163 @@
import {
ALLOW_MULTIPLE_INSTANCES_ENV,
acquireSingleInstanceLock,
allowsMultipleInstances,
focusExistingWindow,
type SingleInstanceApp,
type SingleInstanceWindow,
} from './single-instance';
function createApp(hasLock: boolean): jest.Mocked<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, jest.fn(), {})
).toBe(true);
expect(app.quit).not.toHaveBeenCalled();
expect(app.on).toHaveBeenCalledWith(
'second-instance',
expect.any(Function)
);
});
it('quits and stops startup when another instance owns the profile', () => {
const app = createApp(false);
expect(
acquireSingleInstanceLock(app, () => null, jest.fn(), {})
).toBe(false);
expect(app.quit).toHaveBeenCalledTimes(1);
expect(app.on).not.toHaveBeenCalled();
});
it('focuses the existing window when a second instance is launched', () => {
const app = createApp(true);
const window = createWindow({
isMinimized: jest.fn().mockReturnValue(true),
isVisible: jest.fn().mockReturnValue(false),
});
const createMainWindow = jest.fn();
acquireSingleInstanceLock(app, () => window, createMainWindow, {});
const [, handler] = app.on.mock.calls[0];
(handler as () => void)();
expect(window.restore).toHaveBeenCalledTimes(1);
expect(window.show).toHaveBeenCalledTimes(1);
expect(window.focus).toHaveBeenCalledTimes(1);
expect(createMainWindow).not.toHaveBeenCalled();
});
// macOS keeps the process alive after the last window closes, so a second
// launch must rebuild a window instead of quitting into nothing.
it.each([
['no window exists', null],
['the window was destroyed', 'destroyed'],
])('re-creates the main window when %s', (_label, windowState) => {
const app = createApp(true);
const window =
windowState === 'destroyed'
? createWindow({
isDestroyed: jest.fn().mockReturnValue(true),
})
: null;
const createMainWindow = jest.fn();
acquireSingleInstanceLock(app, () => window, createMainWindow, {});
const [, handler] = app.on.mock.calls[0];
(handler as () => void)();
expect(createMainWindow).toHaveBeenCalledTimes(1);
if (window) {
expect(window.focus).not.toHaveBeenCalled();
}
});
it('skips the lock entirely when multiple instances are allowed', () => {
const app = createApp(false);
expect(
acquireSingleInstanceLock(app, () => null, jest.fn(), {
[ALLOW_MULTIPLE_INSTANCES_ENV]: '1',
})
).toBe(true);
expect(app.requestSingleInstanceLock).not.toHaveBeenCalled();
expect(app.quit).not.toHaveBeenCalled();
});
});
describe('focusExistingWindow', () => {
it('does nothing without a window', () => {
expect(() => focusExistingWindow(null)).not.toThrow();
});
it('does not touch a destroyed window', () => {
const window = createWindow({
isDestroyed: jest.fn().mockReturnValue(true),
});
focusExistingWindow(window);
expect(window.focus).not.toHaveBeenCalled();
expect(window.restore).not.toHaveBeenCalled();
});
it('only focuses an already visible window', () => {
const window = createWindow();
focusExistingWindow(window);
expect(window.restore).not.toHaveBeenCalled();
expect(window.show).not.toHaveBeenCalled();
expect(window.focus).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,114 @@
/**
* Single-instance guard.
*
* Every Electron instance that shares a `userData` directory also shares the
* Chromium storage profile behind it. Only the first process gets the
* IndexedDB LevelDB lock, so a second concurrent instance silently loses
* renderer-side persistence: the settings store falls back to defaults on read
* and its writes never reach disk (issues #102, #1156). Holding a single
* instance lock is the fix — a second launch focuses the window that already
* owns the profile instead of opening a broken twin.
*
* The lock is scoped to `userData`, so it must be requested *after*
* `app.setPath('userData', ...)`; otherwise E2E runs with a dedicated data
* directory would contend on the default profile's lock instead of their own.
*/
/** Minimal Electron `App` surface used by the guard, kept narrow for tests. */
export interface SingleInstanceApp {
quit(): void;
on(event: 'second-instance', listener: () => void): unknown;
requestSingleInstanceLock(): boolean;
}
/** Minimal `BrowserWindow` surface used when re-focusing the existing window. */
export interface SingleInstanceWindow {
focus(): void;
isDestroyed(): boolean;
isMinimized(): boolean;
isVisible(): boolean;
restore(): void;
show(): void;
}
export const ALLOW_MULTIPLE_INSTANCES_ENV = 'IPTVNATOR_ALLOW_MULTIPLE_INSTANCES';
const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']);
/**
* Escape hatch for local debugging (e.g. attaching a second CDP-enabled
* instance to the same database). Off by default so packaged users always get
* the guard.
*/
export function allowsMultipleInstances(
env: NodeJS.ProcessEnv = process.env
): boolean {
return TRUTHY_VALUES.has(
(env[ALLOW_MULTIPLE_INSTANCES_ENV] ?? '').trim().toLowerCase()
);
}
/** Brings an existing main window back to the foreground. */
export function focusExistingWindow(
window: SingleInstanceWindow | null | undefined
): void {
if (!window || window.isDestroyed()) {
return;
}
if (window.isMinimized()) {
window.restore();
}
if (!window.isVisible()) {
window.show();
}
window.focus();
}
/** True when there is no live window left to bring forward. */
function needsNewWindow(
window: SingleInstanceWindow | null | undefined
): boolean {
return !window || window.isDestroyed();
}
/**
* Acquires the single instance lock.
*
* @param createMainWindow re-creates the main window when the lock owner has
* none left. On macOS closing the last window keeps the process alive, so
* without this a second launch would quit silently and leave the user with no
* window at all.
* @returns `true` when this process owns the profile and startup may continue,
* `false` when another instance already owns it and this one is quitting.
*/
export function acquireSingleInstanceLock(
app: SingleInstanceApp,
getMainWindow: () => SingleInstanceWindow | null | undefined,
createMainWindow: () => void,
env: NodeJS.ProcessEnv = process.env
): boolean {
if (allowsMultipleInstances(env)) {
return true;
}
if (!app.requestSingleInstanceLock()) {
app.quit();
return false;
}
app.on('second-instance', () => {
const mainWindow = getMainWindow();
if (needsNewWindow(mainWindow)) {
createMainWindow();
return;
}
focusExistingWindow(mainWindow);
});
return true;
}
+19 -4
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');
@@ -148,10 +149,10 @@ export default class Main {
EpgEvents.bootstrapEpgEvents();
RemoteControlEvents.bootstrapRemoteControlEvents();
// Set main window for downloads and reset stale downloads
if (App.mainWindow) {
setDownloadsMainWindow(App.mainWindow);
}
// Keep the downloads broadcaster bound to the live window. macOS can
// rebuild the window while the process runs, and a stale reference
// silently swallows every DOWNLOADS_UPDATE_EVENT.
App.onMainWindowCreated(setDownloadsMainWindow);
// Load the renderer only after IPC handlers are registered. On slower
// Linux CI hosts the renderer can otherwise invoke Electron bridge IPC
@@ -194,6 +195,20 @@ runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => {
// handle setup events as quickly as possible
Main.initialize();
// A second instance sharing this userData directory cannot take the
// Chromium storage lock, so its renderer silently loses every settings
// write. Focus the window that already owns the profile instead — or
// re-create it, since on macOS the process outlives its last window.
if (
!acquireSingleInstanceLock(
app,
() => App.mainWindow,
() => App.ensureMainWindow()
)
) {
return;
}
// bootstrap app
Main.bootstrapApp();
@@ -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,33 @@ export class SettingsSnackbarService {
...config,
});
}
/**
* Failures need more reading time than the 2s confirmation toast, and a
* dismiss action so the message can stay up until it is acknowledged.
*/
error(message: string, dismissLabel: string): void {
this.snackBar.open(message, dismissLabel, {
duration: 10000,
horizontalPosition: 'center',
verticalPosition: 'bottom',
panelClass: ['settings-snackbar', 'settings-snackbar--error'],
});
}
/**
* Reports a failed settings read/write. Both mean the same thing to the
* user — what is on screen will not survive a restart — so they get a
* dismissible warning instead of the silent fallback to defaults.
*/
storageFailure(failure: SettingsStorageFailure): void {
this.error(
this.translate.instant(
failure === 'save'
? 'SETTINGS.SETTINGS_SAVE_FAILED'
: 'SETTINGS.SETTINGS_LOAD_FAILED'
),
this.translate.instant('CLOSE')
);
}
}
@@ -0,0 +1,128 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsComponent } from './settings.component';
import {
configureSettingsComponentTestBed,
createElectronStub,
createEpgBridgeStub,
MatSnackBarStub,
MockSettingsStore,
stubSettingsSideEffects,
} from './test-stubs/settings-test-harness.stub';
/** Matches the snackbar config used by `SettingsSnackbarService.error`. */
const ERROR_SNACKBAR_CONFIG = expect.objectContaining({
panelClass: ['settings-snackbar', 'settings-snackbar--error'],
});
/**
* Assertions target the MatDialog the component actually calls: spying on the
* one returned by `TestBed.inject(MatDialog)` does not observe it.
*/
interface SettingsComponentPrivateTestApi {
matDialog: MatDialog;
}
/**
* Settings live in the renderer's IndexedDB, and both halves of the round trip
* can fail while the UI keeps looking healthy: a failed read shows defaults as
* if they were saved, and a failed write is applied in memory so it survives
* until the next restart. These cover the user-visible reporting of both.
*/
describe('SettingsComponent storage failures', () => {
let component: SettingsComponent;
let fixture: ComponentFixture<SettingsComponent>;
let settingsStore: MockSettingsStore;
let snackBar: MatSnackBarStub;
let epgBridge: Partial<EpgRuntimeBridgeService>;
const originalElectron = window.electron;
const componentMatDialog = (): MatDialog =>
(component as unknown as SettingsComponentPrivateTestApi).matDialog;
beforeEach(waitForAsync(() => {
epgBridge = createEpgBridgeStub();
configureSettingsComponentTestBed(epgBridge);
}));
beforeEach(() => {
window.electron = createElectronStub();
fixture = TestBed.createComponent(SettingsComponent);
settingsStore = TestBed.inject(
SettingsStore
) as unknown as MockSettingsStore;
snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub;
component = fixture.componentInstance;
stubSettingsSideEffects(component);
fixture.detectChanges();
});
afterEach(() => {
window.electron = originalElectron;
});
it('warns when the persisted settings could not be read', async () => {
settingsStore.storageFailure.set('load');
await component.ngOnInit();
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.SETTINGS_LOAD_FAILED',
'CLOSE',
ERROR_SNACKBAR_CONFIG
);
});
it('warns and keeps the dialog open when the settings write fails', async () => {
settingsStore.updateSettings.mockRejectedValue(
new Error('storage unavailable')
);
const closeAll = jest.spyOn(componentMatDialog(), 'closeAll');
component.isDialog = true;
component.onSubmit();
await fixture.whenStable();
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.SETTINGS_SAVE_FAILED',
'CLOSE',
ERROR_SNACKBAR_CONFIG
);
expect(closeAll).not.toHaveBeenCalled();
});
it('closes the dialog only after the settings write succeeded', async () => {
settingsStore.updateSettings.mockResolvedValue(undefined);
const closeAll = jest.spyOn(componentMatDialog(), 'closeAll');
jest.spyOn(component.epg, 'fetchConfiguredEpg').mockImplementation();
component.isDialog = true;
component.onSubmit();
// save() resolves a tick after the store write: the callback and
// the Electron mirror run first, so the close lands on the next
// turn of the microtask queue.
await fixture.whenStable();
await fixture.whenStable();
expect(closeAll).toHaveBeenCalledTimes(1);
});
it('warns when a section write fails without leaving an unhandled rejection', async () => {
settingsStore.updateSettings.mockRejectedValue(
new Error('storage unavailable')
);
component.form.selectCoverSize('large');
await fixture.whenStable();
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.SETTINGS_SAVE_FAILED',
'CLOSE',
ERROR_SNACKBAR_CONFIG
);
});
});
@@ -199,11 +199,26 @@ export class SettingsComponent implements OnInit, OnDestroy {
* the indexed db store
*/
onSubmit(): void {
void this.form.save(() => this.applyChangedSettings());
if (this.isDialog) {
this.matDialog.closeAll();
}
this.form
.save(() => this.applyChangedSettings())
.then(() => {
if (this.isDialog) {
this.matDialog.closeAll();
}
})
.catch(() => {
// The store already applied the change in memory, so without
// this the save looks successful until the next restart. The
// dialog stays open so it can be retried.
//
// The Electron-side pushes in SettingsFormFacade.save() stay in
// the success branch on purpose: main keeps its own copy of the
// player paths and remote-control state, and applying half the
// form while telling the user nothing was saved is worse than
// applying none of it. Once settings live in the main process
// (issue #1273) this split disappears.
this.settingsSnackbar.storageFailure('save');
});
}
/**
@@ -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;
}
},