| import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils'; |
| interface VisibleElementLike { |
| checkVisibility?: () => boolean; |
| getClientRects?: () => { length: number }; |
| } |
|
|
| interface DocumentLike { |
| readonly visibilityState: string; |
| querySelector: (sel: string) => Element | null; |
| querySelectorAll: (sel: string) => Iterable<Element & VisibleElementLike>; |
| createElement: (tag: string) => HTMLElement; |
| body: { appendChild: (el: Element) => void; contains: (el: Element | null) => boolean }; |
| addEventListener: (type: string, cb: () => void) => void; |
| removeEventListener: (type: string, cb: () => void) => void; |
| } |
|
|
| interface ServiceWorkerContainerLike { |
| readonly controller: object | null; |
| addEventListener: (type: string, cb: () => void) => void; |
| } |
|
|
| export interface SwUpdateHandlerOptions { |
| swContainer?: ServiceWorkerContainerLike; |
| document?: DocumentLike; |
| reload?: () => void; |
| |
| raf?: (cb: () => void) => void; |
| |
| setTimer?: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>; |
| |
| clearTimer?: (id: ReturnType<typeof setTimeout> | null) => void; |
| |
| debug?: boolean; |
| |
| version?: string; |
| } |
|
|
| |
| |
| |
| |
| |
|
|
| export const SW_DEBUG_LOG_KEY = 'wm-sw-debug-log'; |
| const SW_DEBUG_LOG_MAX = 30; |
|
|
| |
| |
| |
| |
| |
| export const OPEN_MODAL_SELECTOR = |
| '[aria-modal="true"], [role="dialog"], .cl-modalBackdrop, .modal-overlay, dialog[open]'; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function isModalOpen(doc: DocumentLike): boolean { |
| for (const el of doc.querySelectorAll(OPEN_MODAL_SELECTOR)) { |
| const checkVisibility = el.checkVisibility; |
| if (typeof checkVisibility === 'function') { |
| if (checkVisibility.call(el)) return true; |
| continue; |
| } |
| const getClientRects = el.getClientRects; |
| if (typeof getClientRects === 'function' && getClientRects.call(el).length > 0) { |
| return true; |
| } |
| } |
| return false; |
| } |
|
|
| function appendDebugLog(entry: Record<string, unknown>): void { |
| try { |
| const raw = sessionStorage.getItem(SW_DEBUG_LOG_KEY); |
| const log = (raw ? JSON.parse(raw) : []) as unknown[]; |
| log.push(entry); |
| if (log.length > SW_DEBUG_LOG_MAX) log.splice(0, log.length - SW_DEBUG_LOG_MAX); |
| sessionStorage.setItem(SW_DEBUG_LOG_KEY, JSON.stringify(log)); |
| } catch {} |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function installSwUpdateHandler(options: SwUpdateHandlerOptions = {}): void { |
| const swContainer = options.swContainer ?? navigator.serviceWorker; |
| const doc = options.document ?? (document as unknown as DocumentLike); |
| const reload = options.reload ?? (() => window.location.reload()); |
| const raf = options.raf ?? ((cb: () => void) => requestAnimationFrame(() => requestAnimationFrame(cb))); |
| const setTimer = options.setTimer ?? ((cb: () => void, ms: number) => setTimeout(cb, ms)); |
| const clearTimer = options.clearTimer ?? ((id: ReturnType<typeof setTimeout> | null) => { if (id !== null) clearTimeout(id); }); |
|
|
| const debugEnabled = options.debug ?? (() => { |
| try { return localStorage.getItem('wm-debug-sw') === '1'; } catch { return false; } |
| })(); |
| const version = options.version; |
|
|
| function logSw(event: string, extra: Record<string, unknown> = {}): void { |
| if (!debugEnabled) return; |
| const entry: Record<string, unknown> = { |
| event, |
| ts: new Date().toISOString(), |
| visibility: doc.visibilityState, |
| hasController: !!swContainer.controller, |
| ...extra, |
| }; |
| if (version !== undefined) entry.version = version; |
| console.log('[SWDEBUG]', entry); |
| appendDebugLog(entry); |
| } |
|
|
| |
| |
| const VISIBLE_DWELL_MS = 5_000; |
|
|
| let currentOnHidden: (() => void) | null = null; |
| let currentDwellCancel: (() => void) | null = null; |
|
|
| const showToast = (): void => { |
| if (currentOnHidden) { |
| doc.removeEventListener('visibilitychange', currentOnHidden); |
| currentOnHidden = null; |
| } |
| |
| |
| if (currentDwellCancel) { |
| currentDwellCancel(); |
| currentDwellCancel = null; |
| } |
| doc.querySelector('.update-toast')?.remove(); |
|
|
| const toast = doc.createElement('div'); |
| toast.className = 'update-toast'; |
| setTrustedHtml(toast, trustedHtml(` |
| <div class="update-toast-icon"> |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| <polyline points="23 4 23 10 17 10"/> |
| <path d="M20.49 15a9 9 0 1 1-.49-4.9L23 10"/> |
| </svg> |
| </div> |
| <div class="update-toast-body"> |
| <div class="update-toast-title">Update Available</div> |
| <div class="update-toast-detail">A new version is ready.</div> |
| </div> |
| <button class="update-toast-action" data-action="reload">Reload</button> |
| <button class="update-toast-dismiss" data-action="dismiss" aria-label="Dismiss">\u00d7</button> |
| `, "legacy direct innerHTML migration")); |
|
|
| let dismissed = false; |
| let autoReloadAllowed = false; |
| let dwellTimerId: ReturnType<typeof setTimer> | null = null; |
|
|
| const startDwellTimer = (): void => { |
| if (dwellTimerId !== null || dismissed || autoReloadAllowed) return; |
| logSw('dwell-timer-started', { delayMs: VISIBLE_DWELL_MS }); |
| dwellTimerId = setTimer(() => { |
| dwellTimerId = null; |
| autoReloadAllowed = true; |
| logSw('dwell-timer-expired', { autoReloadAllowed: true }); |
| }, VISIBLE_DWELL_MS); |
| }; |
|
|
| |
| if (doc.visibilityState === 'visible') startDwellTimer(); |
|
|
| logSw('toast-shown', { wasVisible: doc.visibilityState === 'visible' }); |
|
|
| const onHidden = (): void => { |
| if (doc.visibilityState === 'visible') { |
| |
| logSw('visibility-visible'); |
| startDwellTimer(); |
| return; |
| } |
| |
| |
| if (!autoReloadAllowed && dwellTimerId !== null) { |
| clearTimer(dwellTimerId); |
| dwellTimerId = null; |
| logSw('dwell-timer-cancelled-on-hide'); |
| } |
| logSw('visibility-hidden', { autoReloadAllowed, dismissed }); |
| if (!dismissed && autoReloadAllowed && doc.body.contains(toast)) { |
| |
| |
| |
| |
| if (isModalOpen(doc)) { |
| logSw('auto-reload-suppressed-modal-open'); |
| return; |
| } |
| logSw('auto-reload-triggered'); |
| reload(); |
| } |
| }; |
|
|
| toast.addEventListener('click', (e) => { |
| const action = (e.target as HTMLElement).closest<HTMLElement>('[data-action]')?.dataset.action; |
| if (action === 'reload') { |
| clearTimer(dwellTimerId); |
| dwellTimerId = null; |
| currentDwellCancel = null; |
| logSw('reload-clicked'); |
| reload(); |
| } else if (action === 'dismiss') { |
| clearTimer(dwellTimerId); |
| dwellTimerId = null; |
| currentDwellCancel = null; |
| dismissed = true; |
| logSw('dismiss-clicked'); |
| doc.removeEventListener('visibilitychange', onHidden); |
| currentOnHidden = null; |
| toast.classList.remove('visible'); |
| setTimeout(() => toast.remove(), 300); |
| } |
| }); |
|
|
| currentOnHidden = onHidden; |
| currentDwellCancel = () => { clearTimer(dwellTimerId); dwellTimerId = null; }; |
| doc.addEventListener('visibilitychange', onHidden); |
| doc.body.appendChild(toast); |
| raf(() => toast.classList.add('visible')); |
| }; |
|
|
| let hadController = !!swContainer.controller; |
| logSw('handler-installed', { hadController }); |
| swContainer.addEventListener('controllerchange', () => { |
| logSw('controllerchange', { hadController }); |
| if (!hadController) { |
| hadController = true; |
| return; |
| } |
| showToast(); |
| }); |
| } |
|
|