mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(electron): disable service worker in desktop runtime
This commit is contained in:
1 parent
8739e95b70
commit
ce78669c17
5 files changed
+201
-7
No files matched your search
@@ -1,3 +1,5 @@
|
||||
const mockClearStorageData = jest.fn();
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: jest.fn(() => '/tmp'),
|
||||
@@ -10,6 +12,11 @@ jest.mock('electron', () => ({
|
||||
screen: {
|
||||
getPrimaryDisplay: jest.fn(),
|
||||
},
|
||||
session: {
|
||||
defaultSession: {
|
||||
clearStorageData: mockClearStorageData,
|
||||
},
|
||||
},
|
||||
shell: {
|
||||
openExternal: jest.fn(),
|
||||
},
|
||||
@@ -24,12 +31,48 @@ jest.mock('./services/store.service', () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
clearElectronServiceWorkerStorage,
|
||||
getMainWindowWebPreferences,
|
||||
isExternalBrowserUrl,
|
||||
isTrustedRendererNavigationUrl,
|
||||
} from './app';
|
||||
import App from './app';
|
||||
import { app as electronApp } from 'electron';
|
||||
|
||||
type MockMainWindow = {
|
||||
loadFile: jest.Mock<Promise<void>, [string]>;
|
||||
loadURL: jest.Mock<Promise<void>, [string]>;
|
||||
webContents: {
|
||||
openDevTools: jest.Mock<void, []>;
|
||||
};
|
||||
};
|
||||
|
||||
function createMockMainWindow(): MockMainWindow {
|
||||
return {
|
||||
loadFile: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
|
||||
loadURL: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
|
||||
webContents: {
|
||||
openDevTools: jest.fn<void, []>(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type AppInternals = {
|
||||
mainWindow: MockMainWindow;
|
||||
loadMainWindow: () => Promise<void>;
|
||||
};
|
||||
|
||||
function getAppInternals(): AppInternals {
|
||||
return App as unknown as AppInternals;
|
||||
}
|
||||
|
||||
describe('Electron app security helpers', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env.ELECTRON_IS_DEV;
|
||||
(electronApp as unknown as { isPackaged: boolean }).isPackaged = false;
|
||||
});
|
||||
|
||||
it('creates an explicitly hardened BrowserWindow webPreferences object', () => {
|
||||
expect(getMainWindowWebPreferences()).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -90,4 +133,53 @@ describe('Electron app security helpers', () => {
|
||||
isTrustedRendererNavigationUrl('https://example.com', false)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('clears Electron service worker storage before loading the packaged renderer', async () => {
|
||||
const appInternals = getAppInternals();
|
||||
const mainWindow = createMockMainWindow();
|
||||
appInternals.mainWindow = mainWindow;
|
||||
(electronApp as unknown as { isPackaged: boolean }).isPackaged = true;
|
||||
|
||||
await appInternals.loadMainWindow();
|
||||
|
||||
expect(mockClearStorageData).toHaveBeenCalledWith({
|
||||
storages: ['serviceworkers', 'cachestorage'],
|
||||
});
|
||||
expect(mainWindow.loadFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('index.html')
|
||||
);
|
||||
expect(
|
||||
mockClearStorageData.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(mainWindow.loadFile.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('continues packaged renderer loading when Electron service worker cleanup fails', async () => {
|
||||
const appInternals = getAppInternals();
|
||||
const mainWindow = createMockMainWindow();
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
|
||||
appInternals.mainWindow = mainWindow;
|
||||
(electronApp as unknown as { isPackaged: boolean }).isPackaged = true;
|
||||
mockClearStorageData.mockRejectedValueOnce(new Error('cleanup failed'));
|
||||
|
||||
await appInternals.loadMainWindow();
|
||||
|
||||
expect(mainWindow.loadFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('index.html')
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Failed to clear Electron service worker storage:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('clears only service worker registrations and cache storage', async () => {
|
||||
await clearElectronServiceWorkerStorage();
|
||||
|
||||
expect(mockClearStorageData).toHaveBeenCalledWith({
|
||||
storages: ['serviceworkers', 'cachestorage'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { app, BrowserWindow, Menu, screen, shell } from 'electron';
|
||||
import { app, BrowserWindow, Menu, screen, session, shell } from 'electron';
|
||||
import { WINDOW_STATE_CHANGED } from '@iptvnator/shared/interfaces';
|
||||
import { join, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { rendererAppName, rendererAppPort } from './constants';
|
||||
import {
|
||||
isStartupTraceEnabled,
|
||||
isRendererConsoleTraceEnabled,
|
||||
isWindowTraceEnabled,
|
||||
trace,
|
||||
@@ -88,6 +89,30 @@ export function getMainWindowWebPreferences(): Electron.BrowserWindowConstructor
|
||||
};
|
||||
}
|
||||
|
||||
export async function clearElectronServiceWorkerStorage(
|
||||
electronSession: Pick<Electron.Session, 'clearStorageData'> = session.defaultSession
|
||||
): Promise<void> {
|
||||
try {
|
||||
await electronSession.clearStorageData({
|
||||
storages: ['serviceworkers', 'cachestorage'],
|
||||
});
|
||||
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'electron-service-worker-storage:cleared');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear Electron service worker storage:', error);
|
||||
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace(
|
||||
'startup',
|
||||
'electron-service-worker-storage:clear-failed',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function attachWindowTrace(mainWindow: Electron.BrowserWindow): void {
|
||||
if (!isWindowTraceEnabled()) {
|
||||
return;
|
||||
@@ -211,7 +236,9 @@ export default class App {
|
||||
// Some APIs can only be used after this event occurs.
|
||||
if (rendererAppName) {
|
||||
App.initMainWindow();
|
||||
App.loadMainWindow();
|
||||
void App.loadMainWindow().catch((error) => {
|
||||
console.error('Failed to load main window:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,15 +409,19 @@ export default class App {
|
||||
});
|
||||
}
|
||||
|
||||
private static loadMainWindow() {
|
||||
private static async loadMainWindow(): Promise<void> {
|
||||
// load the index.html of the app.
|
||||
if (App.isDevelopmentMode()) {
|
||||
App.mainWindow.loadURL(`http://localhost:${rendererAppPort}`);
|
||||
const loadPromise = App.mainWindow.loadURL(
|
||||
`http://localhost:${rendererAppPort}`
|
||||
);
|
||||
if (App.shouldOpenDevTools()) {
|
||||
App.mainWindow.webContents.openDevTools();
|
||||
}
|
||||
await loadPromise;
|
||||
} else {
|
||||
App.mainWindow.loadFile(getPackagedRendererIndexPath());
|
||||
await clearElectronServiceWorkerStorage();
|
||||
await App.mainWindow.loadFile(getPackagedRendererIndexPath());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,4 +36,42 @@ describe('runtime config helpers', () => {
|
||||
).toBe(false);
|
||||
expect(shouldEnableServiceWorker(true, {} as Navigator)).toBe(false);
|
||||
});
|
||||
|
||||
it('disables service worker for Electron runtime', () => {
|
||||
expect(
|
||||
shouldEnableServiceWorker(
|
||||
true,
|
||||
{ serviceWorker: {} } as Navigator,
|
||||
{
|
||||
electronBridge: {},
|
||||
protocol: 'file:',
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('disables service worker for Electron runtime on non-file origins', () => {
|
||||
expect(
|
||||
shouldEnableServiceWorker(
|
||||
true,
|
||||
{ serviceWorker: {} } as Navigator,
|
||||
{
|
||||
electronBridge: {},
|
||||
protocol: 'https:',
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('disables service worker for file origins without an Electron bridge', () => {
|
||||
expect(
|
||||
shouldEnableServiceWorker(
|
||||
true,
|
||||
{ serviceWorker: {} } as Navigator,
|
||||
{
|
||||
protocol: 'file:',
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -19,9 +19,33 @@ export function getRuntimeBackendUrl(): string {
|
||||
);
|
||||
}
|
||||
|
||||
export interface ServiceWorkerRuntimeContext {
|
||||
readonly electronBridge?: unknown;
|
||||
readonly protocol?: string;
|
||||
}
|
||||
|
||||
function getDefaultServiceWorkerRuntimeContext(): ServiceWorkerRuntimeContext {
|
||||
const browserWindow = globalThis.window as
|
||||
| (Window & { electron?: unknown })
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
electronBridge: browserWindow?.electron,
|
||||
protocol:
|
||||
browserWindow?.location?.protocol ?? globalThis.location?.protocol,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldEnableServiceWorker(
|
||||
production = AppConfig.production,
|
||||
navigatorRef: Navigator | undefined = globalThis.navigator
|
||||
navigatorRef: Navigator | undefined = globalThis.navigator,
|
||||
runtimeContext = getDefaultServiceWorkerRuntimeContext()
|
||||
): boolean {
|
||||
return production && !!navigatorRef && 'serviceWorker' in navigatorRef;
|
||||
return (
|
||||
production &&
|
||||
!!navigatorRef &&
|
||||
'serviceWorker' in navigatorRef &&
|
||||
!runtimeContext.electronBridge &&
|
||||
runtimeContext.protocol !== 'file:'
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,15 @@ Angular also emits hashed font and media assets under `dist/apps/web/media/`.
|
||||
Keep `/media/**` in `ngsw-config.json` so the PWA service worker can cache
|
||||
bundled fonts, including Material Icons.
|
||||
|
||||
The Angular service worker is a browser/PWA feature only. Packaged Electron
|
||||
loads the same Angular production bundle from `file://.../app.asar/web`, but it
|
||||
must not register `ngsw-worker.js`; otherwise a desktop update can leave the
|
||||
first Electron window controlled by a stale file-origin service worker and serve
|
||||
old chunks from Electron `userData`. Electron clears legacy `serviceworkers` and
|
||||
`cachestorage` storage from its default session before loading the packaged
|
||||
renderer so existing desktop installs recover on the next startup without
|
||||
clearing unrelated app storage.
|
||||
|
||||
`web:serve-static` serves `dist/apps/web` and builds with `web:build:pwa`, so it
|
||||
exercises the same output layout as Docker. If Nx daemon state returns stale
|
||||
service worker outputs while changing build options, run:
|
||||
|
||||
Reference in new issue
Block a user