sozsoft-platform/ui/src/views/version/swRegistration.ts

467 lines
15 KiB
TypeScript
Raw Normal View History

2026-02-24 20:44:16 +00:00
import { registerSW } from 'virtual:pwa-register'
2026-07-14 14:16:08 +00:00
import { store } from '@/store'
2026-02-24 20:44:16 +00:00
const ACTIVATION_RETRY_DELAY = 5_000
2026-07-14 14:16:08 +00:00
const ACTIVATION_TIMEOUT = 20_000
let registrationStarted = false
let activationInProgress = false
let reloadStarted = false
let currentRegistration: ServiceWorkerRegistration | undefined
let activateUpdateHandler: ((reloadPage?: boolean) => Promise<void>) | undefined
let registrationInitialization: Promise<void> | undefined
let activationRetryTimer: number | undefined
let activationTimeoutTimer: number | undefined
2026-07-14 14:16:08 +00:00
let overlayStoreUnsubscribe: (() => void) | undefined
const watchedInstallingWorkers = new WeakSet<ServiceWorker>()
export type ManualUpdateResult = 'up-to-date' | 'updating' | 'unsupported'
2026-07-14 14:16:08 +00:00
type Release = {
version: string
changeLog?: string[]
}
const getText = (value: unknown) =>
value === undefined || value === null ? undefined : String(value)
function updateOverlayApiVersion(overlay: HTMLElement) {
const apiConfig = store.getState().abpConfig.config?.extraProperties
const apiEnvironment = getText(apiConfig?.environment)
const apiVersion = getText(apiConfig?.version)
if (!apiEnvironment && !apiVersion) return false
const apiValue = overlay.querySelector<HTMLElement>('[data-sw-api-version]')
if (apiValue) {
apiValue.textContent = `${apiEnvironment ?? '-'}:${apiVersion ?? '-'}`
}
return true
}
function watchOverlayApiVersion(overlay: HTMLElement) {
overlayStoreUnsubscribe?.()
if (updateOverlayApiVersion(overlay)) return
overlayStoreUnsubscribe = store.subscribe(() => {
if (!overlay.isConnected || updateOverlayApiVersion(overlay)) {
overlayStoreUnsubscribe?.()
overlayStoreUnsubscribe = undefined
}
})
}
async function loadUpdateOverlayDetails(overlay: HTMLElement) {
const state = store.getState()
const uiEnvironment = import.meta.env.MODE
let uiVersion = state.locale.currentUiVersion
let latestRelease: Release | undefined
try {
const response = await fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store' })
if (!response.ok) throw new Error(`Version request failed with ${response.status}`)
const releases = ((await response.json()) as { releases?: Release[] }).releases
latestRelease = releases?.[0]
uiVersion = latestRelease?.version ?? uiVersion
} catch (error) {
console.warn('Update details could not be loaded.', error)
}
// The overlay may have been removed while version.json was loading.
if (!overlay.isConnected) return
const uiValue = overlay.querySelector<HTMLElement>('[data-sw-ui-version]')
const releaseTitle = overlay.querySelector<HTMLElement>('[data-sw-release-title]')
const releaseList = overlay.querySelector<HTMLUListElement>('[data-sw-release-list]')
if (uiValue) uiValue.textContent = `${uiEnvironment}:${uiVersion ?? '-'}`
watchOverlayApiVersion(overlay)
if (!releaseList || !releaseTitle) return
const changes = latestRelease?.changeLog?.filter(Boolean) ?? []
releaseTitle.textContent = latestRelease?.version
? `What's new in v${latestRelease.version}`
: "What's new"
releaseList.replaceChildren()
if (changes.length === 0) {
const item = document.createElement('li')
item.className = 'sw-update-empty'
item.textContent = 'Release notes are not available.'
releaseList.appendChild(item)
return
}
changes.forEach((change) => {
const item = document.createElement('li')
const icon = document.createElement('span')
const text = document.createElement('span')
icon.className = 'sw-update-check'
icon.setAttribute('aria-hidden', 'true')
icon.textContent = '\u2713'
text.textContent = change.replace(/^\s*-\s*/, '')
item.append(icon, text)
releaseList.appendChild(item)
})
}
function showUpdateOverlay() {
if (document.getElementById('sw-update-overlay')) return
const style = document.createElement('style')
style.id = 'sw-update-overlay-style'
style.textContent = `
#sw-update-overlay {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
#sw-update-overlay .sw-update-card {
display: flex;
flex-direction: column;
align-items: center;
2026-07-14 14:16:08 +00:00
gap: 16px;
background: #fff;
border-radius: 16px;
2026-07-14 14:16:08 +00:00
padding: 32px 40px;
box-shadow: 0 8px 40px rgba(0,0,0,0.25);
text-align: center;
2026-07-14 14:16:08 +00:00
max-width: 560px;
max-height: min(90vh, 720px);
width: 90%;
}
#sw-update-overlay .sw-update-spinner {
width: 56px;
height: 56px;
border: 5px solid #e5e7eb;
border-top-color: #6366f1;
border-radius: 50%;
animation: sw-spin 0.8s linear infinite;
}
@keyframes sw-spin {
to { transform: rotate(360deg); }
}
#sw-update-overlay .sw-update-title {
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
#sw-update-overlay .sw-update-desc {
font-size: 14px;
color: #6b7280;
margin: 0;
}
2026-07-14 14:16:08 +00:00
#sw-update-overlay .sw-update-versions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 10px 12px;
border-radius: 8px;
background: #f3f4f6;
color: #4b5563;
font-size: 13px;
text-transform: capitalize;
}
#sw-update-overlay .sw-update-separator {
color: #9ca3af;
margin: 0 4px;
}
#sw-update-overlay .sw-update-release {
width: 100%;
min-height: 0;
overflow-y: auto;
text-align: left;
border-top: 1px solid #e5e7eb;
padding-top: 14px;
}
#sw-update-overlay .sw-update-release-title {
margin: 0 0 10px;
color: #374151;
font-size: 14px;
font-weight: 700;
}
#sw-update-overlay .sw-update-list {
display: flex;
flex-direction: column;
gap: 8px;
list-style: none;
padding: 0;
margin: 0;
color: #6b7280;
font-size: 13px;
line-height: 1.45;
}
#sw-update-overlay .sw-update-list li {
display: flex;
align-items: flex-start;
gap: 8px;
}
#sw-update-overlay .sw-update-check {
display: inline-flex;
flex: 0 0 16px;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-top: 1px;
border-radius: 50%;
background: #d1fae5;
color: #059669;
font-size: 11px;
font-weight: 700;
}
#sw-update-overlay .sw-update-empty {
display: block;
color: #9ca3af;
}
@media (max-width: 640px) {
#sw-update-overlay .sw-update-card {
padding: 24px 20px;
}
}
`
document.head.appendChild(style)
const overlay = document.createElement('div')
overlay.id = 'sw-update-overlay'
2026-07-14 14:16:08 +00:00
overlay.setAttribute('role', 'status')
overlay.setAttribute('aria-live', 'polite')
overlay.innerHTML = `
<div class="sw-update-card">
<div class="sw-update-spinner"></div>
2026-07-14 14:16:08 +00:00
<p class="sw-update-title">System Updating</p>
<p class="sw-update-desc">Loading new version, please wait...<br/>The page will reload automatically.</p>
2026-07-14 14:16:08 +00:00
<div class="sw-update-versions">
<span><strong>UI:</strong> <span data-sw-ui-version>Loading...</span></span>
<span class="sw-update-separator">|</span>
<span><strong>API:</strong> <span data-sw-api-version>Loading...</span></span>
</div>
<div class="sw-update-release">
<p class="sw-update-release-title" data-sw-release-title>What's new</p>
<ul class="sw-update-list" data-sw-release-list>
<li class="sw-update-empty">Loading release notes...</li>
</ul>
</div>
</div>
`
document.body.appendChild(overlay)
2026-07-14 14:16:08 +00:00
void loadUpdateOverlayDetails(overlay)
}
function hideUpdateOverlay() {
2026-07-14 14:16:08 +00:00
overlayStoreUnsubscribe?.()
overlayStoreUnsubscribe = undefined
document.getElementById('sw-update-overlay')?.remove()
document.getElementById('sw-update-overlay-style')?.remove()
}
function clearActivationTimers() {
window.clearTimeout(activationRetryTimer)
window.clearTimeout(activationTimeoutTimer)
activationRetryTimer = undefined
activationTimeoutTimer = undefined
}
function reloadForUpdate() {
if (reloadStarted) return
reloadStarted = true
clearActivationTimers()
window.location.reload()
}
function activateWaitingWorker(
registration: ServiceWorkerRegistration | undefined,
activateUpdate: (reloadPage?: boolean) => Promise<void>,
showOverlay: boolean,
) {
if (activationInProgress) {
if (showOverlay) showUpdateOverlay()
return
}
activationInProgress = true
if (showOverlay) {
showUpdateOverlay()
}
// Workbox'ın controlling olayı herhangi bir nedenle kaçarsa native olay da
// yeni worker kontrolü aldığı anda sayfayı yeniler.
navigator.serviceWorker.addEventListener('controllerchange', reloadForUpdate, {
once: true,
})
// Workbox callback sırasına bağlı kalmadan waiting worker'ı doğrudan geçir.
registration?.waiting?.postMessage({ type: 'SKIP_WAITING' })
void activateUpdate(true).catch((error: unknown) => {
console.error('Service worker activation failed.', error)
activationInProgress = false
clearActivationTimers()
hideUpdateOverlay()
})
// Bazı tarayıcılarda Workbox mesajı bekleyen worker'a ulaşmayabiliyor.
// Kısa bir süre sonra native mesajla bir kez daha aktivasyon istenir.
activationRetryTimer = window.setTimeout(() => {
registration?.waiting?.postMessage({ type: 'SKIP_WAITING' })
}, ACTIVATION_RETRY_DELAY)
// Aktivasyon yine de tamamlanamazsa uygulamayı sonsuza kadar kilitleme.
// Eski worker çalışmaya devam eder; bir sonraki açılışta tekrar denenir.
activationTimeoutTimer = window.setTimeout(() => {
activationInProgress = false
clearActivationTimers()
// Worker waiting durumundan çıktıysa aktivasyon gerçekleşmiş fakat
// controllerchange olayı kaçmış olabilir. Yeni precache'i almak için yenile.
if (!registration?.waiting && registration?.active) {
reloadForUpdate()
return
}
hideUpdateOverlay()
console.warn('Service worker activation timed out; continuing with current version.')
}, ACTIVATION_TIMEOUT)
}
function showUpdateWhileInstalling(registration: ServiceWorkerRegistration) {
const installingWorker = registration.installing
if (
!installingWorker ||
!navigator.serviceWorker.controller ||
watchedInstallingWorkers.has(installingWorker)
) {
return
}
watchedInstallingWorkers.add(installingWorker)
showUpdateOverlay()
installingWorker.addEventListener('statechange', () => {
// Precache dosyalarından biri indirilemezse worker redundant olur. Eski
// uygulama çalışmaya devam eder ve kullanıcı kilitli ekranda bırakılmaz.
if (installingWorker.state === 'redundant' && !activationInProgress) {
hideUpdateOverlay()
}
})
}
function watchForUpdate(registration: ServiceWorkerRegistration) {
registration.addEventListener('updatefound', () => {
// Gerçek worker güncellemesi bulunduğu anda, precache indirmesi başlamadan
// Loading'i göster ve aktivasyon/reload tamamlanana kadar açık tut.
showUpdateWhileInstalling(registration)
})
// Listener bağlanmadan hemen önce başlamış bir kurulumu da kaçırma.
showUpdateWhileInstalling(registration)
}
function checkForUpdate(registration: ServiceWorkerRegistration) {
// Bir deploy versiyon numarası/tag değişmeden yapılsa bile sw.js içindeki
// precache manifest'i değişir. Uygulama açılırken yapılan bu tek kontrol
// yeni build'i algılar; açık uygulamada periyodik bir arka plan işi çalışmaz.
void registration.update().catch((error: unknown) => {
console.warn('Service worker update check failed.', error)
})
}
2026-02-24 20:44:16 +00:00
export const registerServiceWorker = () => {
if (registrationStarted || !('serviceWorker' in navigator)) return
registrationStarted = true
registrationInitialization = navigator.serviceWorker
.getRegistration()
.catch(() => undefined)
.then((existingRegistration) => {
if (existingRegistration) {
watchForUpdate(existingRegistration)
2026-05-11 12:47:14 +00:00
}
const activateUpdate = registerSW({
immediate: true,
onRegisteredSW(_swUrl, registration) {
if (!registration) return
if (registration !== existingRegistration) {
watchForUpdate(registration)
}
currentRegistration = registration
checkForUpdate(registration)
// waiting yalnızca aktif worker'dan farklı, kurulumu tamamlanmış gerçek
// bir worker olduğunda bulunur. Onu doğrudan etkinleştir.
if (registration.waiting && navigator.serviceWorker.controller) {
activateWaitingWorker(registration, activateUpdate, true)
}
},
onNeedRefresh() {
// Bu callback worker'ın bütün yeni JS/CSS dosyalarını başarıyla precache'e
// almasından sonra çalışır; aktivasyon ve reload atomik yapılır.
activateWaitingWorker(currentRegistration, activateUpdate, true)
},
onNeedReload() {
reloadForUpdate()
},
onOfflineReady() {
console.log('📦 App offline ready')
},
onRegisterError(error) {
activationInProgress = false
clearActivationTimers()
hideUpdateOverlay()
console.error('Service worker registration failed.', error)
},
})
activateUpdateHandler = activateUpdate
})
}
export const checkForAppUpdate = async (): Promise<ManualUpdateResult> => {
if (!('serviceWorker' in navigator)) return 'unsupported'
// App içindeki normal kayıt işlemi henüz bitmediyse önce onu tamamla.
await registrationInitialization
const registration =
currentRegistration ?? (await navigator.serviceWorker.ready.catch(() => undefined))
if (!registration) return 'unsupported'
let updateFound = false
const handleUpdateFound = () => {
updateFound = true
}
registration.addEventListener('updatefound', handleUpdateFound)
try {
await registration.update()
} finally {
registration.removeEventListener('updatefound', handleUpdateFound)
}
const updateAvailable = Boolean(updateFound || registration.installing || registration.waiting)
if (!updateAvailable) return 'up-to-date'
if (registration.waiting && activateUpdateHandler) {
activateWaitingWorker(registration, activateUpdateHandler, true)
}
// installing durumundaysa gerçek onNeedRefresh callback'i indirme bittikten
// sonra aynı overlay/aktivasyon akışını başlatır.
return 'updating'
2026-02-24 20:44:16 +00:00
}