318 lines
8.3 KiB
JavaScript
318 lines
8.3 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'
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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
|
|
};
|