sozsoft-platform/ui/src/views/version/swRegistration.ts
2026-08-26 21:44:53 +03:00

1046 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = <T>(promise: Promise<T>, ms: number) =>
Promise.race([
promise,
new Promise<undefined>((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<AppUpdateState>) {
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<AppUpdateState>,
) {
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<VersionInfo> | 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<void> | 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<ServiceWorkerRegistration>()
const watchedWorkers = new WeakSet<ServiceWorker>()
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<void>((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<ManualUpdateResult> => {
// 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'
}