diff --git a/src/automats/index.js b/src/automats/index.js index 47b4e1c..3db7c92 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -35,6 +35,14 @@ export { selectActiveHelpId, } from './helpAutomat'; +export { + default as pwaAutomat, + initPwa, + openPwaDialog, + closePwaDialog, + applyPwaUpdate, +} from './pwaAutomat'; + export { default as supplierAutomat } from './supplierAutomat'; export { default as warehouseAutomat } from './warehouseAutomat'; diff --git a/src/automats/pwaAutomat.js b/src/automats/pwaAutomat.js new file mode 100644 index 0000000..0b39091 --- /dev/null +++ b/src/automats/pwaAutomat.js @@ -0,0 +1,125 @@ +/** + * @file pwaAutomat.js + * @description Manages PWA update lifecycle, service worker registration, + * and user prompt dialog state. + */ + +import { Automat } from 'react-automat'; + +/** + * @typedef {Object} PwaState + * @property {boolean} needRefresh Whether a new SW update is available and waiting + * @property {boolean} dialogOpen Whether the update dialog is currently visible + * @property {boolean} offlineReady Whether content is cached for offline use + * @property {boolean} updating Whether an update is currently being applied + */ + +/** @type {PwaState} */ +const initialState = { + needRefresh: false, + dialogOpen: false, + offlineReady: false, + updating: false, +}; + +let swUpdater = null; + +/** + * State store for PWA lifecycle & update notifications. + * Registered on window.__AUTOMATS__ as 'pwa'. + * + * @type {Automat} + */ +const pwaAutomat = new Automat( + initialState, + { + setRegistration(updater) { + swUpdater = updater; + }, + setNeedRefresh() { + pwaAutomat.setState({ + needRefresh: true, + dialogOpen: true, + }); + }, + setOfflineReady() { + pwaAutomat.setState({ + offlineReady: true, + }); + }, + openDialog() { + pwaAutomat.setState({ dialogOpen: true }); + }, + closeDialog() { + pwaAutomat.setState({ dialogOpen: false }); + }, + async applyUpdate() { + pwaAutomat.setState({ updating: true }); + if (typeof swUpdater === 'function') { + try { + await swUpdater(true); + } catch (err) { + console.error('[PWA] Error activating update:', err); + window.location.reload(); + } + } else { + window.location.reload(); + } + }, + // Allows testing the dialog in dev mode or in browser console: + // window.__AUTOMATS__.pwa.actions.simulateUpdateAvailable() + simulateUpdateAvailable() { + pwaAutomat.setState({ + needRefresh: true, + dialogOpen: true, + }); + }, + }, + { name: 'pwa' }, +); + +/** + * Initializes PWA service worker registration in production. + */ +export function initPwa() { + if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return; + + import('virtual:pwa-register') + .then(({ registerSW }) => { + const updateSW = registerSW({ + immediate: true, + onNeedRefresh() { + pwaAutomat.actions.setNeedRefresh(); + }, + onOfflineReady() { + pwaAutomat.actions.setOfflineReady(); + }, + onRegistered(registration) { + if (!registration) return; + + // Check for new updates periodically (every 30 minutes) + setInterval(() => { + registration.update().catch(() => {}); + }, 30 * 60 * 1000); + + // Check for updates when tab or PWA returns to foreground + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + registration.update().catch(() => {}); + } + }); + }, + }); + + pwaAutomat.actions.setRegistration(updateSW); + }) + .catch((err) => { + console.warn('[PWA] Service worker registration failed or omitted:', err); + }); +} + +export const openPwaDialog = () => pwaAutomat.actions.openDialog(); +export const closePwaDialog = () => pwaAutomat.actions.closeDialog(); +export const applyPwaUpdate = () => pwaAutomat.actions.applyUpdate(); + +export default pwaAutomat; diff --git a/src/components/PwaUpdateDialog.jsx b/src/components/PwaUpdateDialog.jsx new file mode 100644 index 0000000..5a89ecd --- /dev/null +++ b/src/components/PwaUpdateDialog.jsx @@ -0,0 +1,114 @@ +import { PureComponent } from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogActions from '@mui/material/DialogActions'; +import CircularProgress from '@mui/material/CircularProgress'; +import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt'; +import AppDialog from './AppDialog'; +import pwaAutomat from '../automats/pwaAutomat'; + +/** + * PWA Update prompt dialog. + * Appears when a new service worker update has been downloaded in the background. + * Allows the user to reload the page to apply the update immediately, + * or postpone it ("Später") without losing ongoing form input. + */ +export default class PwaUpdateDialog extends PureComponent { + constructor(props) { + super(props); + this.state = { + dialogOpen: pwaAutomat.state.dialogOpen, + updating: pwaAutomat.state.updating, + }; + this.handleUpdate = this.handleUpdate.bind(this); + this.handleClose = this.handleClose.bind(this); + } + + componentDidMount() { + this.unsub = pwaAutomat.subscribe(this, ['dialogOpen', 'updating']); + } + + componentWillUnmount() { + this.unsub?.(); + } + + handleClose() { + if (this.state.updating) return; + pwaAutomat.actions.closeDialog(); + } + + handleUpdate() { + pwaAutomat.actions.applyUpdate(); + } + + render() { + const { dialogOpen, updating } = this.state; + + return ( + + + + + + Aktualisierung verfügbar + + + + + Eine neue Version von Dashfront ist bereit. + + + Möchten Sie die Anwendung jetzt neu laden, um die Änderungen zu übernehmen? + Stellen Sie sicher, dass wichtige Eingaben gespeichert sind. + + + + + + + + + ); + } +} diff --git a/src/components/UserMenu.jsx b/src/components/UserMenu.jsx index 04abc23..a4b5a74 100644 --- a/src/components/UserMenu.jsx +++ b/src/components/UserMenu.jsx @@ -1,5 +1,6 @@ import { PureComponent } from 'react'; import Avatar from '@mui/material/Avatar'; +import Badge from '@mui/material/Badge'; import Box from '@mui/material/Box'; import IconButton from '@mui/material/IconButton'; import Menu from '@mui/material/Menu'; @@ -10,10 +11,11 @@ import LogoutIcon from '@mui/icons-material/Logout'; import BusinessIcon from '@mui/icons-material/Business'; import SettingsIcon from '@mui/icons-material/Settings'; import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded'; +import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt'; import Switch from '@mui/material/Switch'; import Divider from '@mui/material/Divider'; import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat'; -import { helpAutomat, toggleHelp } from '../automats'; +import { helpAutomat, toggleHelp, pwaAutomat, openPwaDialog } from '../automats'; import CompanySettingsDialog from './CompanySettingsDialog'; import SettingsDialog from './settings/SettingsDialog'; import { notifyError } from '../utils/notify'; @@ -27,6 +29,7 @@ class UserMenu extends PureComponent { numbersOpen: false, user: authAutomat.state.user, helpEnabled: helpAutomat.state?.enabled ?? false, + pwaUpdateAvailable: pwaAutomat.state.needRefresh, }; this.handleOpenMenu = this.handleOpenMenu.bind(this); this.handleCloseMenu = this.handleCloseMenu.bind(this); @@ -35,6 +38,7 @@ class UserMenu extends PureComponent { this.handleOpenNumbers = this.handleOpenNumbers.bind(this); this.handleCloseNumbers = this.handleCloseNumbers.bind(this); this.handleToggleHelp = this.handleToggleHelp.bind(this); + this.handleOpenPwaUpdate = this.handleOpenPwaUpdate.bind(this); this.handleLogout = this.handleLogout.bind(this); } @@ -43,11 +47,20 @@ class UserMenu extends PureComponent { this.unsubscribeHelp = helpAutomat.subscribe(this, (state) => ({ helpEnabled: Boolean(state?.enabled), })); + this.unsubscribePwa = pwaAutomat.subscribe(this, (state) => ({ + pwaUpdateAvailable: Boolean(state?.needRefresh), + })); } componentWillUnmount() { this.unsubscribeAuth?.(); this.unsubscribeHelp?.(); + this.unsubscribePwa?.(); + } + + handleOpenPwaUpdate() { + this.setState({ anchorEl: null }); + openPwaDialog(); } handleOpenMenu(event) { @@ -91,7 +104,7 @@ class UserMenu extends PureComponent { } render() { - const { user, anchorEl, settingsOpen, numbersOpen, helpEnabled } = this.state; + const { user, anchorEl, settingsOpen, numbersOpen, helpEnabled, pwaUpdateAvailable } = this.state; const open = Boolean(anchorEl); const pictureSrc = (() => { @@ -112,9 +125,17 @@ class UserMenu extends PureComponent { onMouseDown={(e) => e.preventDefault()} sx={{ p: 0, width: 32, height: 32, borderRadius: '50%' }} > - - {user?.email?.[0]?.toUpperCase()} - + + + {user?.email?.[0]?.toUpperCase()} + + + {pwaUpdateAvailable && ( + + + + + + + )} {user?.email} diff --git a/src/main.jsx b/src/main.jsx index 7ad3bc9..a8d4a94 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -11,6 +11,8 @@ import { installCssVars } from './colors'; // Side-effect imports: each module registers its automat and subscribes to // authAutomat, so the stores must be evaluated before the session bootstraps. import './automats'; +import { initPwa } from './automats'; +import PwaUpdateDialog from './components/PwaUpdateDialog'; import './index.css'; // Publish the color tokens as CSS custom properties so index.css (and any @@ -37,18 +39,8 @@ if (import.meta.env.DEV) { void clearDevServiceWorker(); } -// PWA auto-update: reload the page as soon as a new build's service worker -// takes control. The virtual module only exists in production builds (the -// plugin is registered for `build` only), hence the dynamic import. -if (import.meta.env.PROD && 'serviceWorker' in navigator) { - import('virtual:pwa-register') - .then(({ registerSW }) => { - registerSW({ immediate: true }); - }) - .catch(() => { - // No SW in this build — app still works, just without offline support. - }); -} +// Initialize PWA service worker registration and update listener in production +initPwa(); createRoot(document.getElementById('root')).render( @@ -71,6 +63,7 @@ createRoot(document.getElementById('root')).render( ) : null} + , diff --git a/vite.config.js b/vite.config.js index 4dbac88..f4627d4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -289,12 +289,9 @@ export default defineConfig(({ command }) => ({ ...(command === 'build' ? [ VitePWA({ - // autoUpdate: the SW calls clientsClaim()/skipWaiting() itself, so a - // new build takes over every open tab on its own. - registerType: 'autoUpdate', - // Registration is done explicitly in src/main.jsx via - // `virtual:pwa-register` with `immediate: true`; that is what makes - // the *page* reload (a bare injected script only swaps the SW). + // prompt: new service worker waits in the background until the user + // confirms update via the PWA update dialog. + registerType: 'prompt', injectRegister: false, manifest: { id: '/', @@ -325,12 +322,6 @@ export default defineConfig(({ command }) => ({ ], }, workbox: { - // NOTE: vite-plugin-pwa only auto-sets these two when - // injectRegister === 'auto'. Because we register manually in - // src/main.jsx (injectRegister: false), they must be explicit — - // otherwise a new SW waits forever behind the old one and no tab - // is ever claimed. - skipWaiting: true, clientsClaim: true, globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'], globIgnores: ['**/*.wasm'],