From ce78669c17cf0ecf6ff418ca89e6b69ba3c066de Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 14 Jun 2026 15:35:59 +0200 Subject: [PATCH] fix(electron): disable service worker in desktop runtime --- apps/electron-backend/src/app/app.spec.ts | 92 +++++++++++++++++++ apps/electron-backend/src/app/app.ts | 41 ++++++++- .../src/app/services/runtime-config.spec.ts | 38 ++++++++ apps/web/src/app/services/runtime-config.ts | 28 +++++- docs/architecture/pwa-self-hosted.md | 9 ++ 5 files changed, 201 insertions(+), 7 deletions(-) diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index b10e63772..3afd14d84 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -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, [string]>; + loadURL: jest.Mock, [string]>; + webContents: { + openDevTools: jest.Mock; + }; +}; + +function createMockMainWindow(): MockMainWindow { + return { + loadFile: jest.fn, [string]>().mockResolvedValue(), + loadURL: jest.fn, [string]>().mockResolvedValue(), + webContents: { + openDevTools: jest.fn(), + }, + }; +} + +type AppInternals = { + mainWindow: MockMainWindow; + loadMainWindow: () => Promise; +}; + +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'], + }); + }); }); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index 884e8a54f..57c70cbad 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -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 = session.defaultSession +): Promise { + 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 { // 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()); } } diff --git a/apps/web/src/app/services/runtime-config.spec.ts b/apps/web/src/app/services/runtime-config.spec.ts index 3efffcf35..fc9c48cfe 100644 --- a/apps/web/src/app/services/runtime-config.spec.ts +++ b/apps/web/src/app/services/runtime-config.spec.ts @@ -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); + }); }); diff --git a/apps/web/src/app/services/runtime-config.ts b/apps/web/src/app/services/runtime-config.ts index 2a542a9b0..02b77c1f2 100644 --- a/apps/web/src/app/services/runtime-config.ts +++ b/apps/web/src/app/services/runtime-config.ts @@ -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:' + ); } diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 822d96f29..da799457d 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -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: