1046 lines
36 KiB
TypeScript
1046 lines
36 KiB
TypeScript
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'
|
||
}
|