mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
0334296f15
commit
bfad82c26c
37 files changed
+956
-23
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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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