import Alpine from 'alpinejs'; import { logout, restoreSession, verifyConnection } from '../api/auth.js'; import { listKitchens } from '../api/kitchens.js'; import { APP_NAME, APP_VERSION } from './config.js'; import { createRouter, navigate } from './router.js'; import { createAppStore } from './store.js'; 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 { supported: false }; } if (import.meta.env.DEV) { const registrations = await navigator.serviceWorker.getRegistrations(); 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; } 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'); appRoot.innerHTML = appShell( APP_NAME, APP_VERSION, import.meta.env.DEV ? 'development' : 'production', ); Alpine.initTree(appRoot); const navRoot = document.querySelector('#app-nav'); function renderNav() { if (!navRoot) { return; } if (typeof Alpine.destroyTree === 'function') { Alpine.destroyTree(navRoot); } navRoot.innerHTML = navBar(APP_NAME); Alpine.initTree(navRoot); } const router = createRouter({ Alpine, store, outlet: document.querySelector('#route-view'), }); let authFailureHandled = false; function applyKitchens(kitchens) { store.setKitchens(kitchens); if (!store.activeKitchen && kitchens.length) { store.setActiveKitchen(kitchens[0]); } renderNav(); return kitchens; } window.__loncApp = { navigate, async refreshKitchens() { return applyKitchens(await listKitchens(store)); }, async restoreSession() { try { const result = await restoreSession(store); if (result?.kitchens) { applyKitchens(result.kitchens); } else if (store.isConnected) { await window.__loncApp.refreshKitchens(); } if (store.isConnected) { authFailureHandled = false; } renderNav(); } catch (error) { renderNav(); if (window.location.hash !== '#/login') { navigate('/login'); } } }, async verifyConnection() { const result = await verifyConnection(store); if (result?.kitchens) { applyKitchens(result.kitchens); } else if (store.isConnected) { await window.__loncApp.refreshKitchens(); } if (store.isConnected) { authFailureHandled = false; } renderNav(); return result; }, async checkForAppUpdate() { return appUpdateManager.checkForAppUpdate(); }, async applyAppUpdate() { return appUpdateManager.applyAppUpdate(); }, handleAuthFailure(error) { if (!store.session?.applicationKey || !store.session?.hasValidated || authFailureHandled) { return; } authFailureHandled = true; store.markSessionInvalid(); renderNav(); const status = error?.status || error?.cause?.status; const message = status === 401 || status === 403 ? 'This application key is no longer accepted by Tryton. Please verify it again or disconnect and create a new key.' : 'Authenticated requests are no longer succeeding. The application key may have been cancelled, or access is being denied by the server. Please reconnect or create a new key.'; store.addAlert({ type: 'warning', timeout: 0, message, }); navigate('/login'); window.setTimeout(() => router.render(), 0); }, async logout() { await logout(store); authFailureHandled = false; renderNav(); navigate('/login'); }, router, }; window.addEventListener('online', () => { store.addAlert({ type: 'success', message: 'Connection restored.' }); }); window.addEventListener('offline', () => { store.addAlert({ type: 'warning', message: 'You are offline. Cached screens stay available, but API actions may fail.', }); }); window.__loncApp .restoreSession() .finally(() => router.start()) .catch(() => router.start()); renderNav(); appUpdateManager .installServiceWorker() .then(() => appUpdateManager.startPeriodicChecks()) .catch(() => { store.addAlert({ type: 'warning', message: 'PWA installation support could not be initialized.', }); }); }