Add app version management, update checks, and periodic SW updates
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful

- 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.
This commit is contained in:
2026-04-11 03:19:53 +02:00
parent 9e6ad2dc08
commit 0a8464f63c
7 changed files with 366 additions and 18 deletions
+199 -13
View File
@@ -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.',
});
});
});
}