Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea8a95b95d | |||
| 0a8464f63c |
@@ -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/
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lonc-web",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
Vendored
+190
-4
@@ -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.',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user