import { registerSW } from 'virtual:pwa-register' import { store } from '@/store' /** * Service worker güncelleme akışı * ------------------------------------------------------------------ * 1. Kayıt + açılışta bir kez `update()` kontrolü. * 2. Sekme görünürken periyodik, sekmeye dönüldüğünde ve internet geri * geldiğinde tekrar kontrol -> yeni deploy açık uygulamada da yakalanır. * 3. Yeni worker kurulmaya başlayınca (`installing`) engellemeyen bir bant * ile "yeni sürüm indiriliyor" bilgisi gösterilir. * 4. Kurulum bitince (`installed`) tam ekran overlay + sürüm notları gösterilir, * SKIP_WAITING gönderilir ve `controllerchange` ile sayfa yenilenir. * 5. Yenileme sonrası `consumeAppUpdatedVersion()` ile "güncellendi" bildirimi * ve changelog yönlendirmesi yapılır. */ export type ManualUpdateResult = | 'up-to-date' | 'updating' /** Tarayıcı service worker desteklemiyor. */ | 'unsupported' /** Destekleniyor ama bu ortamda kapalı (dev; VITE_PWA_DEV=true ile açılır). */ | 'disabled' const PERIODIC_CHECK_INTERVAL = 15 * 60 * 1000 const MIN_CHECK_GAP = 60 * 1000 const ACTIVATION_RETRY_DELAY = 8_000 const ACTIVATION_TIMEOUT = 30_000 // `virtual:pwa-register` dev'de (devOptions kapalıyken) no-op'tur ve hiçbir // callback çalışmaz. Kayıt promise'inin süresiz beklememesi için üst sınır. const REGISTRATION_TIMEOUT = 10_000 // `registration.update()` yavaş/kopuk ağda süresiz askıda kalabiliyor. const UPDATE_CALL_TIMEOUT = 20_000 const withTimeout = (promise: Promise, ms: number) => Promise.race([ promise, new Promise((resolve) => window.setTimeout(() => resolve(undefined), ms)), ]) const UPDATED_VERSION_KEY = 'sozsoft:app-updated-version' const CHUNK_RELOAD_GUARD_KEY = 'sozsoft:chunk-reload' type Release = { version: string changeLog?: string[] } let started = false let registration: ServiceWorkerRegistration | undefined let initialization: Promise | undefined let activating = false let reloading = false let lastCheckAt = 0 let periodicTimer: number | undefined let activationRetryTimer: number | undefined let activationTimeoutTimer: number | undefined let storeUnsubscribe: (() => void) | undefined let latestVersion: string | undefined let releasePromise: Promise | undefined const watchedRegistrations = new WeakSet() const watchedWorkers = new WeakSet() /* ------------------------------------------------------------------ */ /* Sürüm bilgisi */ /* ------------------------------------------------------------------ */ const loadLatestRelease = () => (releasePromise ??= fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store' }) .then((response) => { if (!response.ok) throw new Error(`version.json isteği ${response.status} döndü`) return response.json() as Promise<{ releases?: Release[] }> }) .then((data) => { const release = data.releases?.[0] latestVersion = release?.version ?? latestVersion return release }) .catch((error: unknown) => { console.warn('Sürüm bilgisi okunamadı.', error) releasePromise = undefined return undefined })) /** Yenileme sonrası bir kez okunur; yeni sürüm numarasını döner. */ export const consumeAppUpdatedVersion = (): string | undefined => { try { const value = sessionStorage.getItem(UPDATED_VERSION_KEY) if (value) sessionStorage.removeItem(UPDATED_VERSION_KEY) return value ?? undefined } catch { return undefined } } /* ------------------------------------------------------------------ */ /* Arayüz */ /* ------------------------------------------------------------------ */ const STYLE_ID = 'sw-update-style' const BANNER_ID = 'sw-update-banner' const OVERLAY_ID = 'sw-update-overlay' function ensureStyles() { if (document.getElementById(STYLE_ID)) return const style = document.createElement('style') style.id = STYLE_ID style.textContent = ` #${BANNER_ID} { position: fixed; right: 16px; bottom: 16px; z-index: 99998; display: flex; align-items: center; gap: 10px; max-width: 320px; padding: 12px 16px; border-radius: 12px; background: #111827; color: #f9fafb; font-size: 13px; line-height: 1.4; box-shadow: 0 10px 30px rgba(0,0,0,0.3); } #${OVERLAY_ID} { position: fixed; inset: 0; z-index: 99999; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.65); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); } .sw-update-card { display: flex; flex-direction: column; align-items: center; gap: 16px; background: #fff; border-radius: 16px; padding: 32px 40px; box-shadow: 0 8px 40px rgba(0,0,0,0.25); text-align: center; max-width: 560px; max-height: min(90vh, 720px); width: 90%; } .sw-update-spinner { flex: 0 0 auto; width: 56px; height: 56px; border: 5px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: sw-spin 0.8s linear infinite; } #${BANNER_ID} .sw-update-spinner { width: 20px; height: 20px; border-width: 3px; border-color: rgba(255,255,255,0.25); border-top-color: #a5b4fc; } @keyframes sw-spin { to { transform: rotate(360deg); } } .sw-update-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; } .sw-update-desc { font-size: 14px; color: #6b7280; margin: 0; } .sw-update-versions { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 6px; width: 100%; padding: 10px 12px; border-radius: 8px; background: #f3f4f6; color: #4b5563; font-size: 13px; } .sw-update-separator { color: #9ca3af; margin: 0 4px; } .sw-update-release { width: 100%; min-height: 0; overflow-y: auto; text-align: left; border-top: 1px solid #e5e7eb; padding-top: 14px; } .sw-update-release-title { margin: 0 0 10px; color: #374151; font-size: 14px; font-weight: 700; } .sw-update-list { display: flex; flex-direction: column; gap: 8px; list-style: none; padding: 0; margin: 0; color: #6b7280; font-size: 13px; line-height: 1.45; } .sw-update-list li { display: flex; align-items: flex-start; gap: 8px; } .sw-update-check { display: inline-flex; flex: 0 0 16px; align-items: center; justify-content: center; width: 16px; height: 16px; margin-top: 1px; border-radius: 50%; background: #d1fae5; color: #059669; font-size: 11px; font-weight: 700; } .sw-update-empty { display: block; color: #9ca3af; } .sw-update-action { appearance: none; border: 0; cursor: pointer; padding: 10px 20px; border-radius: 8px; background: #4f46e5; color: #fff; font-size: 14px; font-weight: 600; } .sw-update-action:hover { background: #4338ca; } @media (max-width: 640px) { .sw-update-card { padding: 24px 20px; } #${BANNER_ID} { left: 16px; right: 16px; max-width: none; } } ` document.head.appendChild(style) } function removeStyles() { document.getElementById(STYLE_ID)?.remove() } /** İndirme sürerken kullanıcıyı engellemeyen bilgilendirme. */ function showDownloadBanner() { if (document.getElementById(BANNER_ID) || document.getElementById(OVERLAY_ID)) return ensureStyles() const banner = document.createElement('div') banner.id = BANNER_ID banner.setAttribute('role', 'status') banner.setAttribute('aria-live', 'polite') const spinner = document.createElement('div') spinner.className = 'sw-update-spinner' const text = document.createElement('span') text.textContent = 'Yeni sürüm indiriliyor, lütfen sekmeyi kapatmayın...' banner.append(spinner, text) document.body.appendChild(banner) } function removeDownloadBanner() { document.getElementById(BANNER_ID)?.remove() } function getText(value: unknown) { return value === undefined || value === null ? undefined : String(value) } function applyApiVersion(overlay: HTMLElement) { const apiConfig = store.getState().abpConfig.config?.extraProperties const environment = getText(apiConfig?.environment) const version = getText(apiConfig?.version) if (!environment && !version) return false const target = overlay.querySelector('[data-sw-api-version]') if (target) target.textContent = `${environment ?? '-'}:${version ?? '-'}` return true } function watchApiVersion(overlay: HTMLElement) { storeUnsubscribe?.() storeUnsubscribe = undefined if (applyApiVersion(overlay)) return storeUnsubscribe = store.subscribe(() => { if (!overlay.isConnected || applyApiVersion(overlay)) { storeUnsubscribe?.() storeUnsubscribe = undefined } }) } async function fillOverlayDetails(overlay: HTMLElement) { const release = await loadLatestRelease() if (!overlay.isConnected) return const uiVersion = release?.version ?? store.getState().locale.currentUiVersion const uiValue = overlay.querySelector('[data-sw-ui-version]') if (uiValue) uiValue.textContent = `${import.meta.env.MODE}:${uiVersion ?? '-'}` watchApiVersion(overlay) const releaseTitle = overlay.querySelector('[data-sw-release-title]') const releaseList = overlay.querySelector('[data-sw-release-list]') if (!releaseTitle || !releaseList) return releaseTitle.textContent = release?.version ? `v${release.version} ile gelen yenilikler` : 'Sürüm notları' releaseList.replaceChildren() const changes = release?.changeLog?.filter(Boolean) ?? [] if (changes.length === 0) { const item = document.createElement('li') item.className = 'sw-update-empty' item.textContent = 'Bu sürüm için not bulunamadı.' releaseList.appendChild(item) return } changes.forEach((change) => { const item = document.createElement('li') const icon = document.createElement('span') const text = document.createElement('span') icon.className = 'sw-update-check' icon.setAttribute('aria-hidden', 'true') icon.textContent = '✓' text.textContent = change.replace(/^\s*-\s*/, '') item.append(icon, text) releaseList.appendChild(item) }) } function showActivationOverlay() { removeDownloadBanner() if (document.getElementById(OVERLAY_ID)) return ensureStyles() const overlay = document.createElement('div') overlay.id = OVERLAY_ID overlay.setAttribute('role', 'status') overlay.setAttribute('aria-live', 'polite') overlay.innerHTML = `

Sistem güncelleniyor

Yeni sürüm yükleniyor, lütfen bekleyin...
Sayfa otomatik olarak yenilenecek.

UI: Yükleniyor... | API: Yükleniyor...

Sürüm notları

  • Sürüm notları yükleniyor...
` document.body.appendChild(overlay) void fillOverlayDetails(overlay) } /** Aktivasyon tamamlanamazsa kullanıcıyı belirsizlikte bırakma. */ function showManualReloadPrompt() { const overlay = document.getElementById(OVERLAY_ID) if (!overlay) return overlay.querySelector('.sw-update-spinner')?.remove() const title = overlay.querySelector('[data-sw-title]') const desc = overlay.querySelector('[data-sw-desc]') if (title) title.textContent = 'Güncelleme tamamlanamadı' if (desc) { desc.textContent = 'Yeni sürüme geçmek için sayfayı yenileyin.' } if (overlay.querySelector('.sw-update-action')) return const button = document.createElement('button') button.type = 'button' button.className = 'sw-update-action' button.textContent = 'Sayfayı yenile' button.addEventListener('click', () => reloadForUpdate()) overlay.querySelector('.sw-update-card')?.appendChild(button) } function teardownUpdateUi() { storeUnsubscribe?.() storeUnsubscribe = undefined removeDownloadBanner() document.getElementById(OVERLAY_ID)?.remove() removeStyles() } /* ------------------------------------------------------------------ */ /* Aktivasyon */ /* ------------------------------------------------------------------ */ function clearActivationTimers() { window.clearTimeout(activationRetryTimer) window.clearTimeout(activationTimeoutTimer) activationRetryTimer = undefined activationTimeoutTimer = undefined } function reloadForUpdate() { if (reloading) return reloading = true clearActivationTimers() try { if (latestVersion) sessionStorage.setItem(UPDATED_VERSION_KEY, latestVersion) } catch { /* sessionStorage kullanılamıyorsa bildirim atlanır */ } window.location.reload() } function postSkipWaiting() { registration?.waiting?.postMessage({ type: 'SKIP_WAITING' }) } function activateWaitingWorker() { if (activating) { showActivationOverlay() return } activating = true showActivationOverlay() // Sürüm numarasını yenileme öncesinde hazır tut. void loadLatestRelease() navigator.serviceWorker.addEventListener('controllerchange', reloadForUpdate, { once: true }) postSkipWaiting() // Bazı tarayıcılarda ilk mesaj bekleyen worker'a ulaşmayabiliyor. activationRetryTimer = window.setTimeout(postSkipWaiting, ACTIVATION_RETRY_DELAY) activationTimeoutTimer = window.setTimeout(() => { clearActivationTimers() activating = false // Worker waiting'den çıktıysa aktivasyon olmuş, sadece controllerchange // kaçmış olabilir; yeni precache'i almak için yenile. if (!registration?.waiting && registration?.active) { reloadForUpdate() return } console.warn('Service worker aktivasyonu zaman aşımına uğradı.') showManualReloadPrompt() }, ACTIVATION_TIMEOUT) } /* ------------------------------------------------------------------ */ /* Güncelleme takibi */ /* ------------------------------------------------------------------ */ function trackInstallingWorker(reg: ServiceWorkerRegistration) { const worker = reg.installing // Controller yoksa bu ilk kurulumdur; kullanıcıya güncelleme demek yanlış olur. if (!worker || !navigator.serviceWorker.controller || watchedWorkers.has(worker)) return watchedWorkers.add(worker) showDownloadBanner() worker.addEventListener('statechange', () => { if (worker.state === 'installed') { activateWaitingWorker() return } // Precache dosyalarından biri indirilemezse worker redundant olur; eski // sürüm çalışmaya devam eder ve kullanıcı kilitli ekranda kalmaz. if (worker.state === 'redundant' && !activating) { console.warn('Yeni service worker kurulamadı, mevcut sürümle devam ediliyor.') teardownUpdateUi() } }) } function watchRegistration(reg: ServiceWorkerRegistration) { if (watchedRegistrations.has(reg)) return watchedRegistrations.add(reg) reg.addEventListener('updatefound', () => trackInstallingWorker(reg)) trackInstallingWorker(reg) } /** * Kayıtlı worker'ın script'i artık sunulmuyorsa (ör. PWA kapatıldı, dosya adı * değişti) sunucu SPA fallback'i yüzünden index.html döner ve tarayıcı * "unsupported MIME type" SecurityError'ı fırlatır. Bu durumda eski kayıt * kalıcı olarak bozuktur; temizlenmezse her kontrol aynı hatayı verir. */ async function unregisterIfStale(reg: ServiceWorkerRegistration, error: unknown) { const isStaleScript = error instanceof Error && (error.name === 'SecurityError' || /MIME type/i.test(error.message)) if (!isStaleScript) return false console.warn('Geçersiz service worker kaydı temizleniyor.', error) try { await reg.unregister() } catch { /* temizlenemediyse bir sonraki açılışta tekrar denenir */ } if (registration === reg) registration = undefined return true } async function runUpdateCheck(force = false) { if (!registration || activating || reloading) return if (!force && !navigator.onLine) return const now = Date.now() if (!force && now - lastCheckAt < MIN_CHECK_GAP) return lastCheckAt = now const reg = registration try { await withTimeout(reg.update(), UPDATE_CALL_TIMEOUT) } catch (error: unknown) { if (!(await unregisterIfStale(reg, error))) { console.warn('Service worker güncelleme kontrolü başarısız.', error) } } } function startUpdateWatchers() { window.clearInterval(periodicTimer) periodicTimer = window.setInterval(() => { if (document.visibilityState === 'visible') void runUpdateCheck() }, PERIODIC_CHECK_INTERVAL) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') void runUpdateCheck() }) window.addEventListener('online', () => void runUpdateCheck()) } /** * Deploy sonrası sunucudan kaldırılmış bir lazy chunk istenirse Vite * `vite:preloadError` fırlatır. Beyaz ekran yerine bir kez sayfayı yenile. */ function handleChunkLoadFailures() { window.addEventListener('vite:preloadError', (event) => { if (reloading) return try { if (sessionStorage.getItem(CHUNK_RELOAD_GUARD_KEY)) return sessionStorage.setItem(CHUNK_RELOAD_GUARD_KEY, '1') } catch { return } event.preventDefault() console.warn('Eksik chunk tespit edildi, sayfa yenileniyor.') reloading = true window.location.reload() }) // Sayfa sorunsuz açıldıysa koruma bayrağını temizle. window.setTimeout(() => { try { sessionStorage.removeItem(CHUNK_RELOAD_GUARD_KEY) } catch { /* yoksay */ } }, 10_000) } /* ------------------------------------------------------------------ */ /* Genel API */ /* ------------------------------------------------------------------ */ /** Service worker yalnızca prod'da, dev'de ise VITE_PWA_DEV=true ile aktiftir. */ const isServiceWorkerEnabled = import.meta.env.PROD || import.meta.env.VITE_PWA_DEV === 'true' /** Kapalıyken önceki oturumlardan kalan kayıtları temizler. */ async function unregisterAll() { try { const registrations = await navigator.serviceWorker.getRegistrations() await Promise.all(registrations.map((reg) => reg.unregister().catch(() => false))) } catch { /* yoksay */ } } export const registerServiceWorker = () => { if (started || !('serviceWorker' in navigator)) return started = true handleChunkLoadFailures() if (!isServiceWorkerEnabled) { // Dev'de SW kapalı. Daha önce kaydedilmiş dev worker'ı bırakırsak script'i // (/dev-sw.js) artık üretilmediği için sunucu index.html döner ve her // güncelleme kontrolü "unsupported MIME type" hatası verir. initialization = unregisterAll() return } initialization = new Promise((resolve) => { // Kayıt callback'i hiç çalışmazsa bile promise'i serbest bırak; aksi halde // `checkForAppUpdate()` "Kontrol ediliyor..." durumunda takılı kalır. const registrationTimeout = window.setTimeout(resolve, REGISTRATION_TIMEOUT) const settle = () => { window.clearTimeout(registrationTimeout) resolve() } registerSW({ immediate: true, onRegisteredSW(_swUrl, reg) { if (reg) { registration = reg watchRegistration(reg) startUpdateWatchers() // Kurulumu tamamlanmış, aktif olandan farklı bir worker bekliyorsa // doğrudan devreye al. if (reg.waiting && navigator.serviceWorker.controller) { activateWaitingWorker() } else { void runUpdateCheck(true) } } settle() }, onNeedRefresh() { // Yeni worker tüm kabuk dosyalarını precache'e aldıktan sonra çalışır. activateWaitingWorker() }, onRegisterError(error) { activating = false clearActivationTimers() teardownUpdateUi() console.error('Service worker kaydı başarısız.', error) settle() }, }) }) } export const checkForAppUpdate = async (): Promise => { if (!('serviceWorker' in navigator)) return 'unsupported' if (!isServiceWorkerEnabled) return 'disabled' await initialization // `navigator.serviceWorker.ready` kayıt yokken (ör. dev ortamı) hiçbir zaman // resolve etmez; getRegistration() kayıt yoksa undefined döner. const reg = registration ?? (await navigator.serviceWorker.getRegistration().catch(() => undefined)) if (!reg) return 'unsupported' registration ??= reg watchRegistration(reg) if (reg.waiting && navigator.serviceWorker.controller) { activateWaitingWorker() return 'updating' } let updateFound = false const handleUpdateFound = () => { updateFound = true } let staleRegistrationCleared = false reg.addEventListener('updatefound', handleUpdateFound) try { await withTimeout(reg.update(), UPDATE_CALL_TIMEOUT) } catch (error: unknown) { staleRegistrationCleared = await unregisterIfStale(reg, error) if (!staleRegistrationCleared) { console.warn('Manuel güncelleme kontrolü başarısız.', error) } } finally { reg.removeEventListener('updatefound', handleUpdateFound) lastCheckAt = Date.now() } // Bozuk kayıt temizlendi; bir sonraki açılışta yeni worker kaydedilecek. if (staleRegistrationCleared) return 'up-to-date' if (!updateFound && !reg.installing && !reg.waiting) return 'up-to-date' if (reg.waiting && navigator.serviceWorker.controller) activateWaitingWorker() // `installing` durumundaysa statechange dinleyicisi aynı akışı sürdürür. return 'updating' }