fix(electron): disable service worker in desktop runtime

This commit is contained in:
4gray committed 2026-06-14 15:59:03 +02:00
1 parent 8739e95b70
commit ce78669c17
5 files changed
+201 -7

No files matched your search

+92
View File
@@ -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'],
});
});
});
+36 -5
View File
@@ -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);
});
});
+26 -2
View File
@@ -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:'
);
}
+9
View 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: