From 0a8464f63c291952257a8369181c17315742910f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Bregar?= Date: Sat, 11 Apr 2026 03:19:53 +0200 Subject: [PATCH] Add app version management, update checks, and periodic SW updates - Introduced version.json and appVersionAssetPlugin for build-time version tracking. - Enhanced settings page with update check/status UI. - Refactored bootstrap to handle SW updates and initiate periodic checks. --- README.md | 2 + package-lock.json | 4 +- package.json | 2 +- public/service-worker.js | 12 +- src/app/bootstrap.js | 212 +++++++++++++++++++++++++++-- src/features/auth/settings-page.js | 130 +++++++++++++++++- vite.config.js | 22 +++ 7 files changed, 366 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 437fb22..e4e326f 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ For installability and service worker support: - serve `manifest.webmanifest` with an appropriate web manifest content type - make sure `service-worker.js` is reachable from the deployed site root +- make sure `version.json` is reachable from the deployed site root for app update checks - avoid aggressive caching on `index.html` during upgrades so new builds are picked up reliably ### Smoke test after deployment @@ -113,6 +114,7 @@ public/ manifest.webmanifest offline.html service-worker.js + version.json src/ api/ app/ diff --git a/package-lock.json b/package-lock.json index 869d22d..b34b814 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lonc-web", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lonc-web", - "version": "0.1.3", + "version": "0.1.4", "dependencies": { "@zxing/browser": "^0.1.5", "alpinejs": "^3.14.9", diff --git a/package.json b/package.json index 5360215..d24f754 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lonc-web", - "version": "0.1.3", + "version": "0.1.4", "private": true, "type": "module", "scripts": { diff --git a/public/service-worker.js b/public/service-worker.js index a62cb76..86b120f 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -3,7 +3,6 @@ const APP_SHELL = ['/', '/index.html', '/manifest.webmanifest', '/offline.html', self.addEventListener('install', (event) => { event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))); - self.skipWaiting(); }); self.addEventListener('activate', (event) => { @@ -19,6 +18,12 @@ self.addEventListener('activate', (event) => { self.clients.claim(); }); +self.addEventListener('message', (event) => { + if (event?.data?.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); + self.addEventListener('fetch', (event) => { if (event.request.method !== 'GET') { return; @@ -40,6 +45,11 @@ self.addEventListener('fetch', (event) => { return; } + if (requestUrl.pathname === '/version.json') { + event.respondWith(fetch(event.request, { cache: 'no-store' })); + return; + } + const destination = event.request.destination; if ( destination === 'script' || diff --git a/src/app/bootstrap.js b/src/app/bootstrap.js index 1d95127..b42b59c 100644 --- a/src/app/bootstrap.js +++ b/src/app/bootstrap.js @@ -9,24 +9,201 @@ import { appShell } from '../components/app-shell.js'; import { navBar } from '../components/nav-bar.js'; import { registerFeatureData } from '../features/register.js'; -async function installServiceWorker() { - if (!('serviceWorker' in navigator)) { - return; +const APP_UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000; + +function createAppUpdateManager() { + let registration = null; + let waitingWorker = null; + + async function fetchServerVersion() { + try { + const response = await fetch(`/version.json?ts=${Date.now()}`, { + cache: 'no-store', + headers: { + 'cache-control': 'no-cache', + pragma: 'no-cache', + }, + }); + + if (!response.ok) { + throw new Error(`Version endpoint failed with HTTP ${response.status}`); + } + + const payload = await response.json(); + const serverVersion = String(payload?.version || '').trim(); + const serverBuildTime = String(payload?.buildTime || '').trim(); + + return { + serverVersion: serverVersion || null, + serverBuildTime: serverBuildTime || null, + }; + } catch (error) { + return { + serverVersion: null, + serverBuildTime: null, + error: error instanceof Error ? error.message : 'Unable to reach version endpoint.', + }; + } } - if (import.meta.env.DEV) { - const registrations = await navigator.serviceWorker.getRegistrations(); - await Promise.all(registrations.map((registration) => registration.unregister())); - return; + function syncWaitingWorker() { + waitingWorker = registration?.waiting || null; } - await navigator.serviceWorker.register('/service-worker.js'); + function setupRegistrationHooks() { + if (!registration) { + return; + } + + syncWaitingWorker(); + + registration.addEventListener('updatefound', () => { + const installing = registration.installing; + if (!installing) { + return; + } + + installing.addEventListener('statechange', () => { + if (installing.state === 'installed' && navigator.serviceWorker.controller) { + waitingWorker = registration.waiting || installing; + } + }); + }); + } + + async function installServiceWorker() { + if (!('serviceWorker' in navigator)) { + return { supported: false }; + } + + if (import.meta.env.DEV) { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((existingRegistration) => existingRegistration.unregister())); + return { supported: false, development: true }; + } + + registration = await navigator.serviceWorker.register('/service-worker.js'); + setupRegistrationHooks(); + + return { + supported: true, + registered: true, + }; + } + + async function checkForAppUpdate() { + if (registration) { + await registration.update().catch(() => {}); + syncWaitingWorker(); + } + + const server = await fetchServerVersion(); + const hasVersionMismatch = Boolean(server.serverVersion && server.serverVersion !== APP_VERSION); + + return { + supported: 'serviceWorker' in navigator, + currentVersion: APP_VERSION, + serverVersion: server.serverVersion, + serverBuildTime: server.serverBuildTime, + waitingWorker: Boolean(waitingWorker), + updateAvailable: Boolean(waitingWorker) || hasVersionMismatch, + hasVersionMismatch, + serverError: server.error || null, + }; + } + + async function waitForControllerChange(previousController, timeoutMs = 4000) { + if (!('serviceWorker' in navigator)) { + return; + } + + if (navigator.serviceWorker.controller && navigator.serviceWorker.controller !== previousController) { + return; + } + + await new Promise((resolve) => { + let isDone = false; + + const finish = () => { + if (isDone) { + return; + } + + isDone = true; + navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange); + clearTimeout(timeout); + resolve(); + }; + + const onControllerChange = () => { + finish(); + }; + + const timeout = setTimeout(finish, timeoutMs); + + navigator.serviceWorker.addEventListener('controllerchange', onControllerChange); + }); + } + + async function clearAllServiceWorkerCaches() { + if (!('caches' in window)) { + return; + } + + const keys = await caches.keys(); + await Promise.all(keys.map((key) => caches.delete(key))); + } + + async function applyAppUpdate() { + const previousController = navigator.serviceWorker?.controller || null; + + if (registration) { + await registration.update().catch(() => {}); + syncWaitingWorker(); + } + + if (waitingWorker) { + waitingWorker.postMessage({ type: 'SKIP_WAITING' }); + await waitForControllerChange(previousController); + } + + await clearAllServiceWorkerCaches().catch(() => {}); + + const nextUrl = new URL(window.location.href); + nextUrl.searchParams.set('app_update', String(Date.now())); + window.location.replace(nextUrl.toString()); + } + + function startPeriodicChecks() { + if (!registration) { + return; + } + + window.setInterval(() => { + checkForAppUpdate().catch(() => {}); + }, APP_UPDATE_CHECK_INTERVAL_MS); + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + checkForAppUpdate().catch(() => {}); + } + }); + } + + return { + installServiceWorker, + checkForAppUpdate, + applyAppUpdate, + startPeriodicChecks, + }; } export function bootstrapApp() { const store = createAppStore(); Alpine.store('app', store); + const appUpdateManager = createAppUpdateManager(); + registerFeatureData(Alpine, store); const appRoot = document.querySelector('#app'); @@ -104,6 +281,12 @@ export function bootstrapApp() { renderNav(); return result; }, + async checkForAppUpdate() { + return appUpdateManager.checkForAppUpdate(); + }, + async applyAppUpdate() { + return appUpdateManager.applyAppUpdate(); + }, handleAuthFailure(error) { if (!store.session?.applicationKey || !store.session?.hasValidated || authFailureHandled) { return; @@ -152,10 +335,13 @@ export function bootstrapApp() { renderNav(); - installServiceWorker().catch(() => { - store.addAlert({ - type: 'warning', - message: 'PWA installation support could not be initialized.', + appUpdateManager + .installServiceWorker() + .then(() => appUpdateManager.startPeriodicChecks()) + .catch(() => { + store.addAlert({ + type: 'warning', + message: 'PWA installation support could not be initialized.', + }); }); - }); } diff --git a/src/features/auth/settings-page.js b/src/features/auth/settings-page.js index 00664c8..49ef926 100644 --- a/src/features/auth/settings-page.js +++ b/src/features/auth/settings-page.js @@ -1,10 +1,12 @@ +import { APP_VERSION } from '../../app/config.js'; + export function renderSettingsPage() { return `
-
+

Client Settings

@@ -40,6 +42,45 @@ export function renderSettingsPage() {
+ +
+ +
+
+

App update

+

+ Check for the latest deployed build and force-refresh this installed app when needed. +

+
+
+
+
+
Current version
+
+
+
+
+
+
Server version
+
+ +
+
+
+
+ + +
+
+
@@ -68,6 +109,15 @@ export function settingsPageData(store) { baseUrl: store.config.baseUrl || '', database: store.config.database || '', }, + update: { + currentVersion: APP_VERSION, + serverVersion: null, + serverBuildTime: null, + statusText: 'Ready to check for updates.', + statusType: 'secondary', + isChecking: false, + isApplying: false, + }, get userLogin() { return store.session?.userLogin || ''; }, @@ -83,5 +133,83 @@ export function settingsPageData(store) { store.addAlert({ type: 'success', message: 'Settings saved locally.' }); }, + formatBuildTime(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleString(); + }, + updateStatusClass() { + switch (this.update.statusType) { + case 'success': + return 'text-success'; + case 'warning': + return 'text-warning'; + case 'danger': + return 'text-danger'; + default: + return 'text-body-secondary'; + } + }, + async initUpdatePanel() { + await this.checkForUpdates(); + }, + async checkForUpdates() { + if (!window.__loncApp?.checkForAppUpdate) { + this.update.statusText = 'Service worker updates are not available in this browser.'; + this.update.statusType = 'warning'; + return; + } + + this.update.isChecking = true; + this.update.statusText = 'Checking for updates...'; + this.update.statusType = 'secondary'; + + try { + const result = await window.__loncApp.checkForAppUpdate(); + this.update.currentVersion = result.currentVersion || APP_VERSION; + this.update.serverVersion = result.serverVersion || null; + this.update.serverBuildTime = result.serverBuildTime || null; + + if (result.updateAvailable) { + this.update.statusText = 'Update available. Use "Update app" to refresh this installed build.'; + this.update.statusType = 'warning'; + } else if (result.serverError) { + this.update.statusText = `No update signal from server (${result.serverError}).`; + this.update.statusType = 'secondary'; + } else { + this.update.statusText = 'This app is up to date.'; + this.update.statusType = 'success'; + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown update check error.'; + this.update.statusText = `Update check failed: ${message}`; + this.update.statusType = 'danger'; + } finally { + this.update.isChecking = false; + } + }, + async applyUpdate() { + if (!window.__loncApp?.applyAppUpdate) { + this.update.statusText = 'Update action is not available in this browser.'; + this.update.statusType = 'warning'; + return; + } + + this.update.isApplying = true; + this.update.statusText = 'Applying update and refreshing app...'; + this.update.statusType = 'warning'; + + try { + await window.__loncApp.applyAppUpdate(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown update error.'; + this.update.statusText = `Update failed: ${message}`; + this.update.statusType = 'danger'; + this.update.isApplying = false; + } + }, }; } diff --git a/vite.config.js b/vite.config.js index 19cd7b0..cfa322e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,10 +1,32 @@ import { defineConfig } from 'vite'; import packageJson from './package.json'; +function appVersionAssetPlugin() { + return { + name: 'app-version-asset', + apply: 'build', + generateBundle() { + this.emitFile({ + type: 'asset', + fileName: 'version.json', + source: JSON.stringify( + { + version: packageJson.version, + buildTime: new Date().toISOString(), + }, + null, + 2, + ), + }); + }, + }; +} + export default defineConfig({ define: { __APP_VERSION__: JSON.stringify(packageJson.version), }, + plugins: [appVersionAssetPlugin()], server: { port: 4173, },