import { canUseWindow, isEnterpriseMessengerContainer, matchesMedia } from "./lib/runtimeContext"; import { PWA_UPDATE_READY_EVENT } from "./lib/pwaUpdateEvents"; const MOBILE_APPROVAL_TASK_PATH = /^\/workflow\/tasks\/([^/]+)$/i; const OFFLINE_ALLOWED_PATHS = ["/mobile/approvals"]; const RUNTIME_CACHE_PREFIXES = ["app-api-runtime", "app-static-runtime"]; const CLEARABLE_CACHE_PREFIXES = [...RUNTIME_CACHE_PREFIXES, "workbox-precache"]; const AUTH_INPUT_PATHS = ["/login", "/change-password", "/forgot-password", "/reset-password"]; const BUILD_SHA_DISPLAY_LENGTH = 12; type ReadyResponse = { data?: { buildSha?: unknown; }; }; let buildFreshnessMonitorInstalled = false; let pwaRegistrationStarted = false; function isAuthInputPath(pathname: string) { return AUTH_INPUT_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`)); } function waitForActivatedWorker(worker: ServiceWorker): Promise { if (worker.state === "activated") return Promise.resolve(true); if (worker.state === "redundant") return Promise.resolve(false); return new Promise((resolve) => { const onStateChange = () => { if (worker.state !== "activated" && worker.state !== "redundant") return; worker.removeEventListener("statechange", onStateChange); resolve(worker.state === "activated"); }; worker.addEventListener("statechange", onStateChange); }); } function normalizeBuildSha(value: unknown) { const normalized = String(value ?? "").trim().toLowerCase(); if ( !normalized || normalized === "unknown" || normalized === "development" || !/^[a-f0-9]{7,64}$/.test(normalized) ) { return null; } return normalized.slice(0, BUILD_SHA_DISPLAY_LENGTH); } function buildUserControlledRefresh() { return () => { void clearPwaRuntimeCaches() .catch(() => undefined) .finally(() => { window.location.reload(); }); }; } export function installBuildFreshnessMonitor() { if (!canUseWindow() || buildFreshnessMonitorInstalled) { return; } buildFreshnessMonitorInstalled = true; let checkInFlight = false; let mismatchNotified = false; const checkBuildFreshness = async () => { if (document.visibilityState !== "visible" || checkInFlight || mismatchNotified) { return; } const clientBuildSha = normalizeBuildSha(document.documentElement.dataset.buildSha); if (!clientBuildSha) { return; } checkInFlight = true; try { const response = await window.fetch("/ready", { cache: "no-store", credentials: "same-origin", headers: { accept: "application/json" }, }); if (!response.ok) { return; } const payload = await response.json() as ReadyResponse; const serverBuildSha = normalizeBuildSha(payload.data?.buildSha); if (!serverBuildSha || serverBuildSha === clientBuildSha) { return; } mismatchNotified = true; window.dispatchEvent(new CustomEvent(PWA_UPDATE_READY_EVENT, { detail: { applyUpdate: buildUserControlledRefresh() }, })); } catch { } finally { checkInFlight = false; } }; document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") { void checkBuildFreshness(); } }); } export function registerPwaIfEnabled() { if (import.meta.env.DEV || import.meta.env.VITE_PWA_ENABLED === "false" || !canUseWindow() || !("serviceWorker" in window.navigator) || pwaRegistrationStarted) { return; } pwaRegistrationStarted = true; const serviceWorker = window.navigator.serviceWorker; let previousController = serviceWorker.controller; let refreshScheduled = false; let refreshApplied = false; // Native navigation may start an update before register() resolves. Subscribe // first: updatefound/activated listeners installed afterward can miss it. serviceWorker.addEventListener("controllerchange", () => { const controller = serviceWorker.controller; if (!controller || controller === previousController) return; const isUpdate = previousController !== null; previousController = controller; void waitForActivatedWorker(controller).then((activated) => { if (!activated || controller !== serviceWorker.controller) return; if (!isUpdate) { window.dispatchEvent(new CustomEvent("hyb-oa:pwa-offline-ready")); return; } if (refreshScheduled) return; refreshScheduled = true; const applyUpdate = () => { if (refreshApplied) return; refreshApplied = true; // Workbox owns new precache installation and old-revision cleanup. // Deleting it here races activation and destroys the new offline shell. void clearPwaCaches(RUNTIME_CACHE_PREFIXES) .catch(() => undefined) .finally(() => window.location.reload()); }; if (isAuthInputPath(window.location.pathname)) { window.dispatchEvent(new CustomEvent(PWA_UPDATE_READY_EVENT, { detail: { applyUpdate } })); } else { applyUpdate(); } }); }); void serviceWorker.register(`${import.meta.env.BASE_URL}sw.js`, { scope: import.meta.env.BASE_URL }) .catch(() => undefined); } export function buildMobileApprovalsPath(taskId?: string | null) { const normalizedTaskId = String(taskId ?? "").trim(); if (!normalizedTaskId) { return "/mobile/approvals"; } return `/mobile/approvals/${encodeURIComponent(normalizedTaskId)}`; } export function extractWorkflowTaskId(pathname: string) { const match = pathname.match(MOBILE_APPROVAL_TASK_PATH); return match?.[1] ?? null; } export function isOfflineSnapshotAllowedPath(pathname: string) { if (OFFLINE_ALLOWED_PATHS.includes(pathname)) { return true; } if (/^\/mobile\/approvals\/[^/]+$/.test(pathname)) { return true; } return Boolean(extractWorkflowTaskId(pathname)); } export function shouldPreferMobileApprovalExperience() { if (!canUseWindow()) { return false; } const isStandalone = matchesMedia("(display-mode: standalone)") || (window.navigator as Navigator & { standalone?: boolean }).standalone === true; const isTouchSmallScreen = window.navigator.maxTouchPoints > 0 && matchesMedia("(max-width: 1024px)"); return isStandalone || isEnterpriseMessengerContainer() || isTouchSmallScreen; } async function clearPwaCaches(prefixes: readonly string[]) { if (!canUseWindow() || !("caches" in window)) { return; } const cacheNames = await window.caches.keys(); const cachesToDelete = cacheNames.filter((cacheName) => prefixes.some((prefix) => cacheName.startsWith(prefix)), ); await Promise.all(cachesToDelete.map(async (cacheName) => { try { await window.caches.delete(cacheName); } catch { } })); } export async function clearPwaRuntimeCaches() { await clearPwaCaches(CLEARABLE_CACHE_PREFIXES); }