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>
498 lines
15 KiB
JavaScript
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,
|
|
};
|