Merge pull request 'Add app version management, update checks, and periodic SW updates' (#4) from codex/add-pwa-update-controls into main
ci/woodpecker/push/woodpecker Pipeline was successful

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-04-11 01:21:19 +00:00
7 changed files with 366 additions and 18 deletions
+2
View File
@@ -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/
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "lonc-web",
"version": "0.1.3",
"version": "0.1.4",
"private": true,
"type": "module",
"scripts": {
+11 -1
View File
@@ -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' ||
+190 -4
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';
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.',
};
}
}
function syncWaitingWorker() {
waitingWorker = registration?.waiting || null;
}
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;
return { supported: false };
}
if (import.meta.env.DEV) {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((registration) => registration.unregister()));
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;
}
await navigator.serviceWorker.register('/service-worker.js');
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,7 +335,10 @@ export function bootstrapApp() {
renderNav();
installServiceWorker().catch(() => {
appUpdateManager
.installServiceWorker()
.then(() => appUpdateManager.startPeriodicChecks())
.catch(() => {
store.addAlert({
type: 'warning',
message: 'PWA installation support could not be initialized.',
+129 -1
View File
@@ -1,10 +1,12 @@
import { APP_VERSION } from '../../app/config.js';
export function renderSettingsPage() {
return `
<section class="container-xxl py-4 py-lg-5">
<div class="row g-4">
<div class="col-12 col-lg-7">
<div class="card border-0 shadow-sm">
<div class="card-body p-4" x-data="settingsPage()">
<div class="card-body p-4" x-data="settingsPage()" x-init="initUpdatePanel()">
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<p class="eyebrow mb-2">Client Settings</p>
@@ -40,6 +42,45 @@ export function renderSettingsPage() {
</div>
<button class="btn btn-primary align-self-start" type="submit">Save settings</button>
</form>
<hr class="my-4" />
<div class="vstack gap-3">
<div>
<h2 class="h5 mb-1">App update</h2>
<p class="text-body-secondary mb-0">
Check for the latest deployed build and force-refresh this installed app when needed.
</p>
</div>
<div class="row g-3 small">
<div class="col-12 col-md-6">
<div class="border rounded-3 p-3 h-100 bg-body-tertiary">
<div class="text-uppercase text-body-secondary fw-semibold mb-1">Current version</div>
<div class="fw-semibold" x-text="update.currentVersion"></div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="border rounded-3 p-3 h-100 bg-body-tertiary">
<div class="text-uppercase text-body-secondary fw-semibold mb-1">Server version</div>
<div class="fw-semibold" x-text="update.serverVersion || 'Unavailable'"></div>
<template x-if="update.serverBuildTime">
<div class="text-body-secondary mt-1" x-text="'Built: ' + formatBuildTime(update.serverBuildTime)"></div>
</template>
</div>
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-2">
<button class="btn btn-outline-secondary" type="button" @click="checkForUpdates()" :disabled="update.isChecking || update.isApplying">
<span x-show="!update.isChecking">Check for updates</span>
<span x-show="update.isChecking">Checking...</span>
</button>
<button class="btn btn-primary" type="button" @click="applyUpdate()" :disabled="update.isApplying">
<span x-show="!update.isApplying">Update app</span>
<span x-show="update.isApplying">Updating...</span>
</button>
</div>
<div class="small" :class="updateStatusClass()" x-text="update.statusText"></div>
</div>
</div>
</div>
</div>
@@ -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;
}
},
};
}
+22
View File
@@ -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,
},