From 2c1b6e5fbe924da8168e5d46a820fb6e9baf01d2 Mon Sep 17 00:00:00 2001 From: benjamin Date: Sun, 25 Oct 2026 11:47:21 +0100 Subject: [PATCH] fix service worker --- angular.json | 1 + firebase.json | 13 +--- src/app/app.component.ts | 2 + src/app/services/app-update.service.spec.ts | 66 ++++++++++++++++++ src/app/services/app-update.service.ts | 74 +++++++++++++++++++++ src/styles/styles.less | 22 ++++++ 6 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 src/app/services/app-update.service.spec.ts create mode 100644 src/app/services/app-update.service.ts diff --git a/angular.json b/angular.json index 63e0738..0115ee6 100644 --- a/angular.json +++ b/angular.json @@ -59,6 +59,7 @@ }, "configurations": { "production": { + "serviceWorker": "ngsw-config.json", "fileReplacements": [ { "replace": "src/environments/environment.ts", diff --git a/firebase.json b/firebase.json index b4a76d4..9460182 100644 --- a/firebase.json +++ b/firebase.json @@ -18,20 +18,11 @@ ], "headers": [ { - "source": "/**/!(*.*)", + "source": "**", "headers": [ { "key": "Cache-Control", - "value": "max-age=1" - } - ] - }, - { - "source": "**/*.@(js|css)", - "headers": [ - { - "key": "Cache-Control", - "value": "max-age=846000" + "value": "no-cache" } ] } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 1057368..63efe7c 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -3,6 +3,7 @@ import {fader} from './animations'; import {RouterOutlet} from '@angular/router'; import {NavigationComponent} from './widget-modules/components/application-frame/navigation/navigation.component'; import {ThemeService} from './services/theme/theme.service'; +import {AppUpdateService} from './services/app-update.service'; @Component({ selector: 'app-root', @@ -15,6 +16,7 @@ import {ThemeService} from './services/theme/theme.service'; export class AppComponent implements OnInit { public constructor() { inject(ThemeService); + inject(AppUpdateService); } public ngOnInit(): void { diff --git a/src/app/services/app-update.service.spec.ts b/src/app/services/app-update.service.spec.ts new file mode 100644 index 0000000..08b0829 --- /dev/null +++ b/src/app/services/app-update.service.spec.ts @@ -0,0 +1,66 @@ +import {ApplicationRef} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {MatSnackBar} from '@angular/material/snack-bar'; +import {SwUpdate, VersionEvent} from '@angular/service-worker'; +import {Subject} from 'rxjs'; +import {vi} from 'vitest'; +import {AppUpdateService} from './app-update.service'; + +describe('AppUpdateService', () => { + let stable: Subject; + let versions: Subject; + let updates: {isEnabled: boolean; versionUpdates: Subject; unrecoverable: Subject; checkForUpdate: ReturnType}; + let snackBar: {open: ReturnType}; + + beforeEach(() => { + stable = new Subject(); + versions = new Subject(); + updates = {isEnabled: true, versionUpdates: versions, unrecoverable: new Subject(), checkForUpdate: vi.fn().mockResolvedValue(false)}; + snackBar = {open: vi.fn().mockReturnValue({onAction: () => new Subject()})}; + TestBed.configureTestingModule({ + providers: [ + {provide: ApplicationRef, useValue: {isStable: stable}}, + {provide: SwUpdate, useValue: updates}, + {provide: MatSnackBar, useValue: snackBar}, + ], + }); + }); + + it('waits for stability and checks again when connectivity returns', async () => { + TestBed.inject(AppUpdateService); + stable.next(false); + expect(updates.checkForUpdate).not.toHaveBeenCalled(); + stable.next(true); + expect(updates.checkForUpdate).toHaveBeenCalledTimes(1); + await Promise.resolve(); + window.dispatchEvent(new Event('online')); + expect(updates.checkForUpdate).toHaveBeenCalledTimes(2); + TestBed.resetTestingModule(); + window.dispatchEvent(new Event('online')); + expect(updates.checkForUpdate).toHaveBeenCalledTimes(2); + }); + + it('does not check when the service worker is disabled', () => { + updates.isEnabled = false; + TestBed.inject(AppUpdateService); + stable.next(true); + window.dispatchEvent(new Event('online')); + expect(updates.checkForUpdate).not.toHaveBeenCalled(); + }); + + it('offers a persistent reload action only after the update is ready', () => { + TestBed.inject(AppUpdateService); + versions.next({type: 'VERSION_DETECTED', version: {hash: 'new'}}); + expect(snackBar.open).not.toHaveBeenCalled(); + const ready: VersionEvent = {type: 'VERSION_READY', currentVersion: {hash: 'old'}, latestVersion: {hash: 'new'}}; + versions.next(ready); + versions.next(ready); + expect(snackBar.open).toHaveBeenCalledExactlyOnceWith('Eine neue Version ist verfügbar.', 'Jetzt aktualisieren', {duration: 0}); + }); + + it('offers recovery when cached application files cannot be recovered', () => { + TestBed.inject(AppUpdateService); + updates.unrecoverable.next({reason: 'Missing chunk'}); + expect(snackBar.open).toHaveBeenCalledWith('Die Anwendung muss neu geladen werden.', 'Jetzt aktualisieren', {duration: 0}); + }); +}); diff --git a/src/app/services/app-update.service.ts b/src/app/services/app-update.service.ts new file mode 100644 index 0000000..7d7f078 --- /dev/null +++ b/src/app/services/app-update.service.ts @@ -0,0 +1,74 @@ +import {ApplicationRef, DestroyRef, Injectable, NgZone, inject} from '@angular/core'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {MatSnackBar} from '@angular/material/snack-bar'; +import {SwUpdate} from '@angular/service-worker'; +import {concat, filter, first, fromEvent, interval, merge} from 'rxjs'; + +@Injectable({providedIn: 'root'}) +export class AppUpdateService { + private readonly updates = inject(SwUpdate, {optional: true}); + private readonly snackBar = inject(MatSnackBar); + private readonly zone = inject(NgZone); + private readonly destroyRef = inject(DestroyRef); + private checking = false; + private prompted = false; + + constructor() { + if (!this.updates?.isEnabled) { + return; + } + + this.updates.versionUpdates.pipe(takeUntilDestroyed()).subscribe(event => { + if (event.type === 'VERSION_READY') { + this.promptReload('Eine neue Version ist verfügbar.'); + } + }); + this.updates.unrecoverable.pipe(takeUntilDestroyed()).subscribe(() => { + this.promptReload('Die Anwendung muss neu geladen werden.'); + }); + + const appRef = inject(ApplicationRef); + // Poll only after stabilization so service worker registration is not delayed. + this.zone.runOutsideAngular(() => { + concat( + appRef.isStable.pipe(first(stable => stable)), + merge( + interval(15 * 60 * 1000), + fromEvent(window, 'online'), + fromEvent(document, 'visibilitychange').pipe(filter(() => document.visibilityState === 'visible')) + ) + ) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => void this.checkForUpdate()); + }); + } + + private async checkForUpdate(): Promise { + if (this.checking) { + return; + } + this.checking = true; + try { + await this.updates?.checkForUpdate(); + } catch (error) { + // Offline clients retry when connectivity returns or on the next interval. + console.warn('Aktualisierung konnte nicht geprüft werden.', error); + } finally { + this.checking = false; + } + } + + private promptReload(message: string): void { + if (this.prompted) { + return; + } + this.prompted = true; + this.zone.run(() => { + this.snackBar + .open(message, 'Jetzt aktualisieren', {duration: 0}) + .onAction() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => window.location.reload()); + }); + } +} diff --git a/src/styles/styles.less b/src/styles/styles.less index b008ffe..9bedd04 100644 --- a/src/styles/styles.less +++ b/src/styles/styles.less @@ -160,6 +160,28 @@ body.theme-dark .mat-mdc-text-field-wrapper { background-color: rgba(36, 54, 72, 0.96); } +body.theme-dark .mat-button-toggle:not(.mat-button-toggle-checked) { + --mat-standard-button-toggle-background-color: var(--surface-strong); + --mat-standard-button-toggle-text-color: var(--text); + --mat-standard-button-toggle-state-layer-color: var(--primary-hover); + --mat-standard-button-toggle-disabled-state-background-color: var(--surface-muted); + --mat-standard-button-toggle-disabled-state-text-color: var(--text-soft); + background-color: var(--surface-strong); + color: var(--text); +} + +body.theme-dark .mat-button-toggle:not(.mat-button-toggle-checked) .mat-button-toggle-button { + background-color: var(--surface-strong); +} + +body.theme-dark .mat-button-toggle:not(.mat-button-toggle-checked) .mat-button-toggle-label-content { + color: var(--text); +} + +body.theme-dark .mat-button-toggle:not(.mat-button-toggle-checked):hover { + background-color: var(--surface); +} + body.theme-dark .mdc-notched-outline__leading, body.theme-dark .mdc-notched-outline__notch, body.theme-dark .mdc-notched-outline__trailing {