2026-02-24 20:44:16 +00:00
|
|
|
|
import { registerSW } from 'virtual:pwa-register'
|
2026-08-12 19:08:14 +00:00
|
|
|
|
// `beforeinstallprompt` erken tetiklendiği için dinleyiciler modül yüklenirken kurulur.
|
|
|
|
|
|
import './installPrompt'
|
2026-02-24 20:44:16 +00:00
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
/**
|
2026-08-12 19:08:14 +00:00
|
|
|
|
* Sürüm / güncelleme motoru
|
2026-08-07 11:59:47 +00:00
|
|
|
|
* ------------------------------------------------------------------
|
2026-08-12 19:08:14 +00:00
|
|
|
|
* Tek sürüm kaynağı `public/version.json` (build'de `scripts/generate-version.js`
|
|
|
|
|
|
* üretir). Çalışan bundle'ın `buildId`'si derleme anında gömülür; sunucudaki
|
|
|
|
|
|
* `version.json` ile karşılaştırıldığı için git tag'i atılmasa bile YENİ DEPLOY
|
|
|
|
|
|
* kesin olarak anlaşılır.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Akış:
|
|
|
|
|
|
* 1. Açılışta tek bir `version.json` isteği (sonuç önbelleklenir, diğer
|
|
|
|
|
|
* ekranlar `fetchVersionInfo()` ile aynı sonucu kullanır).
|
|
|
|
|
|
* 2. `buildId` farklıysa service worker güncellemesi tetiklenir.
|
|
|
|
|
|
* 3. Service worker (`src/sw.ts`) indirdiği her dosyayı isim/boyut/yüzde
|
|
|
|
|
|
* olarak yayınlar; bu modül state'e çevirir, pencere (lazy) gösterir.
|
|
|
|
|
|
* 4. Kurulum bitince SKIP_WAITING → `controllerchange` → reload.
|
|
|
|
|
|
* 5. Yeni build ilk kez çalıştığında changelog bayrağı yazılır; login sonrası
|
|
|
|
|
|
* kullanıcı ChangeLog ekranına yönlendirilir.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Bu dosya entry chunk'ındadır: React/UI bağımlılığı yoktur, pencere ve
|
|
|
|
|
|
* bildirim bileşenleri yalnızca gerçekten güncelleme varken lazy yüklenir.
|
2026-08-07 11:59:47 +00:00
|
|
|
|
*/
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
|
|
/* Tipler */
|
|
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
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'
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-08-07 11:59:47 +00:00
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
export type AppUpdateStatus =
|
|
|
|
|
|
| 'idle'
|
|
|
|
|
|
| 'checking'
|
|
|
|
|
|
| 'downloading'
|
|
|
|
|
|
| 'ready'
|
|
|
|
|
|
/** Tüm bileşenler indi ve geçiş gerekmiyor (ilk kurulum). */
|
|
|
|
|
|
| 'completed'
|
|
|
|
|
|
| 'activating'
|
|
|
|
|
|
| 'reload-required'
|
|
|
|
|
|
| 'error'
|
|
|
|
|
|
|
|
|
|
|
|
export type AppUpdateState = {
|
|
|
|
|
|
status: AppUpdateStatus
|
|
|
|
|
|
/** Pencere açık mı. */
|
|
|
|
|
|
visible: boolean
|
|
|
|
|
|
/** Sunucudaki (yeni) sürüm. */
|
|
|
|
|
|
version?: string
|
|
|
|
|
|
/** Çalışan bundle'ın sürümü. */
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-07-14 07:37:13 +00:00
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
export type Release = {
|
2026-07-14 14:16:08 +00:00
|
|
|
|
version: string
|
2026-08-12 19:08:14 +00:00
|
|
|
|
buildDate?: string
|
|
|
|
|
|
commit?: string
|
2026-07-14 14:16:08 +00:00
|
|
|
|
changeLog?: string[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
export type VersionInfo = {
|
|
|
|
|
|
buildId: string
|
|
|
|
|
|
version: string
|
|
|
|
|
|
buildTime?: string
|
|
|
|
|
|
buildDate?: string
|
|
|
|
|
|
commit?: string
|
|
|
|
|
|
releases: Release[]
|
|
|
|
|
|
}
|
2026-08-07 11:59:47 +00:00
|
|
|
|
|
|
|
|
|
|
/* ------------------------------------------------------------------ */
|
2026-08-12 19:08:14 +00:00
|
|
|
|
/* Sabitler */
|
2026-08-07 11:59:47 +00:00
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
declare const __APP_BUILD_ID__: string
|
|
|
|
|
|
declare const __APP_VERSION__: string
|
2026-08-07 11:59:47 +00:00
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
const RUNNING_BUILD_ID = typeof __APP_BUILD_ID__ === 'string' ? __APP_BUILD_ID__ : 'dev'
|
|
|
|
|
|
const RUNNING_VERSION = typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : '0.0.0'
|
|
|
|
|
|
|
|
|
|
|
|
/** Sekme açık kalırsa periyodik yoklama. */
|
|
|
|
|
|
const VERSION_POLL_INTERVAL = 30 * 60 * 1000
|
|
|
|
|
|
/** Aynı version.json sonucunun paylaşıldığı süre (açılışta tek istek). */
|
|
|
|
|
|
const VERSION_CACHE_TTL = 60 * 1000
|
|
|
|
|
|
/** İki deploy yoklaması arasındaki en kısa süre (sekmeye dönüş, online vb.). */
|
|
|
|
|
|
const MIN_VERSION_CHECK_GAP = 5 * 60 * 1000
|
|
|
|
|
|
const MIN_CHECK_GAP = 30 * 1000
|
|
|
|
|
|
const ACTIVATION_RETRY_DELAY = 8_000
|
|
|
|
|
|
const ACTIVATION_TIMEOUT = 45_000
|
|
|
|
|
|
const REGISTRATION_TIMEOUT = 10_000
|
|
|
|
|
|
const UPDATE_CALL_TIMEOUT = 20_000
|
|
|
|
|
|
/** Yenileme gerektiren deploy'da otomatik geçişe kalan süre. */
|
|
|
|
|
|
const AUTO_RELOAD_SECONDS = 5
|
|
|
|
|
|
/** Kurulum/aktivasyon başarısız olduğunda otomatik yeniden deneme süresi. */
|
|
|
|
|
|
const RETRY_DELAY_SECONDS = 15
|
|
|
|
|
|
|
|
|
|
|
|
const LAST_BUILD_KEY = 'sozsoft:last-build-id'
|
|
|
|
|
|
const AUTO_RELOAD_GUARD_KEY = 'sozsoft:auto-reload-build'
|
|
|
|
|
|
const PENDING_CHANGELOG_KEY = 'sozsoft:pending-changelog'
|
|
|
|
|
|
const CHUNK_RELOAD_GUARD_KEY = 'sozsoft:chunk-reload'
|
|
|
|
|
|
|
|
|
|
|
|
/** 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, session = false) => {
|
2026-07-14 14:16:08 +00:00
|
|
|
|
try {
|
2026-08-12 19:08:14 +00:00
|
|
|
|
return (session ? sessionStorage : localStorage).getItem(key) ?? undefined
|
2026-08-07 11:59:47 +00:00
|
|
|
|
} catch {
|
|
|
|
|
|
return undefined
|
2026-07-14 14:16:08 +00:00
|
|
|
|
}
|
2026-08-07 11:59:47 +00:00
|
|
|
|
}
|
2026-07-14 14:16:08 +00:00
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
const writeStorage = (key: string, value: string | undefined, session = false) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const store = session ? sessionStorage : localStorage
|
|
|
|
|
|
if (value === undefined) store.removeItem(key)
|
|
|
|
|
|
else store.setItem(key, value)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* storage kapalıysa yoksay */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
/* ------------------------------------------------------------------ */
|
2026-08-12 19:08:14 +00:00
|
|
|
|
/* Durum yönetimi */
|
2026-08-07 11:59:47 +00:00
|
|
|
|
/* ------------------------------------------------------------------ */
|
2026-07-14 14:16:08 +00:00
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
let state: AppUpdateState = {
|
|
|
|
|
|
status: 'idle',
|
|
|
|
|
|
visible: false,
|
|
|
|
|
|
currentVersion: RUNNING_VERSION,
|
|
|
|
|
|
changeLog: [],
|
|
|
|
|
|
files: [],
|
|
|
|
|
|
loadedBytes: 0,
|
|
|
|
|
|
totalBytes: 0,
|
|
|
|
|
|
percent: 0,
|
|
|
|
|
|
reusedCount: 0,
|
|
|
|
|
|
dismissible: false,
|
2026-08-07 11:59:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
const listeners = new Set<(value: AppUpdateState) => void>()
|
2026-08-07 11:59:47 +00:00
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
export const getAppUpdateState = () => state
|
2026-08-07 11:59:47 +00:00
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
export const subscribeAppUpdate = (listener: (value: AppUpdateState) => void) => {
|
|
|
|
|
|
listeners.add(listener)
|
|
|
|
|
|
return () => listeners.delete(listener)
|
2026-08-07 11:59:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
function setState(patch: Partial<AppUpdateState>) {
|
|
|
|
|
|
state = { ...state, ...patch }
|
|
|
|
|
|
listeners.forEach((listener) => listener(state))
|
2026-08-07 11:59:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
/* Kalan süre tahmini: oturum başından beri ölçülen ortalama hız. */
|
|
|
|
|
|
let rateStartedAt = 0
|
|
|
|
|
|
let rateStartBytes = 0
|
2026-08-07 11:59:47 +00:00
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
const resetRate = () => {
|
|
|
|
|
|
rateStartedAt = 0
|
|
|
|
|
|
rateStartBytes = 0
|
2026-08-07 11:59:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-08-07 11:59:47 +00:00
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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,
|
2026-08-07 11:59:47 +00:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
|
|
/* Sürüm bilgisi */
|
|
|
|
|
|
/* ------------------------------------------------------------------ */
|
2026-08-07 11:59:47 +00:00
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
let versionInfoPromise: Promise<VersionInfo | undefined> | undefined
|
|
|
|
|
|
let inFlightVersionRequest: Promise<VersionInfo | undefined> | undefined
|
|
|
|
|
|
let lastVersionFetchAt = 0
|
|
|
|
|
|
let latestInfo: VersionInfo | undefined
|
|
|
|
|
|
|
|
|
|
|
|
const normalize = (raw: unknown): VersionInfo | undefined => {
|
|
|
|
|
|
const data = raw as Partial<VersionInfo> | undefined
|
|
|
|
|
|
if (!data) return undefined
|
|
|
|
|
|
const releases = Array.isArray(data.releases) ? data.releases : []
|
|
|
|
|
|
const version = data.version ?? releases[0]?.version
|
|
|
|
|
|
if (!version) return undefined
|
|
|
|
|
|
return {
|
|
|
|
|
|
buildId: String(data.buildId ?? data.commit ?? version),
|
|
|
|
|
|
version,
|
|
|
|
|
|
buildTime: data.buildTime,
|
|
|
|
|
|
buildDate: data.buildDate ?? releases[0]?.buildDate,
|
|
|
|
|
|
commit: data.commit,
|
|
|
|
|
|
releases,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-07 11:59:47 +00:00
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* `version.json` — tek sürüm kaynağı (deploy yoklaması + changelog).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Uçuşta bir istek varsa ikinci bir ağ isteği atılmaz; `force` verilmediğinde
|
|
|
|
|
|
* kısa süre içinde (TTL) alınan sonuç paylaşılır. Böylece açılışta motor,
|
|
|
|
|
|
* pencere ve ChangeLog ekranı tek isteği kullanır.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const fetchVersionInfo = (force = false): Promise<VersionInfo | undefined> => {
|
|
|
|
|
|
if (inFlightVersionRequest) return inFlightVersionRequest
|
|
|
|
|
|
if (!force && versionInfoPromise && Date.now() - lastVersionFetchAt < VERSION_CACHE_TTL) {
|
|
|
|
|
|
return versionInfoPromise
|
2026-08-07 11:59:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
inFlightVersionRequest = fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store' })
|
|
|
|
|
|
.then((response) => {
|
|
|
|
|
|
if (!response.ok) throw new Error(`version.json isteği ${response.status} döndü`)
|
|
|
|
|
|
return response.json()
|
|
|
|
|
|
})
|
|
|
|
|
|
.then((data) => {
|
|
|
|
|
|
const info = normalize(data)
|
|
|
|
|
|
if (info) {
|
|
|
|
|
|
latestInfo = info
|
|
|
|
|
|
lastVersionFetchAt = Date.now()
|
|
|
|
|
|
}
|
|
|
|
|
|
return info
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((error: unknown) => {
|
|
|
|
|
|
console.warn('Sürüm bilgisi okunamadı.', error)
|
|
|
|
|
|
versionInfoPromise = undefined
|
|
|
|
|
|
return undefined
|
|
|
|
|
|
})
|
|
|
|
|
|
.finally(() => {
|
|
|
|
|
|
inFlightVersionRequest = undefined
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
versionInfoPromise = inFlightVersionRequest
|
|
|
|
|
|
return inFlightVersionRequest
|
2026-08-07 11:59:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
export const getRunningVersion = () => RUNNING_VERSION
|
|
|
|
|
|
export const getLatestVersionInfo = () => latestInfo
|
|
|
|
|
|
|
|
|
|
|
|
/** Login sonrası changelog yönlendirmesi için bayrak. */
|
|
|
|
|
|
export const hasPendingChangelog = () => !!readStorage(PENDING_CHANGELOG_KEY)
|
2026-08-07 11:59:47 +00:00
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
export const consumePendingChangelog = (): string | undefined => {
|
|
|
|
|
|
const value = readStorage(PENDING_CHANGELOG_KEY)
|
|
|
|
|
|
if (value) writeStorage(PENDING_CHANGELOG_KEY, undefined)
|
|
|
|
|
|
return value
|
2026-07-14 07:37:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
export const clearPendingChangelog = () => writeStorage(PENDING_CHANGELOG_KEY, undefined)
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Yeni bir build ilk kez çalıştığında (güncelleme sonrası ya da kullanıcı
|
|
|
|
|
|
* uygulamaya deploy'dan sonra ilk kez girdiğinde) changelog bayrağını yazar.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function markRunningBuildSeen(version: string) {
|
|
|
|
|
|
const lastSeen = readStorage(LAST_BUILD_KEY)
|
|
|
|
|
|
if (lastSeen && lastSeen !== RUNNING_BUILD_ID) {
|
|
|
|
|
|
writeStorage(PENDING_CHANGELOG_KEY, version)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (lastSeen !== RUNNING_BUILD_ID) writeStorage(LAST_BUILD_KEY, RUNNING_BUILD_ID)
|
2026-08-07 11:59:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ------------------------------------------------------------------ */
|
2026-08-12 19:08:14 +00:00
|
|
|
|
/* Service worker kaydı ve güncelleme */
|
2026-08-07 11:59:47 +00:00
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
let started = false
|
|
|
|
|
|
let registration: ServiceWorkerRegistration | undefined
|
|
|
|
|
|
let initialization: Promise<void> | undefined
|
|
|
|
|
|
let activating = false
|
|
|
|
|
|
let reloading = false
|
|
|
|
|
|
let lastCheckAt = 0
|
|
|
|
|
|
let lastVersionCheckAt = 0
|
|
|
|
|
|
let pollTimer: number | undefined
|
|
|
|
|
|
let activationRetryTimer: number | undefined
|
|
|
|
|
|
let activationTimeoutTimer: number | undefined
|
|
|
|
|
|
|
|
|
|
|
|
const watchedRegistrations = new WeakSet<ServiceWorkerRegistration>()
|
|
|
|
|
|
const watchedWorkers = new WeakSet<ServiceWorker>()
|
|
|
|
|
|
|
2026-07-14 07:37:13 +00:00
|
|
|
|
function clearActivationTimers() {
|
|
|
|
|
|
window.clearTimeout(activationRetryTimer)
|
|
|
|
|
|
window.clearTimeout(activationTimeoutTimer)
|
|
|
|
|
|
activationRetryTimer = undefined
|
|
|
|
|
|
activationTimeoutTimer = undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function reloadForUpdate() {
|
2026-08-07 11:59:47 +00:00
|
|
|
|
if (reloading) return
|
|
|
|
|
|
reloading = true
|
2026-07-14 07:37:13 +00:00
|
|
|
|
clearActivationTimers()
|
2026-08-12 19:08:14 +00:00
|
|
|
|
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() {
|
2026-08-07 11:59:47 +00:00
|
|
|
|
try {
|
2026-08-12 19:08:14 +00:00
|
|
|
|
await caches.delete(SHELL_CACHE)
|
2026-08-07 11:59:47 +00:00
|
|
|
|
} catch {
|
2026-08-12 19:08:14 +00:00
|
|
|
|
/* cache API kapalıysa normal yenileme yapılır */
|
2026-08-07 11:59:47 +00:00
|
|
|
|
}
|
2026-08-12 19:08:14 +00:00
|
|
|
|
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: 'Bağlantı bekleniyor. İnternet geldiğinde güncelleme sürdürülecek.',
|
|
|
|
|
|
})
|
|
|
|
|
|
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()
|
2026-07-14 07:37:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
function postSkipWaiting() {
|
|
|
|
|
|
registration?.waiting?.postMessage({ type: 'SKIP_WAITING' })
|
|
|
|
|
|
}
|
2026-07-14 07:37:13 +00:00
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
function activateWaitingWorker() {
|
|
|
|
|
|
if (activating) {
|
2026-08-12 19:08:14 +00:00
|
|
|
|
setState({ visible: true })
|
2026-08-07 11:59:47 +00:00
|
|
|
|
return
|
2026-07-14 07:37:13 +00:00
|
|
|
|
}
|
2026-08-07 11:59:47 +00:00
|
|
|
|
activating = true
|
2026-08-12 19:08:14 +00:00
|
|
|
|
setState({ status: 'activating', visible: true, percent: 100 })
|
2026-07-14 07:37:13 +00:00
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
navigator.serviceWorker.addEventListener('controllerchange', reloadForUpdate, { once: true })
|
|
|
|
|
|
postSkipWaiting()
|
2026-07-14 07:37:13 +00:00
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
// Bazı tarayıcılarda ilk mesaj bekleyen worker'a ulaşmayabiliyor.
|
|
|
|
|
|
activationRetryTimer = window.setTimeout(postSkipWaiting, ACTIVATION_RETRY_DELAY)
|
2026-07-14 07:37:13 +00:00
|
|
|
|
|
|
|
|
|
|
activationTimeoutTimer = window.setTimeout(() => {
|
|
|
|
|
|
clearActivationTimers()
|
2026-08-07 11:59:47 +00:00
|
|
|
|
activating = false
|
2026-07-14 07:37:13 +00:00
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
// Worker waiting'den çıktıysa aktivasyon olmuş, sadece controllerchange
|
|
|
|
|
|
// kaçmış olabilir; yeni precache'i almak için yenile.
|
2026-07-14 07:37:13 +00:00
|
|
|
|
if (!registration?.waiting && registration?.active) {
|
|
|
|
|
|
reloadForUpdate()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
console.warn('Service worker aktivasyonu zaman aşımına uğradı.')
|
2026-08-12 19:08:14 +00:00
|
|
|
|
setState({
|
|
|
|
|
|
status: 'error',
|
|
|
|
|
|
visible: true,
|
|
|
|
|
|
message: 'Geçiş beklenenden uzun sürdü, yeniden deneniyor.',
|
|
|
|
|
|
})
|
|
|
|
|
|
scheduleAutoAction(RETRY_DELAY_SECONDS, () => void retryUpdate())
|
2026-07-14 07:37:13 +00:00
|
|
|
|
}, ACTIVATION_TIMEOUT)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
/* ------------------------------------------------------------------ */
|
2026-08-12 19:08:14 +00:00
|
|
|
|
/* Service worker mesajları (indirme ilerlemesi) */
|
2026-08-07 11:59:47 +00:00
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
type SwFile = {
|
|
|
|
|
|
index?: number
|
|
|
|
|
|
url: string
|
|
|
|
|
|
name: string
|
|
|
|
|
|
loaded: number
|
|
|
|
|
|
total: number
|
|
|
|
|
|
status: string
|
|
|
|
|
|
}
|
2026-08-07 11:59:47 +00:00
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
type SwProgressMessage = {
|
|
|
|
|
|
type?: string
|
|
|
|
|
|
/** Tam liste (START / 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'
|
|
|
|
|
|
}
|
2026-08-07 11:59:47 +00:00
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
const toFiles = (files: SwFile[] | undefined): UpdateFile[] =>
|
|
|
|
|
|
(files ?? []).map((file, position) => ({
|
|
|
|
|
|
index: file.index ?? position,
|
|
|
|
|
|
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' })
|
2026-08-07 11:59:47 +00:00
|
|
|
|
}
|
2026-08-12 19:08:14 +00:00
|
|
|
|
}, NOTICE_VISIBLE_MS)
|
2026-08-07 11:59:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
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'
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
void showUpdateDialog('downloading')
|
|
|
|
|
|
resetRate()
|
|
|
|
|
|
setState({ percent: 0 })
|
|
|
|
|
|
recalculate(toFiles(data.files), totals, {
|
|
|
|
|
|
status: 'downloading',
|
|
|
|
|
|
visible: true,
|
|
|
|
|
|
reusedCount: data.reusedCount ?? 0,
|
|
|
|
|
|
secondsLeft: undefined,
|
|
|
|
|
|
dismissible: !transitions,
|
|
|
|
|
|
})
|
|
|
|
|
|
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')
|
|
|
|
|
|
// `files`: yalnızca eski service worker'lar (tam liste) için.
|
|
|
|
|
|
recalculate(mergeFiles(toFiles(data.updates ?? data.files)), totals, {
|
|
|
|
|
|
status: 'downloading',
|
|
|
|
|
|
visible: true,
|
|
|
|
|
|
dismissible: !transitions,
|
|
|
|
|
|
})
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'SW_PRECACHE_DONE':
|
|
|
|
|
|
recalculate(keepSeq(toFiles(data.files)), totals, {
|
|
|
|
|
|
// Yeni sürüme geçilecekse 'ready'; ısıtmada iş bitti.
|
|
|
|
|
|
status: transitions ? 'ready' : 'completed',
|
|
|
|
|
|
visible: true,
|
|
|
|
|
|
percent: 100,
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!transitions) hideDialogLater()
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'SW_PRECACHE_ERROR':
|
|
|
|
|
|
if (!transitions) {
|
|
|
|
|
|
// Çalışan sürüm etkilenmedi; kullanıcıyı yeniden yükleme döngüsüne sokma.
|
|
|
|
|
|
setState({
|
|
|
|
|
|
status: 'error',
|
|
|
|
|
|
visible: true,
|
|
|
|
|
|
dismissible: true,
|
|
|
|
|
|
message: 'Bileşenler tamamen indirilemedi, gerektikçe yüklenecek.',
|
|
|
|
|
|
})
|
|
|
|
|
|
hideDialogLater()
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
setState({
|
|
|
|
|
|
status: 'error',
|
|
|
|
|
|
visible: true,
|
|
|
|
|
|
message: 'Yeni sürüm indirilemedi, yeniden deneniyor.',
|
|
|
|
|
|
})
|
|
|
|
|
|
scheduleAutoAction(RETRY_DELAY_SECONDS, () => void retryUpdate())
|
|
|
|
|
|
break
|
|
|
|
|
|
default:
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
2026-08-07 11:59:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
/** Kapatılabilir bildirimlerde pencereyi gizler (indirme arka planda sürer). */
|
|
|
|
|
|
export const dismissAppUpdateDialog = () => setState({ visible: false })
|
|
|
|
|
|
|
|
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
|
|
/* Güncelleme penceresi (lazy) */
|
|
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
|
|
let dialogMountRequested = false
|
|
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
/**
|
2026-08-12 19:08:14 +00:00
|
|
|
|
* 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.
|
2026-08-07 11:59:47 +00:00
|
|
|
|
*/
|
2026-08-12 19:08:14 +00:00
|
|
|
|
async function showUpdateDialog(status: AppUpdateStatus) {
|
|
|
|
|
|
setState({ status, visible: true })
|
|
|
|
|
|
if (dialogMountRequested) return
|
|
|
|
|
|
dialogMountRequested = true
|
2026-08-07 11:59:47 +00:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-08-12 19:08:14 +00:00
|
|
|
|
const { mountAppUpdateDialog } = await import('./mountAppUpdateDialog')
|
|
|
|
|
|
await mountAppUpdateDialog()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
dialogMountRequested = false
|
|
|
|
|
|
console.warn('Güncelleme penceresi yüklenemedi.', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
|
|
/* Sürüm kontrolü */
|
|
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
|
|
function applyVersionInfo(info: VersionInfo) {
|
|
|
|
|
|
const release = info.releases[0]
|
|
|
|
|
|
setState({
|
|
|
|
|
|
version: info.version,
|
|
|
|
|
|
changeLog: release?.changeLog?.filter(Boolean) ?? [],
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Sunucudaki build çalışan build'den farklı mı?
|
|
|
|
|
|
*
|
|
|
|
|
|
* Dev'de karşılaştırma yapılmaz: `__APP_BUILD_ID__` vite config yüklenirken
|
|
|
|
|
|
* gömüldüğü için `version.json` yeniden üretildiğinde sunucu yeniden
|
|
|
|
|
|
* başlatılana kadar kalıcı olarak farklı görünür ve sonsuz yenileme oluşur.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const isNewDeploy = (info: VersionInfo | undefined) =>
|
|
|
|
|
|
!!info && import.meta.env.PROD && RUNNING_BUILD_ID !== 'dev' && info.buildId !== RUNNING_BUILD_ID
|
|
|
|
|
|
|
|
|
|
|
|
/** Deploy yoklaması: `version.json` okunur (sürüm + changelog aynı dosyada). */
|
|
|
|
|
|
async function checkVersionEndpoint(force = false) {
|
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
|
if (now - lastVersionCheckAt < (force ? MIN_CHECK_GAP : MIN_VERSION_CHECK_GAP)) return false
|
|
|
|
|
|
lastVersionCheckAt = now
|
|
|
|
|
|
|
|
|
|
|
|
const info = await fetchVersionInfo(force)
|
|
|
|
|
|
if (!info) return false
|
|
|
|
|
|
applyVersionInfo(info)
|
|
|
|
|
|
|
|
|
|
|
|
if (!isNewDeploy(info)) return false
|
|
|
|
|
|
|
|
|
|
|
|
const requireReload = () => {
|
|
|
|
|
|
void showUpdateDialog('reload-required')
|
|
|
|
|
|
|
|
|
|
|
|
// Yenileme sonrası hâlâ aynı build çalışıyorsa (ör. sunucu eski dosyayı
|
|
|
|
|
|
// servis etmeye devam ediyorsa) otomatik yenilemeyi tekrarlamak sonsuz
|
|
|
|
|
|
// döngü demektir; bu durumda karar kullanıcıya bırakılır.
|
|
|
|
|
|
if (readStorage(AUTO_RELOAD_GUARD_KEY, true) === info.buildId) {
|
|
|
|
|
|
setState({
|
|
|
|
|
|
secondsLeft: undefined,
|
|
|
|
|
|
message: 'Yeni sürüme geçilemedi. Yenilemeyi tekrar deneyebilirsiniz.',
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
writeStorage(AUTO_RELOAD_GUARD_KEY, info.buildId, true)
|
|
|
|
|
|
// Kullanıcı onayı beklenmez; kısa bir bilgilendirmeden sonra otomatik geçilir.
|
|
|
|
|
|
scheduleAutoAction(AUTO_RELOAD_SECONDS, () => void reloadWithFreshShell())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isServiceWorkerEnabled && 'serviceWorker' in navigator) {
|
|
|
|
|
|
await runUpdateCheck(true)
|
|
|
|
|
|
// Service worker aynı kabuk dosyalarını görüyorsa (ör. yalnızca lazy chunk
|
|
|
|
|
|
// değişmiş) kurulum tetiklenmez; bu durumda sayfa yenilemesi yeterlidir.
|
|
|
|
|
|
if (state.status === 'idle' || state.status === 'checking') requireReload()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
requireReload()
|
2026-07-14 07:37:13 +00:00
|
|
|
|
}
|
2026-08-07 11:59:47 +00:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function runUpdateCheck(force = false) {
|
|
|
|
|
|
if (!registration || activating || reloading) return
|
|
|
|
|
|
if (!force && !navigator.onLine) return
|
2026-07-14 07:37:13 +00:00
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
const now = Date.now()
|
|
|
|
|
|
if (!force && now - lastCheckAt < MIN_CHECK_GAP) return
|
|
|
|
|
|
lastCheckAt = now
|
2026-07-14 07:37:13 +00:00
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
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)
|
2026-07-14 07:37:13 +00:00
|
|
|
|
}
|
2026-08-07 11:59:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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üyorsa zaten indiriliyor; ikinci bir oturum başlatma.
|
|
|
|
|
|
if (reg.installing) 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
|
|
|
|
|
|
// Controller yoksa bu ilk kurulumdur; kullanıcıya güncelleme demek yanlış olur.
|
|
|
|
|
|
if (!worker || !navigator.serviceWorker.controller || watchedWorkers.has(worker)) return
|
|
|
|
|
|
|
|
|
|
|
|
watchedWorkers.add(worker)
|
|
|
|
|
|
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.')
|
|
|
|
|
|
setState({
|
|
|
|
|
|
status: 'error',
|
|
|
|
|
|
visible: true,
|
|
|
|
|
|
message: 'Yeni sürüm kurulamadı, yeniden deneniyor.',
|
|
|
|
|
|
})
|
|
|
|
|
|
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 checkVersionEndpoint()
|
|
|
|
|
|
}, VERSION_POLL_INTERVAL)
|
2026-08-07 11:59:47 +00:00
|
|
|
|
|
|
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
2026-08-12 19:08:14 +00:00
|
|
|
|
if (document.visibilityState !== 'visible') return
|
|
|
|
|
|
void checkVersionEndpoint()
|
|
|
|
|
|
})
|
|
|
|
|
|
window.addEventListener('online', () => {
|
|
|
|
|
|
// Çevrimdışıyken beklemeye alınan güncelleme bağlantı gelince sürer.
|
|
|
|
|
|
if (state.status === 'error') void retryUpdate()
|
|
|
|
|
|
else void checkVersionEndpoint()
|
2026-07-14 07:37:13 +00:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
2026-08-12 19:08:14 +00:00
|
|
|
|
if (readStorage(CHUNK_RELOAD_GUARD_KEY, true)) return
|
|
|
|
|
|
writeStorage(CHUNK_RELOAD_GUARD_KEY, '1', true)
|
2026-08-07 11:59:47 +00:00
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
console.warn('Eksik chunk tespit edildi, sayfa yenileniyor.')
|
|
|
|
|
|
reloading = true
|
|
|
|
|
|
window.location.reload()
|
2026-07-14 07:37:13 +00:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
// Sayfa sorunsuz açıldıysa koruma bayrağını temizle.
|
2026-08-12 19:08:14 +00:00
|
|
|
|
window.setTimeout(() => writeStorage(CHUNK_RELOAD_GUARD_KEY, undefined, true), 10_000)
|
2026-07-14 07:37:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
/** 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 */
|
|
|
|
|
|
}
|
2026-07-14 07:37:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
|
|
/* Genel API */
|
|
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
|
|
|
2026-02-24 20:44:16 +00:00
|
|
|
|
export const registerServiceWorker = () => {
|
2026-08-12 19:08:14 +00:00
|
|
|
|
// 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
|
2026-08-07 11:59:47 +00:00
|
|
|
|
started = true
|
2026-08-12 19:08:14 +00:00
|
|
|
|
scope.__sozsoftUpdateEngineStarted = true
|
|
|
|
|
|
|
|
|
|
|
|
// Sürüm okuma ve deploy kontrolü service worker'dan bağımsızdır; dev'de de
|
|
|
|
|
|
// (SW kapalıyken) yeni deploy algılanır.
|
|
|
|
|
|
markRunningBuildSeen(RUNNING_VERSION)
|
|
|
|
|
|
|
|
|
|
|
|
// Açılış performansı: sürüm isteği ve SW kaydı ilk boyamayı beklesin.
|
|
|
|
|
|
// Açılışta `force` verilmez; bildirim kancasıyla aynı istek paylaşılır.
|
|
|
|
|
|
const boot = () => {
|
|
|
|
|
|
void checkVersionEndpoint()
|
|
|
|
|
|
startWatchers()
|
|
|
|
|
|
}
|
|
|
|
|
|
const idle = (window as unknown as { requestIdleCallback?: (cb: () => void, o?: object) => void })
|
|
|
|
|
|
.requestIdleCallback
|
|
|
|
|
|
if (idle) idle.call(window, boot, { timeout: 3_000 })
|
|
|
|
|
|
else window.setTimeout(boot, 1_500)
|
|
|
|
|
|
|
|
|
|
|
|
if (!('serviceWorker' in navigator)) return
|
2026-08-07 11:59:47 +00:00
|
|
|
|
|
|
|
|
|
|
handleChunkLoadFailures()
|
2026-07-14 07:37:13 +00:00
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
if (!isServiceWorkerEnabled) {
|
|
|
|
|
|
// Dev'de SW kapalı. Daha önce kaydedilmiş dev worker'ı bırakırsak script'i
|
2026-08-12 19:08:14 +00:00
|
|
|
|
// artık üretilmediği için sunucu index.html döner ve her güncelleme
|
|
|
|
|
|
// kontrolü "unsupported MIME type" hatası verir.
|
2026-08-07 11:59:47 +00:00
|
|
|
|
initialization = unregisterAll()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
navigator.serviceWorker.addEventListener('message', handleWorkerMessage)
|
|
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
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.
|
2026-08-12 19:08:14 +00:00
|
|
|
|
if (reg.waiting && navigator.serviceWorker.controller) activateWaitingWorker()
|
|
|
|
|
|
else scheduleWarmup(reg)
|
2026-08-07 11:59:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
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()
|
|
|
|
|
|
},
|
2026-05-09 07:17:25 +00:00
|
|
|
|
})
|
2026-08-07 11:59:47 +00:00
|
|
|
|
})
|
2026-07-14 07:37:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
/** Kullanıcının menüden tetiklediği manuel kontrol. */
|
2026-07-14 07:37:13 +00:00
|
|
|
|
export const checkForAppUpdate = async (): Promise<ManualUpdateResult> => {
|
2026-08-12 19:08:14 +00:00
|
|
|
|
setState({ status: state.status === 'idle' ? 'checking' : state.status })
|
|
|
|
|
|
|
|
|
|
|
|
const newDeploy = await checkVersionEndpoint(true)
|
|
|
|
|
|
if (newDeploy) return 'updating'
|
|
|
|
|
|
|
2026-07-14 07:37:13 +00:00
|
|
|
|
if (!('serviceWorker' in navigator)) return 'unsupported'
|
2026-08-07 11:59:47 +00:00
|
|
|
|
if (!isServiceWorkerEnabled) return 'disabled'
|
2026-07-14 07:37:13 +00:00
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
await initialization
|
|
|
|
|
|
const reg =
|
|
|
|
|
|
registration ?? (await navigator.serviceWorker.getRegistration().catch(() => undefined))
|
|
|
|
|
|
if (!reg) return 'unsupported'
|
2026-07-14 07:37:13 +00:00
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
registration ??= reg
|
|
|
|
|
|
watchRegistration(reg)
|
|
|
|
|
|
|
|
|
|
|
|
if (reg.waiting && navigator.serviceWorker.controller) {
|
|
|
|
|
|
activateWaitingWorker()
|
|
|
|
|
|
return 'updating'
|
|
|
|
|
|
}
|
2026-07-14 07:37:13 +00:00
|
|
|
|
|
|
|
|
|
|
let updateFound = false
|
|
|
|
|
|
const handleUpdateFound = () => {
|
|
|
|
|
|
updateFound = true
|
|
|
|
|
|
}
|
2026-08-07 11:59:47 +00:00
|
|
|
|
|
|
|
|
|
|
let staleRegistrationCleared = false
|
|
|
|
|
|
reg.addEventListener('updatefound', handleUpdateFound)
|
2026-07-14 07:37:13 +00:00
|
|
|
|
try {
|
2026-08-07 11:59:47 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-07-14 07:37:13 +00:00
|
|
|
|
} finally {
|
2026-08-07 11:59:47 +00:00
|
|
|
|
reg.removeEventListener('updatefound', handleUpdateFound)
|
|
|
|
|
|
lastCheckAt = Date.now()
|
2026-05-09 07:17:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
// Bozuk kayıt temizlendi; bir sonraki açılışta yeni worker kaydedilecek.
|
|
|
|
|
|
if (staleRegistrationCleared) return 'up-to-date'
|
2026-07-14 07:37:13 +00:00
|
|
|
|
|
2026-08-12 19:08:14 +00:00
|
|
|
|
if (!updateFound && !reg.installing && !reg.waiting) {
|
|
|
|
|
|
if (state.status === 'checking') setState({ status: 'idle' })
|
|
|
|
|
|
return 'up-to-date'
|
|
|
|
|
|
}
|
2026-07-14 07:37:13 +00:00
|
|
|
|
|
2026-08-07 11:59:47 +00:00
|
|
|
|
if (reg.waiting && navigator.serviceWorker.controller) activateWaitingWorker()
|
|
|
|
|
|
// `installing` durumundaysa statechange dinleyicisi aynı akışı sürdürür.
|
2026-07-14 07:37:13 +00:00
|
|
|
|
return 'updating'
|
2026-02-24 20:44:16 +00:00
|
|
|
|
}
|