pwaLUPMIS2/src/pwa.js
ekke 4809ef324a Three offline and toolbar fixes, and no service worker in development
The drawing rail's option bar covered the Transform button. ol-ext positions
the flyout with `left: 100%`, which is meant to clear the whole rail; the rule
that makes that work sets the rail's controls to `position: static`, and it
only tied with ol-ext's own `.ol-control.ol-bar .ol-control` on specificity, so
stylesheet order decided it and ol-ext's injected sheet came last. The flyout
was therefore measured against the Select button and landed on the second
column. The selector now carries enough classes to win outright. This is the
same failure as v16, in a second place.

The local database did not open when the device was offline. The fetch handler
skipped every request whose destination was 'worker', to stay clear of the Vite
dev server's module plumbing — but in a build SQLocal's worker is a hashed file
under /assets/, so it was never cached either. With the network down the
request failed, initSchema() never resolved, and everything waiting on the
database stopped without an error. The shell, the map and the tiles all worked,
so the application looked like it was surviving offline while nothing could be
saved or read. The skip now covers only the dev server's own paths; a built
worker is cached like any other asset. Verified both ways against a production
build with the preview server stopped: before, the schema hangs and the table
list is empty; after, the schema completes and all twelve tables are listed.

The service worker is no longer registered on the development server. It cannot
do its job there anyway — the modules it would need come from node_modules,
which it deliberately keeps clear of — while it does cache main.js and src/*.js
cache-first, so it can serve a stale module and fight hot reloading. In Safari
it also costs the local database outright: a worker script that reaches the
browser through a controlling service worker loses its cross-origin-isolation
headers, and SQLocal's worker is refused. Because switching registration off
does nothing for a worker already installed, initPWA now unregisters whatever
it finds and clears the app caches when registration is off. That clearing runs
on every load rather than only on the one that unregistered: an unregistered
worker goes on controlling its page until the next navigation and refills the
caches behind us. The tile caches are left alone — they hold what an officer
downloaded for field work.

CACHE_VERSION goes to v27 for the first two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 11:05:11 +02:00

549 lines
17 KiB
JavaScript

/**
* PWA Module
*
* Handles Progressive Web App functionality:
* - Service Worker registration
* - Install prompt handling
* - Offline detection
* - Update notifications
*
* Note: The Service Worker (sw.js) handles caching.
* The SharedWorker (shared-db-worker.js) handles database.
* They are separate workers with different purposes.
*/
// ============================================================================
// Service Worker Registration
// ============================================================================
let swRegistration = null;
export async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) {
console.warn('[PWA] Service Workers not supported');
return null;
}
try {
swRegistration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('[PWA] Service Worker registered:', swRegistration.scope);
// Handle updates
swRegistration.addEventListener('updatefound', () => {
const newWorker = swRegistration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available
console.log('[PWA] New version available');
showUpdateNotification();
}
});
});
return swRegistration;
} catch (error) {
console.error('[PWA] Service Worker registration failed:', error);
return null;
}
}
/**
* Remove any service worker registered against this origin, and the caches it
* filled. Used on the development server, where the service worker is not
* registered at all (see initPWA) — without this, a worker installed by an
* earlier session would go on controlling the page for as long as it exists.
*
* It is not only a tidiness problem. On the dev server the worker caches
* `/main.js` and `/src/*.js` cache-first, so it can hand back a stale module
* and fight hot reloading; and in Safari a worker script that reaches the
* browser through a controlling service worker loses its cross-origin-isolation
* headers, so SQLocal's worker is refused and the local database never opens.
*
* @returns {Promise<boolean>} true if anything was removed
*/
export async function unregisterServiceWorkers() {
if (!('serviceWorker' in navigator)) return false;
let removed = false;
try {
for (const registration of await navigator.serviceWorker.getRegistrations()) {
if (await registration.unregister()) removed = true;
}
// Clear the app caches on every load, not only on the load that did the
// unregistering. An unregistered worker goes on controlling the page it is
// already attached to until the next navigation, so it refills those caches
// behind us during that one load; deleting them unconditionally is what
// makes this converge. Nothing in a development build reads them anyway.
//
// The tile caches are left alone: they hold what an officer downloaded for
// field work, they are expensive to refill, and they are equally valid
// with no service worker in sight.
if ('caches' in window) {
const names = await caches.keys();
await Promise.all(
names.filter((name) => !name.startsWith('tiles-')).map((name) => caches.delete(name)),
);
}
if (removed) {
console.log('[PWA] Service worker unregistered (development build) — reload once more to drop it completely');
}
} catch (error) {
console.warn('[PWA] Could not unregister the service worker:', error);
}
return removed;
}
// ============================================================================
// Install Prompt
// ============================================================================
let deferredPrompt = null;
let installButton = null;
/**
* Initialize install prompt handling
* @param {string|HTMLElement} buttonSelector - Button element or selector
*/
export function initInstallPrompt(buttonSelector = '#install-btn') {
installButton = typeof buttonSelector === 'string'
? document.querySelector(buttonSelector)
: buttonSelector;
if (!installButton) {
console.warn('[PWA] Install button not found:', buttonSelector);
return;
}
// Initially hide the button
installButton.style.display = 'none';
// Listen for the beforeinstallprompt event
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
// Show the install button
installButton.style.display = 'block';
console.log('[PWA] Install prompt ready');
});
// Handle install button click
installButton.addEventListener('click', async () => {
if (!deferredPrompt) {
// Show manual instructions for Safari
showManualInstallInstructions();
return;
}
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log('[PWA] Install prompt outcome:', outcome);
deferredPrompt = null;
installButton.style.display = 'none';
});
// Hide button if app is already installed
window.addEventListener('appinstalled', () => {
console.log('[PWA] App installed');
deferredPrompt = null;
installButton.style.display = 'none';
});
// Check if running as installed PWA
if (window.matchMedia('(display-mode: standalone)').matches) {
installButton.style.display = 'none';
}
}
function showManualInstallInstructions() {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
let message = 'To install this app:\n\n';
if (isIOS) {
message += '1. Tap the Share button (square with arrow)\n';
message += '2. Scroll down and tap "Add to Home Screen"';
} else if (isSafari) {
message += '1. Click File menu\n';
message += '2. Click "Add to Dock"';
} else {
message += '1. Click the menu button (three dots)\n';
message += '2. Click "Install" or "Add to Home Screen"';
}
alert(message);
}
// ============================================================================
// Offline Detection
// ============================================================================
let offlineIndicator = null;
const offlineListeners = new Set();
/**
* Initialize offline detection
* @param {string|HTMLElement} indicatorSelector - Element to show when offline
*/
export function initOfflineDetection(indicatorSelector = '#offline-indicator') {
offlineIndicator = typeof indicatorSelector === 'string'
? document.querySelector(indicatorSelector)
: indicatorSelector;
// Set initial state
updateOfflineUI(!navigator.onLine);
// Listen for online/offline events
window.addEventListener('online', () => {
console.log('[PWA] Back online');
updateOfflineUI(false);
notifyOfflineListeners(false);
});
window.addEventListener('offline', () => {
console.log('[PWA] Gone offline');
updateOfflineUI(true);
notifyOfflineListeners(true);
});
}
function updateOfflineUI(isOffline) {
if (offlineIndicator) {
offlineIndicator.style.display = isOffline ? 'block' : 'none';
}
// Also toggle a class on body for CSS styling
document.body.classList.toggle('is-offline', isOffline);
}
/**
* Subscribe to offline state changes
* @param {Function} listener - Callback(isOffline: boolean)
* @returns {Function} Unsubscribe function
*/
export function onOfflineChange(listener) {
offlineListeners.add(listener);
// Immediately call with current state
listener(!navigator.onLine);
return () => offlineListeners.delete(listener);
}
function notifyOfflineListeners(isOffline) {
for (const listener of offlineListeners) {
try {
listener(isOffline);
} catch (e) {
console.error('[PWA] Offline listener error:', e);
}
}
}
/**
* Check if currently online
*/
export function isOnline() {
return navigator.onLine;
}
// ============================================================================
// Update Handling
// ============================================================================
let updateCallback = null;
/**
* Set callback for when updates are available
* @param {Function} callback - Called when new version is ready
*/
export function onUpdateAvailable(callback) {
updateCallback = callback;
}
function showUpdateNotification() {
if (updateCallback) {
updateCallback();
return;
}
// Default behavior
if (confirm('A new version is available. Reload now?')) {
applyUpdate();
}
}
/**
* Apply pending update (reload with new version)
*/
export function applyUpdate() {
if (swRegistration?.waiting) {
swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
window.location.reload();
}
// ============================================================================
// Communication with Service Worker
// ============================================================================
/**
* Send a message to the service worker
* @param {Object} message - Message to send
*/
export function postToServiceWorker(message) {
navigator.serviceWorker.controller?.postMessage(message);
}
/**
* Request the service worker to cache specific modules
* @param {string[]} moduleNames - Array of module names to cache
*/
export function cacheModules(moduleNames) {
postToServiceWorker({
type: 'CACHE_MODULES',
payload: { modules: moduleNames }
});
}
/**
* Request the service worker to clear user-specific caches
* (Call this on logout)
*/
export function clearUserCaches() {
postToServiceWorker({
type: 'CLEAR_USER_CACHE'
});
}
/**
* Get the Service Worker we can postMessage to. Resolves with:
* • `navigator.serviceWorker.controller` if it's already in control of the
* page (fastest path), or
* • `registration.active` once `navigator.serviceWorker.ready` resolves
* (covers the first-load case before the SW has claimed the page).
*
* Rejects after `timeoutMs` if no SW becomes available — which would only
* happen in a private/incognito context, an unsupported browser, or when
* registration genuinely failed.
*
* @param {{ timeoutMs?: number }} [opts]
* @returns {Promise<ServiceWorker>}
*/
export async function getActiveServiceWorker({ timeoutMs = 10000 } = {}) {
if (!('serviceWorker' in navigator)) {
throw new Error('Service Workers not supported in this browser');
}
// Fastest path — page is already SW-controlled
if (navigator.serviceWorker.controller) {
return navigator.serviceWorker.controller;
}
// Otherwise wait for the registration to become ready (active SW exists
// for this scope, even if it hasn't claimed THIS page yet)
const ready = navigator.serviceWorker.ready;
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Service-worker readiness timeout')), timeoutMs)
);
const registration = await Promise.race([ready, timeout]);
// The controller may have appeared while we were waiting; otherwise use
// the registration's active worker (we can still postMessage to it — caches
// are shared across the origin)
const sw = navigator.serviceWorker.controller || registration.active;
if (!sw) {
throw new Error('No active service worker available');
}
return sw;
}
/**
* Subscribe to controller-change events. The callback fires whenever a new
* Service Worker takes control of the page (e.g. after an SW update or first
* activation on initial load). Useful for re-querying SW-backed state once
* the SW has actually taken over.
*
* @param {() => void} callback
* @returns {() => void} unsubscribe function
*/
export function onServiceWorkerControllerChange(callback) {
if (!('serviceWorker' in navigator)) return () => {};
const handler = () => {
try { callback(); } catch (e) { console.error('[PWA] controllerchange handler error:', e); }
};
navigator.serviceWorker.addEventListener('controllerchange', handler);
return () => navigator.serviceWorker.removeEventListener('controllerchange', handler);
}
/**
* Send a message to the service worker and wait for a single reply with the
* given response type. Waits for the SW to become available if it isn't yet.
* Resolves with the reply payload, or rejects after a timeout.
*
* @template T
* @param {string} requestType - Message type to send (e.g. 'GET_TILE_STATS')
* @param {string} responseType - Message type expected back (e.g. 'TILE_STATS')
* @param {Object} [extra={}] - Extra fields merged into the outgoing message
* @param {number} [timeoutMs=5000] Reply timeout (after the SW is available)
* @param {number} [readyTimeoutMs=10000] Timeout for the SW to be available
* @returns {Promise<T>}
*/
async function requestFromServiceWorker(requestType, responseType, extra = {}, timeoutMs = 5000, readyTimeoutMs = 10000) {
const sw = await getActiveServiceWorker({ timeoutMs: readyTimeoutMs });
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
const timer = setTimeout(() => {
channel.port1.close();
reject(new Error(`Service-worker reply "${responseType}" timed out`));
}, timeoutMs);
channel.port1.onmessage = (event) => {
if (event.data?.type === responseType) {
clearTimeout(timer);
channel.port1.close();
const { type, ...rest } = event.data;
resolve(rest);
}
};
sw.postMessage({ type: requestType, ...extra }, [channel.port2]);
});
}
/**
* Get statistics about tiles cached locally on this device, broken down by
* provider. Waits up to `readyTimeoutMs` for the service worker to become
* available. Returns null only if the SW genuinely cannot be reached
* (private mode, registration failure, or timeout).
*
* @returns {Promise<{
* totals: { count: number, estBytes: number },
* byProvider: Array<{ key: string, label: string, count: number, limit: number, estBytes: number }>
* } | null>}
*/
export async function getTileCacheStats() {
try {
const reply = await requestFromServiceWorker('GET_TILE_STATS', 'TILE_STATS');
return reply.stats;
} catch (err) {
console.warn('[PWA] getTileCacheStats failed:', err);
return null;
}
}
/**
* Delete every cached tile from this device. Doesn't touch the app shell,
* modules, or API caches — only the per-provider tile buckets.
* Waits for the SW to be available before sending the request.
*
* @returns {Promise<boolean>} true if the request was acknowledged
*/
export async function clearTileCaches() {
try {
await requestFromServiceWorker('CLEAR_TILE_CACHES', 'TILE_CACHES_CLEARED');
return true;
} catch (err) {
console.warn('[PWA] clearTileCaches failed:', err);
return false;
}
}
/**
* Delete cached tiles for a single provider. `cacheName` must match one of
* the per-provider caches reported by `getTileCacheStats()` (e.g.
* `tiles-osm-v4`, `tiles-topo-v4`). Unknown names are rejected by the SW.
*
* @param {string} cacheName
* @returns {Promise<boolean>} true if the cache was actually deleted
*/
export async function clearTileCacheForProvider(cacheName) {
if (!cacheName) return false;
try {
const reply = await requestFromServiceWorker(
'CLEAR_TILE_CACHE', 'TILE_CACHE_CLEARED',
{ cacheName },
);
return !!reply.deleted;
} catch (err) {
console.warn(`[PWA] clearTileCacheForProvider(${cacheName}) failed:`, err);
return false;
}
}
/**
* Get total disk used by this origin (Cache API + IndexedDB + OPFS).
* Returns null if the Storage API is not available.
*
* @returns {Promise<{ usage: number, quota: number } | null>}
*/
export async function getStorageEstimate() {
if (!navigator.storage?.estimate) return null;
try {
const { usage, quota } = await navigator.storage.estimate();
return { usage: usage || 0, quota: quota || 0 };
} catch (err) {
console.warn('[PWA] getStorageEstimate failed:', err);
return null;
}
}
// ============================================================================
// Auto-initialization
// ============================================================================
/**
* Initialize all PWA features
* @param {Object} options
*/
export async function initPWA(options = {}) {
const {
installButton = '#install-btn',
offlineIndicator = '#offline-indicator',
autoRegisterSW = true
} = options;
if (autoRegisterSW) {
await registerServiceWorker();
} else {
// Nothing should be controlling the page when registration is off; clear
// out anything an earlier session left behind.
await unregisterServiceWorkers();
}
initInstallPrompt(installButton);
initOfflineDetection(offlineIndicator);
console.log('[PWA] Initialized');
}
// Export for direct use
export default {
registerServiceWorker,
initInstallPrompt,
initOfflineDetection,
initPWA,
isOnline,
onOfflineChange,
onUpdateAvailable,
applyUpdate,
postToServiceWorker,
cacheModules,
clearUserCaches,
getTileCacheStats,
clearTileCaches,
clearTileCacheForProvider,
getStorageEstimate,
getActiveServiceWorker,
onServiceWorkerControllerChange,
};