import { registerSW } from 'virtual:pwa-register' // `beforeinstallprompt` erken tetiklendiği için dinleyiciler modül yüklenirken kurulur. import './installPrompt' /** * Sürüm / güncelleme motoru * ------------------------------------------------------------------ * Sürüm numarası ve sürüm notları backend'den gelir: application-configuration * yanıtındaki `extraProperties.changeLogs`. Store, config'i her aldığında * `setAppVersionInfo()` ile bu modülü besler; ayrı bir sürüm isteği yoktur. * * Deploy algılaması service worker'ın kendi güncelleme kontrolüne dayanır: * herhangi bir chunk değiştiğinde entry chunk'ın hash'i, dolayısıyla index.html * ve `sw.js` içindeki precache manifest'i değişir; `registration.update()` yeni * worker'ı görür. Böylece ayrı bir sürüm dosyası yoklamaya gerek kalmaz. * * Akış: * 1. Service worker kaydı + periyodik/olay tabanlı `update()` kontrolü. * 2. Yeni worker bulunursa kurulum başlar; `src/sw.ts` indirdiği her dosyayı * isim/boyut/yüzde olarak yayınlar, bu modül state'e çevirir. * 3. Kurulum bitince SKIP_WAITING → `controllerchange` → reload. * 4. Changelog yönlendirmesi bu modülün işi DEĞİLDİR: son görülen sürüm ve * bekleyen changelog bayrağı store'un `locale` diliminde tutulur * (`setUiVersion` / `pendingChangelogVersion`), böylece durum tek yerde ve * `[EasyPeasyStore][0]` içinde persist edilir. Bu modül yalnızca güncelleme * penceresinin gösterdiği sürüm/not bilgisini tutar. * * Bu dosya entry chunk'ındadır: React/UI/store bağımlılığı yoktur, pencere * yalnızca gerçekten indirme varken lazy yüklenir. */ /* ------------------------------------------------------------------ */ /* Tipler */ /* ------------------------------------------------------------------ */ 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' export type UpdateFileStatus = 'pending' | 'downloading' | 'done' | 'error' export type UpdateFile = { /** Service worker'ın gönderdiği liste içindeki sırası (delta birleştirmede anahtar). */ index: number url: string name: string loaded: number total: number status: UpdateFileStatus /** Son hareket sırası; liste en yeni hareketi en üstte gösterir. */ seq?: number } export type AppUpdateStatus = | 'idle' | 'checking' | 'downloading' | 'ready' /** Tüm bileşenler indi ve geçiş gerekmiyor (ilk kurulum). */ | 'completed' | 'activating' | 'error' export type AppUpdateState = { status: AppUpdateStatus /** Pencere açık mı. */ visible: boolean /** Uygulamanın çalışan sürümü (backend'den gelir). */ currentVersion: string changeLog: string[] files: UpdateFile[] loadedBytes: number totalBytes: number percent: number /** Değişmediği için yeniden indirilmeyen dosya sayısı. */ reusedCount: number /** * Durum mesajının kimliği (ör. `App.UpdateMessage.DownloadFailed`). * Localization anahtarı DEĞİLDİR; karşılığı `AppUpdateDialog` içindeki * `UPDATE_MESSAGE` tablosundan çözülür. */ message?: string /** Otomatik geçiş/yeniden deneme için kalan saniye. */ secondsLeft?: number /** İndirme hızından tahmin edilen kalan süre (saniye). */ secondsRemaining?: number /** * Pencere kullanıcı tarafından kapatılabilir mi? İlk kurulumda indirme * çalışan sürümü etkilemediği için kullanıcı kapatıp devam edebilir; * güncellemede ise geçiş bitene kadar açık kalır. */ dismissible: boolean } /** * `extraProperties.changeLogs` yükü: yalnızca ÇALIŞAN sürüm. Tam sürüm notu * listesi ChangeLog ekranının kendi endpoint'inden gelir, her config isteğinde * taşınmaz. */ export type VersionInfo = { /** Uygulamanın (UI) çalışan sürümü. */ uiVersion: string /** Son sürümün notları. */ tags?: string[] } /* ------------------------------------------------------------------ */ /* Sabitler */ /* ------------------------------------------------------------------ */ /** Sekme açık kalırsa periyodik yoklama. */ const VERSION_POLL_INTERVAL = 30 * 60 * 1000 const MIN_CHECK_GAP = 30 * 1000 const ACTIVATION_RETRY_DELAY = 8_000 const ACTIVATION_TIMEOUT = 45_000 /** İndirme bittikten sonra geçişin kendiliğinden başlaması için tanınan süre. */ const ACTIVATION_HANDOFF_TIMEOUT = 15_000 const REGISTRATION_TIMEOUT = 10_000 const UPDATE_CALL_TIMEOUT = 20_000 /** Kurulum/aktivasyon başarısız olduğunda otomatik yeniden deneme süresi. */ const RETRY_DELAY_SECONDS = 15 /** * Chunk yeniden yükleme koruması: sonsuz reload döngüsünü keser. * * Store'da tutulamaz — reload'ın hemen öncesinde, React/store mount edilmeden * yazılması gerekiyor. Bu yüzden modüldeki tek doğrudan depo anahtarı budur; * sürüm/changelog durumu store'un `locale` diliminde durur. Bayrak 10 saniye * sonra silindiği için kalıcı bir kayıt bırakmaz. */ const CHUNK_RELOAD_GUARD_KEY = 'chunkReloadGuard' /** 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' const withTimeout = (promise: Promise, ms: number) => Promise.race([ promise, new Promise((resolve) => window.setTimeout(() => resolve(undefined), ms)), ]) const readStorage = (key: string) => { try { return localStorage.getItem(key) ?? undefined } catch { return undefined } } const writeStorage = (key: string, value: string | undefined) => { try { if (value === undefined) localStorage.removeItem(key) else localStorage.setItem(key, value) } catch { /* storage kapalıysa yoksay */ } } /* ------------------------------------------------------------------ */ /* Durum yönetimi */ /* ------------------------------------------------------------------ */ let state: AppUpdateState = { status: 'idle', visible: false, // Backend'den (`setAppVersionInfo`) gelene kadar boş kalır. currentVersion: '', changeLog: [], files: [], loadedBytes: 0, totalBytes: 0, percent: 0, reusedCount: 0, dismissible: false, } const listeners = new Set<(value: AppUpdateState) => void>() export const getAppUpdateState = () => state export const subscribeAppUpdate = (listener: (value: AppUpdateState) => void) => { listeners.add(listener) return () => listeners.delete(listener) } function setState(patch: Partial) { state = { ...state, ...patch } listeners.forEach((listener) => listener(state)) } /** * Kullanıcı pencereyi kapattı mı? ("arka planda devam et") * * İlerleme mesajları saniyede birkaç kez gelir ve her biri durumu tazeler; * bayrak olmadan `visible: true` yazılıp kapatma bir sonraki tick'te geri * alınıyor, pencere kendiliğinden açılıyordu. Bayrak yalnızca çalışan sürümü * değiştirecek bir geçiş başladığında `forceDialog` ile sıfırlanır — o akışta * pencere zaten kapatılamaz (`dismissible: false`). */ let dismissedByUser = false /** Geçiş gerektiren akışlarda kapatma tercihi geçersizdir. */ const forceDialog = () => { dismissedByUser = false } /** İlerleme güncellemelerinde kullanılacak görünürlük. */ const dialogVisible = () => !dismissedByUser /* Kalan süre tahmini: oturum başından beri ölçülen ortalama hız. */ let rateStartedAt = 0 let rateStartBytes = 0 const resetRate = () => { rateStartedAt = 0 rateStartBytes = 0 } function estimateRemaining(loadedBytes: number, totalBytes: number) { const now = Date.now() if (!rateStartedAt) { rateStartedAt = now rateStartBytes = loadedBytes return undefined } const elapsed = now - rateStartedAt const delta = loadedBytes - rateStartBytes // İlk saniyelerde hız oturmadığı için tahmin verilmez. if (elapsed < 2_000 || delta <= 0 || totalBytes <= 0) return state.secondsRemaining const remaining = Math.max(0, totalBytes - loadedBytes) return Math.round(remaining / (delta / elapsed) / 1000) } /** * Boyutlar derleme anında bilindiği için toplamlar service worker'dan gelir; * gelmezse (eski worker) dosya listesinden hesaplanır. */ function recalculate( files: UpdateFile[], totals?: { loadedBytes?: number; totalBytes?: number }, extra?: Partial, ) { const loadedBytes = totals?.loadedBytes ?? files.reduce((sum, file) => sum + file.loaded, 0) const totalBytes = totals?.totalBytes || files.reduce((sum, file) => sum + (file.total || file.loaded), 0) // Boyut hiç bilinmiyorsa (warmup.json yoksa) biten dosya sayısına düşülür. const ratio = totalBytes > 0 ? loadedBytes / totalBytes : files.filter((file) => file.status === 'done').length / (files.length || 1) const finished = state.status === 'ready' || state.status === 'activating' || state.status === 'completed' setState({ files, loadedBytes, totalBytes, // Yüzde asla geri gitmesin. percent: finished ? 100 : Math.max(state.percent, Math.min(99, Math.round(ratio * 100))), secondsRemaining: finished ? undefined : estimateRemaining(loadedBytes, totalBytes), ...extra, }) } /* ------------------------------------------------------------------ */ /* Sürüm bilgisi */ /* ------------------------------------------------------------------ */ /** * Sürüm bilgisini application-configuration yanıtından alır ve yalnızca bu * modülün GÖRÜNTÜLEME durumunu (güncelleme penceresi) besler. * * Kalıcı sürüm takibi — son görülen sürüm ve changelog bayrağı — burada değil, * store'un `locale` diliminde tutulur (`setUiVersion`). Bu modül entry chunk'ta * olduğu için store'a bağımlı olmamalı; bayrağı yazan taraf store'dur. * * Yük şekli: `extraProperties.changeLogs = { uiVersion, tags[] }`. */ export const setAppVersionInfo = (raw: unknown) => { const data = raw as Partial | undefined if (!data?.uiVersion) return setState({ currentVersion: data.uiVersion, changeLog: Array.isArray(data.tags) ? data.tags.filter(Boolean) : [], }) } /* ------------------------------------------------------------------ */ /* Service worker kaydı ve güncelleme */ /* ------------------------------------------------------------------ */ let started = false let registration: ServiceWorkerRegistration | undefined let initialization: Promise | undefined let activating = false let reloading = false let lastCheckAt = 0 let pollTimer: number | undefined let activationRetryTimer: number | undefined let activationTimeoutTimer: number | undefined let activationHandoffTimer: number | undefined const watchedRegistrations = new WeakSet() const watchedWorkers = new WeakSet() function clearActivationTimers() { window.clearTimeout(activationRetryTimer) window.clearTimeout(activationTimeoutTimer) window.clearTimeout(activationHandoffTimer) activationRetryTimer = undefined activationTimeoutTimer = undefined activationHandoffTimer = undefined } /** * İndirme bitti ('ready') ama geçişi kimse başlatmadıysa devreye girer. * * Normal akışta geçişi worker'ın `installed` olayı tetikler. O olay kaçabiliyor: * dinleyici yalnızca `reg.installing` görüldüğünde kuruluyor ve sayfa hard reload * ile açıldığında kayıt zaten `waiting` durumunda olabiliyor. Watchdog olmadan * durum sonsuza kadar 'ready'de kalıyordu — ekran "yeni sürüme geçiliyor"da * donuyor, zaman aşımı da yoktu çünkü sayaçlar yalnızca aktivasyon başladıktan * sonra kuruluyor. */ function scheduleActivationHandoff() { if (activationHandoffTimer || activating || reloading) return activationHandoffTimer = window.setTimeout(() => { activationHandoffTimer = undefined if (activating || reloading) return if (registration?.waiting) { activateWaitingWorker() return } // Bekleyen worker yok: geçiş sessizce kaybolmuş. Kabuk cache'i tazelenerek // yenilenir, aksi halde aynı eski index.html geri gelir. console.warn('Yeni sürüme geçiş tetiklenmedi, sayfa yenileniyor.') void reloadWithFreshShell() }, ACTIVATION_HANDOFF_TIMEOUT) } function reloadForUpdate() { if (reloading) return reloading = true clearActivationTimers() window.location.reload() } /** src/sw.ts içindeki kabuk cache adı ile aynı olmalı. */ const SHELL_CACHE = 'sozsoft-shell-v1' /** * Service worker güncellenmediği (ör. yalnızca lazy chunk değişen) deploy'larda * kabuk cache'i temizlenmeden yenilenirse aynı eski index.html geri gelir ve * güncelleme penceresi sonsuz döngüye girer. */ async function reloadWithFreshShell() { try { await caches.delete(SHELL_CACHE) } catch { /* cache API kapalıysa normal yenileme yapılır */ } reloadForUpdate() } /* ------------------------------------------------------------------ */ /* Otomatik geçiş */ /* ------------------------------------------------------------------ */ let autoTimer: number | undefined function clearAutoAction() { window.clearInterval(autoTimer) autoTimer = undefined } /** * Güncelleme kullanıcı müdahalesi beklemeden tamamlanır; geri sayım yalnızca * ne olacağını göstermek içindir. Pencere geçiş bitene kadar kapanmaz. */ function scheduleAutoAction(seconds: number, action: () => void) { clearAutoAction() pendingAutoAction = action setState({ secondsLeft: seconds }) autoTimer = window.setInterval(() => { const left = (state.secondsLeft ?? 0) - 1 if (left > 0) { setState({ secondsLeft: left }) return } clearAutoAction() setState({ secondsLeft: 0 }) action() }, 1000) } let pendingAutoAction: (() => void) | undefined /** Yeni sürüme geçişi yeniden dener (kurulum/aktivasyon başarısız olduğunda). */ async function retryUpdate() { clearAutoAction() if (!navigator.onLine) { // Çevrimdışıyken kabuk cache'ini silmek uygulamayı hiç açılamaz hale // getirir; bağlantı gelince `online` dinleyicisi akışı sürdürür. setState({ status: 'error', secondsLeft: undefined, message: 'App.UpdateMessage.WaitingConnection', }) return } activating = false clearActivationTimers() setState({ status: 'checking', message: undefined, files: [], loadedBytes: 0, totalBytes: 0, percent: 0, secondsLeft: undefined, }) if (registration) { await runUpdateCheck(true) if (state.status === 'checking') void reloadWithFreshShell() return } void reloadWithFreshShell() } /** Geri sayımı beklemeden çalıştırır (penceredeki tek buton). */ export const applyAppUpdate = () => { const action = pendingAutoAction clearAutoAction() setState({ secondsLeft: 0 }) if (action) { action() return } if (registration?.waiting) { activateWaitingWorker() return } void reloadWithFreshShell() } function postSkipWaiting() { registration?.waiting?.postMessage({ type: 'SKIP_WAITING' }) } function activateWaitingWorker() { // Yeni sürüme geçiliyor: kapatma tercihi burada geçersiz. forceDialog() if (activating) { setState({ visible: true }) return } activating = true setState({ status: 'activating', visible: true, percent: 100 }) 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ı.') setState({ status: 'error', visible: true, message: 'App.UpdateMessage.ActivationSlow', }) scheduleAutoAction(RETRY_DELAY_SECONDS, () => void retryUpdate()) }, ACTIVATION_TIMEOUT) } /* ------------------------------------------------------------------ */ /* Service worker mesajları (indirme ilerlemesi) */ /* ------------------------------------------------------------------ */ type SwFile = { index: number url: string name: string loaded: number total: number status: string } type SwProgressMessage = { type?: string /** Tam liste (START / STATE / DONE). */ files?: SwFile[] /** Yalnızca değişen dosyalar (PROGRESS). */ updates?: SwFile[] reusedCount?: number loadedBytes?: number totalBytes?: number /** 'install': yeni sürüm kurulumu, 'warmup': eksik bileşenlerin tamamlanması. */ phase?: 'install' | 'warmup' } const toFiles = (files: SwFile[] | undefined): UpdateFile[] => (files ?? []).map((file) => ({ index: file.index, url: file.url, name: file.name, loaded: file.loaded ?? 0, total: file.total ?? 0, status: (file.status as UpdateFileStatus) ?? 'pending', })) let updateSeq = 0 /** * İlerleme mesajları yalnızca değişen dosyaları taşır (bir sürümde ~500 chunk * var; tam listeyi saniyede birkaç kez göndermek/kopyalamak pahalı). Her * güncellenen dosyaya artan bir sıra numarası verilir; pencere listeyi buna * göre en yeni hareket en üstte olacak şekilde diziyor. */ function mergeFiles(updates: UpdateFile[]): UpdateFile[] { if (updates.length === 0) return state.files const merged = state.files.slice() for (const update of updates) { const item = { ...update, seq: ++updateSeq } if (update.index >= 0 && update.index < merged.length) merged[update.index] = item else merged.push(item) } return merged } /** Tam liste geldiğinde (DONE) mevcut sıralama bilgisi korunur. */ const keepSeq = (files: UpdateFile[]): UpdateFile[] => files.map((file, position) => ({ ...file, seq: state.files[position]?.seq })) /** Tamamlandı/hata mesajının ekranda kaldığı süre (geçiş gerekmeyen kurulumda). */ const NOTICE_VISIBLE_MS = 5_000 function hideDialogLater() { window.setTimeout(() => { if (state.status === 'completed' || state.status === 'error') { setState({ visible: false, status: 'idle' }) } }, NOTICE_VISIBLE_MS) } /** * Kurulum oturumu sürüyor mu? Isıtma mesajlarının araya girmesini engeller * (bkz. `handleWorkerMessage`). Geçişli kurulumda sayfa yenilendiği için * sıfırlanması yalnızca ilk kurulum (geçişsiz) akışında gerekir. */ let installSessionActive = false /** Ekranda gösterilen oturumun fazı; faz değişince sayaçlar sıfırlanır. */ let activePhase: 'install' | 'warmup' | undefined /** * Oturumun türü BAŞLARKEN dondurulur. * * Başlık ve kapatılabilirlik her ilerleme mesajında yeniden hesaplanırsa araya * giren tek bir mesaj bile pencereyi "Downloading assets" ile "Downloading the * new version" arasında zıplatır. Karar oturum başına bir kez verilir. */ let sessionTransitions = false /** * Oturum değiştiğinde birikmiş liste/bayt bilgisi taşınmaz: iki oturumun dosya * index'leri ayrı uzaylardadır, birleştirilirse satırlar birbirinin üzerine * yazılır ve ilerleme yanlış hesaplanır. */ function startSession(phase: 'install' | 'warmup' | undefined) { if (activePhase === phase) return activePhase = phase resetRate() setState({ files: [], loadedBytes: 0, totalBytes: 0, percent: 0, secondsRemaining: undefined }) } function handleWorkerMessage(event: MessageEvent) { const data = event.data as SwProgressMessage | undefined if (!data?.type) return /** * Controller yoksa bu bir güncelleme değil, uygulamanın ilk kez hazırlanmasıdır * (yeni kullanıcı, temizlenmiş cache, yeni tarayıcı). Bileşenler yine de * indirilir ve ilerleme gösterilir; ancak çalışan sürüm zaten yeni olduğu için * sonunda geçiş/yenileme yapılmaz, pencere kapatılabilir. */ const controlled = !!navigator.serviceWorker.controller /** * Isıtma (eksik bileşenlerin tamamlanması) çalışan sürümü değiştirmez: sonunda * geçiş/yenileme yapılmaz ve pencere kapatılabilir. Kurulum ise yeni sürüme * geçişle biter. */ const transitions = controlled && data.phase !== 'warmup' /** * Kurulum başladığı anda ısıtma mesajları tamamen susturulur. * * Deploy sonrası iki oturum GERÇEKTEN aynı anda koşuyor: eski aktif worker * `ensureWarmup` ile eksik bileşenleri indirirken yeni worker kuruluyor. İki * oturumun dosya listeleri ayrı index uzayında olduğu için birleşince satırlar * birbirinin üzerine yazılıyor, toplam boyut şişiyor ve pencere her tick'te * "Downloading assets" ile "Downloading the new version" arasında gidip * geliyordu. Kurulum kazanır: çalışan sürümü değiştiren tek oturum odur. */ if (data.phase !== 'warmup') installSessionActive = true if (data.phase === 'warmup' && (installSessionActive || activating || state.status === 'ready')) { return } const totals = { loadedBytes: data.loadedBytes, totalBytes: data.totalBytes } switch (data.type) { // İndirme sürerken bağlanan istemcinin aldığı anlık durum; START ile aynı // yükü taşır (bkz. requestPrecacheState). case 'SW_PRECACHE_STATE': case 'SW_PRECACHE_START': // İndirme başladı: bekleyen geri sayım varsa iptal edilir. clearAutoAction() // Geçişli akış kapatılamaz; önceki kapatma tercihi burada geçersizleşir. if (transitions) forceDialog() void showUpdateDialog('downloading') activePhase = undefined startSession(data.phase) sessionTransitions = transitions recalculate(toFiles(data.files), totals, { status: 'downloading', visible: dialogVisible(), reusedCount: data.reusedCount ?? 0, secondsLeft: undefined, dismissible: !sessionTransitions, }) break case 'SW_PRECACHE_PROGRESS': // Durum isteği bir yarışa takılıp cevapsız kalırsa pencere burada açılır. if (!dialogMountRequested) void showUpdateDialog('downloading') // START/STATE'i kaçırmış olabiliriz; faz değiştiyse önceki oturum atılır. if (activePhase !== data.phase) { startSession(data.phase) sessionTransitions = transitions } recalculate(mergeFiles(toFiles(data.updates)), totals, { status: 'downloading', visible: dialogVisible(), dismissible: !sessionTransitions, }) break case 'SW_PRECACHE_DONE': // Geçişsiz kurulum bittiğinde ısıtma yeniden söz sahibi olabilir. if (!sessionTransitions) { installSessionActive = false activePhase = undefined } recalculate(keepSeq(toFiles(data.files)), totals, { // Yeni sürüme geçilecekse 'ready'; ısıtmada iş bitti. status: sessionTransitions ? 'ready' : 'completed', visible: sessionTransitions || dialogVisible(), percent: 100, }) if (sessionTransitions) scheduleActivationHandoff() else hideDialogLater() break case 'SW_PRECACHE_ERROR': if (!sessionTransitions) { installSessionActive = false activePhase = undefined // Çalışan sürüm etkilenmedi; kullanıcıyı yeniden yükleme döngüsüne sokma. setState({ status: 'error', visible: dialogVisible(), dismissible: true, message: 'App.UpdateMessage.PartialDownload', }) hideDialogLater() break } forceDialog() setState({ status: 'error', visible: true, message: 'App.UpdateMessage.DownloadFailed', }) scheduleAutoAction(RETRY_DELAY_SECONDS, () => void retryUpdate()) break default: break } } /** Kapatılabilir bildirimlerde pencereyi gizler (indirme arka planda sürer). */ export const dismissAppUpdateDialog = () => { dismissedByUser = true setState({ visible: false }) } /* ------------------------------------------------------------------ */ /* Güncelleme penceresi (lazy) */ /* ------------------------------------------------------------------ */ let dialogMountRequested = false /** * Pencere bileşeni entry chunk'ında taşınmaz; yalnızca gerçekten güncelleme * olduğunda indirilir ve body'ye kendi kökünde mount edilir. */ async function showUpdateDialog(status: AppUpdateStatus) { setState({ status, visible: dialogVisible() }) if (dialogMountRequested) return dialogMountRequested = true try { const { mountAppUpdateDialog } = await import('./mountAppUpdateDialog') mountAppUpdateDialog() } catch (error) { dialogMountRequested = false console.warn('Güncelleme penceresi yüklenemedi.', error) } } /* ------------------------------------------------------------------ */ /* Sürüm kontrolü */ /* ------------------------------------------------------------------ */ /** * Deploy'da service worker dosyası mutlaka değişir: herhangi bir chunk'ın * hash'i değiştiğinde entry chunk, dolayısıyla index.html ve `sw.js` içindeki * precache manifest'i de değişir. Bu yüzden ayrı bir sürüm dosyası yoklamaya * gerek yok; `registration.update()` yeni deploy'u tek başına yakalar. */ 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) } } } /** * 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 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 } /** * Kurulum çoğu zaman sayfa gezinmesiyle, uygulama mount olmadan başlar; bu * durumda `SW_PRECACHE_START` kaçar. Kayıt görülür görülmez worker'dan anlık * ilerleme istenir ki pencere ortadan da açılabilsin. */ function requestPrecacheState(reg: ServiceWorkerRegistration) { const worker = reg.installing ?? reg.waiting ?? reg.active worker?.postMessage({ type: 'GET_PRECACHE_STATE' }) } /** * Eksik bileşenlerin tamamlanmasını ister. * * Isıtmayı yalnızca kuruluma bağlamak yetmiyor: süre bütçesi dolduğunda, sekme * kapandığında ya da bir dosya hata verdiğinde kalan chunk'lar bir sonraki * deploy'a kadar indirilmiyor ve kullanıcı ekran açtıkça chunk bekliyordu. * Worker eksik bir şey yoksa hiçbir mesaj yollamaz, pencere de açılmaz. */ function requestWarmup(reg: ServiceWorkerRegistration) { // Kurulum sürüyor ya da geçişi bekliyorsa ikinci bir oturum başlatma: iki // oturum hem bant genişliği için yarışır hem de pencerede iç içe geçer. if (reg.installing || reg.waiting || installSessionActive) return const worker = reg.active ?? navigator.serviceWorker.controller worker?.postMessage({ type: 'ENSURE_WARMUP' }) } /** İlk boyama ve açılış istekleri bitsin diye ısıtma isteği boşta gönderilir. */ function scheduleWarmup(reg: ServiceWorkerRegistration) { const idle = (window as unknown as { requestIdleCallback?: (cb: () => void, o?: object) => void }) .requestIdleCallback const send = () => requestWarmup(reg) if (idle) idle.call(window, send, { timeout: 5_000 }) else window.setTimeout(send, 3_000) } function trackInstallingWorker(reg: ServiceWorkerRegistration) { const worker = reg.installing if (!worker || watchedWorkers.has(worker)) return // İlk kurulumda kullanıcıya "güncelleme" demek yanlış olur. Ölçüt yalnızca // `controller` olamaz: hard reload'da sayfa kontrolsüz açılabiliyor, ama ortada // güncellenen bir kayıt (aktif worker) var. Bu ayrım yapılmazsa dinleyici hiç // kurulmuyor ve geçişi tetikleyecek `installed` olayı kaçıyordu. const isUpdate = !!navigator.serviceWorker.controller || !!reg.active if (!isUpdate) return watchedWorkers.add(worker) // Güncelleme kurulumu: geçiş kaçınılmaz, kapatma tercihi geçersiz. forceDialog() void showUpdateDialog('downloading') 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ı, yeniden denenecek.') forceDialog() setState({ status: 'error', visible: true, message: 'App.UpdateMessage.InstallFailed', }) scheduleAutoAction(RETRY_DELAY_SECONDS, () => void retryUpdate()) } }) } function watchRegistration(reg: ServiceWorkerRegistration) { if (watchedRegistrations.has(reg)) return watchedRegistrations.add(reg) reg.addEventListener('updatefound', () => { trackInstallingWorker(reg) requestPrecacheState(reg) }) trackInstallingWorker(reg) requestPrecacheState(reg) } function startWatchers() { // Yoklama olay tabanlıdır (sekmeye dönüş / bağlantı). Periyodik zamanlayıcı // yalnızca sekme uzun süre açık ve önde kalırsa devreye girer. window.clearInterval(pollTimer) pollTimer = window.setInterval(() => { if (document.visibilityState === 'visible') void runUpdateCheck() }, VERSION_POLL_INTERVAL) document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') return void runUpdateCheck() }) window.addEventListener('online', () => { // Çevrimdışıyken beklemeye alınan güncelleme bağlantı gelince sürer. if (state.status === 'error') void retryUpdate() else 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 if (readStorage(CHUNK_RELOAD_GUARD_KEY)) return writeStorage(CHUNK_RELOAD_GUARD_KEY, '1') 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(() => writeStorage(CHUNK_RELOAD_GUARD_KEY, undefined), 10_000) } /** 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 */ } } /* ------------------------------------------------------------------ */ /* Genel API */ /* ------------------------------------------------------------------ */ export const registerServiceWorker = () => { // Dev'de HMR modülü yeniden çalıştırdığında modül seviyesindeki bayrak // sıfırlanıyor ve zamanlayıcı/dinleyiciler üst üste biniyordu. const scope = window as unknown as { __sozsoftUpdateEngineStarted?: boolean } if (started || scope.__sozsoftUpdateEngineStarted) return started = true scope.__sozsoftUpdateEngineStarted = true // Açılış performansı: yoklama dinleyicileri ilk boyamayı beklesin. const idle = (window as unknown as { requestIdleCallback?: (cb: () => void, o?: object) => void }) .requestIdleCallback if (idle) idle.call(window, startWatchers, { timeout: 3_000 }) else window.setTimeout(startWatchers, 1_500) if (!('serviceWorker' in navigator)) return handleChunkLoadFailures() if (!isServiceWorkerEnabled) { // Dev'de SW kapalı. Daha önce kaydedilmiş dev worker'ı bırakırsak script'i // artık üretilmediği için sunucu index.html döner ve her güncelleme // kontrolü "unsupported MIME type" hatası verir. initialization = unregisterAll() return } navigator.serviceWorker.addEventListener('message', handleWorkerMessage) 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) // Kurulumu tamamlanmış, aktif olandan farklı bir worker bekliyorsa // doğrudan devreye al. if (reg.waiting && navigator.serviceWorker.controller) activateWaitingWorker() else scheduleWarmup(reg) } settle() }, onNeedRefresh() { // Yeni worker tüm kabuk dosyalarını precache'e aldıktan sonra çalışır. activateWaitingWorker() }, onRegisterError(error) { activating = false clearActivationTimers() console.error('Service worker kaydı başarısız.', error) settle() }, }) }) } /** Kullanıcının menüden tetiklediği manuel kontrol. */ export const checkForAppUpdate = async (): Promise => { // Kullanıcı kontrolü kendisi istedi; önceki "arka planda devam et" tercihi // bu akışta geçerli değil. forceDialog() setState({ status: state.status === 'idle' ? 'checking' : state.status }) if (!('serviceWorker' in navigator)) return 'unsupported' if (!isServiceWorkerEnabled) return 'disabled' await initialization 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) { if (state.status === 'checking') setState({ status: 'idle' }) return 'up-to-date' } if (reg.waiting && navigator.serviceWorker.controller) activateWaitingWorker() // `installing` durumundaysa statechange dinleyicisi aynı akışı sürdürür. return 'updating' }