pwaLUPMIS2/src/pwa.js
ekke cfaceb3487 GPS trail recording, SSO auth, account menu, and mobile/UI refinements
Major:
- GPS trail recording: reusable, dependency-free engine in src/geotracker/
  (GeoTracker + geo-utils) with pluggable storage/sync adapters; LUPMIS
  wiring in src/geotracker-lupmis.js. Expandable My Location control
  (Locate Me + Record Trail), live navbar GPS readout, on-map trail/position
  rendering, gps_trails/gps_trail_points SQLocal tables, and store-and-forward
  sync via pushGpsTrail() -> save_gps_trail.php (server side documented, not
  yet built).
- SSO authentication: public/index.php entry point validates the LUSPA SSO
  cookie and injects window.LUPMIS_SESSION; remotedb district_id is now a
  session-resolved getter. Adds public/.htaccess (DirectoryIndex).
- Account menu offcanvas (navbar burger) with sign-in/out states.

UI / fixes:
- LayerSwitcher modernisation; base-map "None" option in picker + settings.
- Mobile drawing toolbar wraps to two rows below 576px and shows only in
  Draw mode; second row right-aligned and clears the Select option bar.
- Safari bottom-dock clipping fixed (app-container 100dvh -> 100svh).
- Rename public/icons -> app-icons to dodge Apache's default /icons/ alias.
- Service Worker bumped to v8 (network-first HTML, per-provider tile clear).

Docs: reusable-mapping and OSM-3D-buildings concept notes; ignore Office
lock files (~$*). Rebuilt dist/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:08:37 +02:00

498 lines
15 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;
}
}
// ============================================================================
// 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();
}
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,
};