Claude vite optimize ve check new version
This commit is contained in:
parent
81960df31b
commit
93e337b9b2
9 changed files with 837 additions and 395 deletions
|
|
@ -20586,6 +20586,30 @@
|
|||
"en": "Update check failed.",
|
||||
"tr": "Güncelleme kontrolü başarısız"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.UpdateCheckDisabled",
|
||||
"en": "Update check is disabled",
|
||||
"tr": "Güncelleme kontrolü kapalı"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.UpdateCheckDisabledInThisEnvironment",
|
||||
"en": "The service worker is disabled in this environment, so the version cannot be checked.",
|
||||
"tr": "Bu ortamda service worker devre dışı olduğu için sürüm kontrolü yapılamıyor."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.UpdateCompleted",
|
||||
"en": "The application has been updated",
|
||||
"tr": "Uygulama güncellendi"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.UpdatedToVersion",
|
||||
"en": "New version",
|
||||
"tr": "Yeni sürüm"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.StaticLookup.AbpAccount",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,28 @@
|
|||
{
|
||||
"commit": "8146adf",
|
||||
"commit": "81960df",
|
||||
"releases": [
|
||||
{
|
||||
"version": "1.1.10",
|
||||
"buildDate": "2026-07-31",
|
||||
"commit": "2eef3ead35848341350659828a14dc4a463e57af",
|
||||
"changeLog": [
|
||||
"- Kullanıcılara User Type sütunu eklendi. Hem \"İç Kullanıcılar\" hemde \"Dış Kullanıcılar\" tanımlanabiliyor.",
|
||||
"- Lisans adedi sadece İç Kullanıcılar üzerinden çalışıyor.",
|
||||
"- Scheduler ve TodoBard komponentler geliştirildi.",
|
||||
"- Lookup editorlerin Static değerlere dil desteği eklendi."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.1.09",
|
||||
"buildDate": "2026-07-29",
|
||||
"commit": "b0e78ac619dfebac2eb17edfc04551217ca4b93e",
|
||||
"changeLog": [
|
||||
"- Wizard yetenekleri artırıldı. Menu Order eklendi.",
|
||||
"- GanttView sample düzenlemeleri",
|
||||
"- Schedulerview sample düzenlemeleri",
|
||||
"- CryptographyXml version 10.0.10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.1.08",
|
||||
"buildDate": "2026-07-16",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Helmet } from 'react-helmet'
|
|||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { getSetupStatus } from '@/services/setup.service'
|
||||
import { ROUTES_ENUM } from '@/routes/route.constant'
|
||||
import { useAppVersionNotice } from '@/views/version/useAppVersionNotice'
|
||||
|
||||
let didInit = false
|
||||
|
||||
|
|
@ -62,6 +63,7 @@ const Theme = (props: CommonProps) => {
|
|||
useDarkMode()
|
||||
useTabFocus()
|
||||
useNotification()
|
||||
useAppVersionNotice()
|
||||
|
||||
type ConfigProviderValue = NonNullable<ComponentProps<typeof ConfigProvider>['value']>
|
||||
|
||||
|
|
|
|||
|
|
@ -47,9 +47,6 @@ const validationSchema = Yup.object().shape({
|
|||
const Login = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const UiVersion = useStoreState((state) => state.locale.currentUiVersion)
|
||||
const { setUiVersion } = useStoreActions((a) => a.locale)
|
||||
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useTimeOutMessage(300000)
|
||||
const [twoFactor, setTwoFactor] = useState(false)
|
||||
|
|
@ -152,9 +149,6 @@ const Login = () => {
|
|||
setShowCaptcha(false)
|
||||
setError('')
|
||||
setMessage('')
|
||||
|
||||
// Versiyon kontrolü
|
||||
findUiVersion()
|
||||
}
|
||||
|
||||
if (showCaptcha) {
|
||||
|
|
@ -165,19 +159,6 @@ const Login = () => {
|
|||
setSubmitting(false)
|
||||
}
|
||||
|
||||
const findUiVersion = async () => {
|
||||
try {
|
||||
const res = await fetch(`/version.json?ts=${Date.now()}`)
|
||||
const latest = (await res.json())?.releases?.[0]?.version
|
||||
if (latest && UiVersion !== latest) {
|
||||
setUiVersion(latest)
|
||||
navigate(ROUTES_ENUM.protected.admin.changeLog)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Versiyon okunamadı', e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ export const useManualUpdateCheck = () => {
|
|||
const { translate } = useLocalization()
|
||||
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
|
||||
|
||||
// translate() eksik anahtarda anahtarın kendisini döndürüyor; kullanıcıya
|
||||
// "::App.X" göstermemek için okunabilir bir karşılığa düş.
|
||||
const label = (key: string, fallback: string) => {
|
||||
const value = translate(key)
|
||||
return !value || value === key || value.includes('::') ? fallback : value
|
||||
}
|
||||
|
||||
const checkForUpdate = async () => {
|
||||
if (isCheckingUpdate) return
|
||||
setIsCheckingUpdate(true)
|
||||
|
|
@ -34,6 +41,19 @@ export const useManualUpdateCheck = () => {
|
|||
</Notification>,
|
||||
{ placement: 'bottom-end' },
|
||||
)
|
||||
} else if (result === 'disabled') {
|
||||
toast.push(
|
||||
<Notification
|
||||
title={label('::App.UpdateCheckDisabled', 'Güncelleme kontrolü kapalı')}
|
||||
type="info"
|
||||
>
|
||||
{label(
|
||||
'::App.UpdateCheckDisabledInThisEnvironment',
|
||||
'Bu ortamda service worker devre dışı olduğu için sürüm kontrolü yapılamıyor.',
|
||||
)}
|
||||
</Notification>,
|
||||
{ placement: 'bottom-end' },
|
||||
)
|
||||
} else if (result === 'unsupported') {
|
||||
toast.push(
|
||||
<Notification title={translate('::App.UpdateCouldNotBeChecked')} type="warning">
|
||||
|
|
|
|||
|
|
@ -1,122 +1,132 @@
|
|||
import { registerSW } from 'virtual:pwa-register'
|
||||
import { store } from '@/store'
|
||||
|
||||
const ACTIVATION_RETRY_DELAY = 5_000
|
||||
const ACTIVATION_TIMEOUT = 20_000
|
||||
/**
|
||||
* Service worker güncelleme akışı
|
||||
* ------------------------------------------------------------------
|
||||
* 1. Kayıt + açılışta bir kez `update()` kontrolü.
|
||||
* 2. Sekme görünürken periyodik, sekmeye dönüldüğünde ve internet geri
|
||||
* geldiğinde tekrar kontrol -> yeni deploy açık uygulamada da yakalanır.
|
||||
* 3. Yeni worker kurulmaya başlayınca (`installing`) engellemeyen bir bant
|
||||
* ile "yeni sürüm indiriliyor" bilgisi gösterilir.
|
||||
* 4. Kurulum bitince (`installed`) tam ekran overlay + sürüm notları gösterilir,
|
||||
* SKIP_WAITING gönderilir ve `controllerchange` ile sayfa yenilenir.
|
||||
* 5. Yenileme sonrası `consumeAppUpdatedVersion()` ile "güncellendi" bildirimi
|
||||
* ve changelog yönlendirmesi yapılır.
|
||||
*/
|
||||
|
||||
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
|
||||
let overlayStoreUnsubscribe: (() => void) | undefined
|
||||
const watchedInstallingWorkers = new WeakSet<ServiceWorker>()
|
||||
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 ManualUpdateResult = 'up-to-date' | 'updating' | 'unsupported'
|
||||
const PERIODIC_CHECK_INTERVAL = 15 * 60 * 1000
|
||||
const MIN_CHECK_GAP = 60 * 1000
|
||||
const ACTIVATION_RETRY_DELAY = 8_000
|
||||
const ACTIVATION_TIMEOUT = 30_000
|
||||
// `virtual:pwa-register` dev'de (devOptions kapalıyken) no-op'tur ve hiçbir
|
||||
// callback çalışmaz. Kayıt promise'inin süresiz beklememesi için üst sınır.
|
||||
const REGISTRATION_TIMEOUT = 10_000
|
||||
// `registration.update()` yavaş/kopuk ağda süresiz askıda kalabiliyor.
|
||||
const UPDATE_CALL_TIMEOUT = 20_000
|
||||
|
||||
const withTimeout = <T>(promise: Promise<T>, ms: number) =>
|
||||
Promise.race([
|
||||
promise,
|
||||
new Promise<undefined>((resolve) => window.setTimeout(() => resolve(undefined), ms)),
|
||||
])
|
||||
|
||||
const UPDATED_VERSION_KEY = 'sozsoft:app-updated-version'
|
||||
const CHUNK_RELOAD_GUARD_KEY = 'sozsoft:chunk-reload'
|
||||
|
||||
type Release = {
|
||||
version: string
|
||||
changeLog?: string[]
|
||||
}
|
||||
|
||||
const getText = (value: unknown) =>
|
||||
value === undefined || value === null ? undefined : String(value)
|
||||
let started = false
|
||||
let registration: ServiceWorkerRegistration | undefined
|
||||
let initialization: Promise<void> | undefined
|
||||
let activating = false
|
||||
let reloading = false
|
||||
let lastCheckAt = 0
|
||||
let periodicTimer: number | undefined
|
||||
let activationRetryTimer: number | undefined
|
||||
let activationTimeoutTimer: number | undefined
|
||||
let storeUnsubscribe: (() => void) | undefined
|
||||
let latestVersion: string | undefined
|
||||
let releasePromise: Promise<Release | undefined> | undefined
|
||||
|
||||
function updateOverlayApiVersion(overlay: HTMLElement) {
|
||||
const apiConfig = store.getState().abpConfig.config?.extraProperties
|
||||
const apiEnvironment = getText(apiConfig?.environment)
|
||||
const apiVersion = getText(apiConfig?.version)
|
||||
const watchedRegistrations = new WeakSet<ServiceWorkerRegistration>()
|
||||
const watchedWorkers = new WeakSet<ServiceWorker>()
|
||||
|
||||
if (!apiEnvironment && !apiVersion) return false
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sürüm bilgisi */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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
|
||||
const loadLatestRelease = () =>
|
||||
(releasePromise ??= 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() as Promise<{ releases?: Release[] }>
|
||||
})
|
||||
.then((data) => {
|
||||
const release = data.releases?.[0]
|
||||
latestVersion = release?.version ?? latestVersion
|
||||
return release
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn('Sürüm bilgisi okunamadı.', error)
|
||||
releasePromise = undefined
|
||||
return undefined
|
||||
}))
|
||||
|
||||
/** Yenileme sonrası bir kez okunur; yeni sürüm numarasını döner. */
|
||||
export const consumeAppUpdatedVersion = (): string | 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)
|
||||
const value = sessionStorage.getItem(UPDATED_VERSION_KEY)
|
||||
if (value) sessionStorage.removeItem(UPDATED_VERSION_KEY)
|
||||
return value ?? undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 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
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Arayüz */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STYLE_ID = 'sw-update-style'
|
||||
const BANNER_ID = 'sw-update-banner'
|
||||
const OVERLAY_ID = 'sw-update-overlay'
|
||||
|
||||
function ensureStyles() {
|
||||
if (document.getElementById(STYLE_ID)) return
|
||||
|
||||
const style = document.createElement('style')
|
||||
style.id = 'sw-update-overlay-style'
|
||||
style.id = STYLE_ID
|
||||
style.textContent = `
|
||||
#sw-update-overlay {
|
||||
#${BANNER_ID} {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 99998;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 320px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
background: #111827;
|
||||
color: #f9fafb;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
}
|
||||
#${OVERLAY_ID} {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99999;
|
||||
|
|
@ -127,7 +137,7 @@ function showUpdateOverlay() {
|
|||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
#sw-update-overlay .sw-update-card {
|
||||
.sw-update-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
|
@ -141,7 +151,8 @@ function showUpdateOverlay() {
|
|||
max-height: min(90vh, 720px);
|
||||
width: 90%;
|
||||
}
|
||||
#sw-update-overlay .sw-update-spinner {
|
||||
.sw-update-spinner {
|
||||
flex: 0 0 auto;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: 5px solid #e5e7eb;
|
||||
|
|
@ -149,21 +160,17 @@ function showUpdateOverlay() {
|
|||
border-radius: 50%;
|
||||
animation: sw-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes sw-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
#${BANNER_ID} .sw-update-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-width: 3px;
|
||||
border-color: rgba(255,255,255,0.25);
|
||||
border-top-color: #a5b4fc;
|
||||
}
|
||||
#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;
|
||||
}
|
||||
#sw-update-overlay .sw-update-versions {
|
||||
@keyframes sw-spin { to { transform: rotate(360deg); } }
|
||||
.sw-update-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
|
||||
.sw-update-desc { font-size: 14px; color: #6b7280; margin: 0; }
|
||||
.sw-update-versions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
|
@ -175,13 +182,9 @@ function showUpdateOverlay() {
|
|||
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 {
|
||||
.sw-update-separator { color: #9ca3af; margin: 0 4px; }
|
||||
.sw-update-release {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
|
|
@ -189,13 +192,8 @@ function showUpdateOverlay() {
|
|||
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 {
|
||||
.sw-update-release-title { margin: 0 0 10px; color: #374151; font-size: 14px; font-weight: 700; }
|
||||
.sw-update-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
|
@ -206,12 +204,8 @@ function showUpdateOverlay() {
|
|||
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 {
|
||||
.sw-update-list li { display: flex; align-items: flex-start; gap: 8px; }
|
||||
.sw-update-check {
|
||||
display: inline-flex;
|
||||
flex: 0 0 16px;
|
||||
align-items: center;
|
||||
|
|
@ -225,51 +219,189 @@ function showUpdateOverlay() {
|
|||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
#sw-update-overlay .sw-update-empty {
|
||||
display: block;
|
||||
color: #9ca3af;
|
||||
.sw-update-empty { display: block; color: #9ca3af; }
|
||||
.sw-update-action {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sw-update-action:hover { background: #4338ca; }
|
||||
@media (max-width: 640px) {
|
||||
#sw-update-overlay .sw-update-card {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
.sw-update-card { padding: 24px 20px; }
|
||||
#${BANNER_ID} { left: 16px; right: 16px; max-width: none; }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
function removeStyles() {
|
||||
document.getElementById(STYLE_ID)?.remove()
|
||||
}
|
||||
|
||||
/** İndirme sürerken kullanıcıyı engellemeyen bilgilendirme. */
|
||||
function showDownloadBanner() {
|
||||
if (document.getElementById(BANNER_ID) || document.getElementById(OVERLAY_ID)) return
|
||||
ensureStyles()
|
||||
|
||||
const banner = document.createElement('div')
|
||||
banner.id = BANNER_ID
|
||||
banner.setAttribute('role', 'status')
|
||||
banner.setAttribute('aria-live', 'polite')
|
||||
|
||||
const spinner = document.createElement('div')
|
||||
spinner.className = 'sw-update-spinner'
|
||||
|
||||
const text = document.createElement('span')
|
||||
text.textContent = 'Yeni sürüm indiriliyor, lütfen sekmeyi kapatmayın...'
|
||||
|
||||
banner.append(spinner, text)
|
||||
document.body.appendChild(banner)
|
||||
}
|
||||
|
||||
function removeDownloadBanner() {
|
||||
document.getElementById(BANNER_ID)?.remove()
|
||||
}
|
||||
|
||||
function getText(value: unknown) {
|
||||
return value === undefined || value === null ? undefined : String(value)
|
||||
}
|
||||
|
||||
function applyApiVersion(overlay: HTMLElement) {
|
||||
const apiConfig = store.getState().abpConfig.config?.extraProperties
|
||||
const environment = getText(apiConfig?.environment)
|
||||
const version = getText(apiConfig?.version)
|
||||
if (!environment && !version) return false
|
||||
|
||||
const target = overlay.querySelector<HTMLElement>('[data-sw-api-version]')
|
||||
if (target) target.textContent = `${environment ?? '-'}:${version ?? '-'}`
|
||||
return true
|
||||
}
|
||||
|
||||
function watchApiVersion(overlay: HTMLElement) {
|
||||
storeUnsubscribe?.()
|
||||
storeUnsubscribe = undefined
|
||||
if (applyApiVersion(overlay)) return
|
||||
|
||||
storeUnsubscribe = store.subscribe(() => {
|
||||
if (!overlay.isConnected || applyApiVersion(overlay)) {
|
||||
storeUnsubscribe?.()
|
||||
storeUnsubscribe = undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function fillOverlayDetails(overlay: HTMLElement) {
|
||||
const release = await loadLatestRelease()
|
||||
if (!overlay.isConnected) return
|
||||
|
||||
const uiVersion = release?.version ?? store.getState().locale.currentUiVersion
|
||||
const uiValue = overlay.querySelector<HTMLElement>('[data-sw-ui-version]')
|
||||
if (uiValue) uiValue.textContent = `${import.meta.env.MODE}:${uiVersion ?? '-'}`
|
||||
watchApiVersion(overlay)
|
||||
|
||||
const releaseTitle = overlay.querySelector<HTMLElement>('[data-sw-release-title]')
|
||||
const releaseList = overlay.querySelector<HTMLUListElement>('[data-sw-release-list]')
|
||||
if (!releaseTitle || !releaseList) return
|
||||
|
||||
releaseTitle.textContent = release?.version
|
||||
? `v${release.version} ile gelen yenilikler`
|
||||
: 'Sürüm notları'
|
||||
releaseList.replaceChildren()
|
||||
|
||||
const changes = release?.changeLog?.filter(Boolean) ?? []
|
||||
if (changes.length === 0) {
|
||||
const item = document.createElement('li')
|
||||
item.className = 'sw-update-empty'
|
||||
item.textContent = 'Bu sürüm için not bulunamadı.'
|
||||
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 = '✓'
|
||||
text.textContent = change.replace(/^\s*-\s*/, '')
|
||||
item.append(icon, text)
|
||||
releaseList.appendChild(item)
|
||||
})
|
||||
}
|
||||
|
||||
function showActivationOverlay() {
|
||||
removeDownloadBanner()
|
||||
if (document.getElementById(OVERLAY_ID)) return
|
||||
ensureStyles()
|
||||
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'sw-update-overlay'
|
||||
overlay.id = OVERLAY_ID
|
||||
overlay.setAttribute('role', 'status')
|
||||
overlay.setAttribute('aria-live', 'polite')
|
||||
overlay.innerHTML = `
|
||||
<div class="sw-update-card">
|
||||
<div class="sw-update-spinner"></div>
|
||||
<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>
|
||||
<p class="sw-update-title" data-sw-title>Sistem güncelleniyor</p>
|
||||
<p class="sw-update-desc" data-sw-desc>Yeni sürüm yükleniyor, lütfen bekleyin...<br/>Sayfa otomatik olarak yenilenecek.</p>
|
||||
<div class="sw-update-versions">
|
||||
<span><strong>UI:</strong> <span data-sw-ui-version>Loading...</span></span>
|
||||
<span><strong>UI:</strong> <span data-sw-ui-version>Yükleniyor...</span></span>
|
||||
<span class="sw-update-separator">|</span>
|
||||
<span><strong>API:</strong> <span data-sw-api-version>Loading...</span></span>
|
||||
<span><strong>API:</strong> <span data-sw-api-version>Yükleniyor...</span></span>
|
||||
</div>
|
||||
<div class="sw-update-release">
|
||||
<p class="sw-update-release-title" data-sw-release-title>What's new</p>
|
||||
<p class="sw-update-release-title" data-sw-release-title>Sürüm notları</p>
|
||||
<ul class="sw-update-list" data-sw-release-list>
|
||||
<li class="sw-update-empty">Loading release notes...</li>
|
||||
<li class="sw-update-empty">Sürüm notları yükleniyor...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(overlay)
|
||||
void loadUpdateOverlayDetails(overlay)
|
||||
void fillOverlayDetails(overlay)
|
||||
}
|
||||
|
||||
function hideUpdateOverlay() {
|
||||
overlayStoreUnsubscribe?.()
|
||||
overlayStoreUnsubscribe = undefined
|
||||
document.getElementById('sw-update-overlay')?.remove()
|
||||
document.getElementById('sw-update-overlay-style')?.remove()
|
||||
/** Aktivasyon tamamlanamazsa kullanıcıyı belirsizlikte bırakma. */
|
||||
function showManualReloadPrompt() {
|
||||
const overlay = document.getElementById(OVERLAY_ID)
|
||||
if (!overlay) return
|
||||
|
||||
overlay.querySelector<HTMLElement>('.sw-update-spinner')?.remove()
|
||||
|
||||
const title = overlay.querySelector<HTMLElement>('[data-sw-title]')
|
||||
const desc = overlay.querySelector<HTMLElement>('[data-sw-desc]')
|
||||
if (title) title.textContent = 'Güncelleme tamamlanamadı'
|
||||
if (desc) {
|
||||
desc.textContent = 'Yeni sürüme geçmek için sayfayı yenileyin.'
|
||||
}
|
||||
|
||||
if (overlay.querySelector('.sw-update-action')) return
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'
|
||||
button.className = 'sw-update-action'
|
||||
button.textContent = 'Sayfayı yenile'
|
||||
button.addEventListener('click', () => reloadForUpdate())
|
||||
overlay.querySelector('.sw-update-card')?.appendChild(button)
|
||||
}
|
||||
|
||||
function teardownUpdateUi() {
|
||||
storeUnsubscribe?.()
|
||||
storeUnsubscribe = undefined
|
||||
removeDownloadBanner()
|
||||
document.getElementById(OVERLAY_ID)?.remove()
|
||||
removeStyles()
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Aktivasyon */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function clearActivationTimers() {
|
||||
window.clearTimeout(activationRetryTimer)
|
||||
window.clearTimeout(activationTimeoutTimer)
|
||||
|
|
@ -278,189 +410,283 @@ function clearActivationTimers() {
|
|||
}
|
||||
|
||||
function reloadForUpdate() {
|
||||
if (reloadStarted) return
|
||||
reloadStarted = true
|
||||
if (reloading) return
|
||||
reloading = true
|
||||
clearActivationTimers()
|
||||
try {
|
||||
if (latestVersion) sessionStorage.setItem(UPDATED_VERSION_KEY, latestVersion)
|
||||
} catch {
|
||||
/* sessionStorage kullanılamıyorsa bildirim atlanır */
|
||||
}
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
function activateWaitingWorker(
|
||||
registration: ServiceWorkerRegistration | undefined,
|
||||
activateUpdate: (reloadPage?: boolean) => Promise<void>,
|
||||
showOverlay: boolean,
|
||||
) {
|
||||
if (activationInProgress) {
|
||||
if (showOverlay) showUpdateOverlay()
|
||||
function postSkipWaiting() {
|
||||
registration?.waiting?.postMessage({ type: 'SKIP_WAITING' })
|
||||
}
|
||||
|
||||
function activateWaitingWorker() {
|
||||
if (activating) {
|
||||
showActivationOverlay()
|
||||
return
|
||||
}
|
||||
activationInProgress = true
|
||||
activating = true
|
||||
showActivationOverlay()
|
||||
// Sürüm numarasını yenileme öncesinde hazır tut.
|
||||
void loadLatestRelease()
|
||||
|
||||
if (showOverlay) {
|
||||
showUpdateOverlay()
|
||||
}
|
||||
navigator.serviceWorker.addEventListener('controllerchange', reloadForUpdate, { once: true })
|
||||
postSkipWaiting()
|
||||
|
||||
// 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,
|
||||
})
|
||||
// Bazı tarayıcılarda ilk mesaj bekleyen worker'a ulaşmayabiliyor.
|
||||
activationRetryTimer = window.setTimeout(postSkipWaiting, ACTIVATION_RETRY_DELAY)
|
||||
|
||||
// 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()
|
||||
activating = false
|
||||
|
||||
// Worker waiting durumundan çıktıysa aktivasyon gerçekleşmiş fakat
|
||||
// controllerchange olayı kaçmış olabilir. Yeni precache'i almak için yenile.
|
||||
// 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
|
||||
}
|
||||
|
||||
hideUpdateOverlay()
|
||||
console.warn('Service worker activation timed out; continuing with current version.')
|
||||
console.warn('Service worker aktivasyonu zaman aşımına uğradı.')
|
||||
showManualReloadPrompt()
|
||||
}, ACTIVATION_TIMEOUT)
|
||||
}
|
||||
|
||||
function showUpdateWhileInstalling(registration: ServiceWorkerRegistration) {
|
||||
const installingWorker = registration.installing
|
||||
if (
|
||||
!installingWorker ||
|
||||
!navigator.serviceWorker.controller ||
|
||||
watchedInstallingWorkers.has(installingWorker)
|
||||
) {
|
||||
return
|
||||
}
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Güncelleme takibi */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
watchedInstallingWorkers.add(installingWorker)
|
||||
showUpdateOverlay()
|
||||
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
|
||||
|
||||
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()
|
||||
watchedWorkers.add(worker)
|
||||
showDownloadBanner()
|
||||
|
||||
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ı, mevcut sürümle devam ediliyor.')
|
||||
teardownUpdateUi()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 watchRegistration(reg: ServiceWorkerRegistration) {
|
||||
if (watchedRegistrations.has(reg)) return
|
||||
watchedRegistrations.add(reg)
|
||||
reg.addEventListener('updatefound', () => trackInstallingWorker(reg))
|
||||
trackInstallingWorker(reg)
|
||||
}
|
||||
|
||||
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)
|
||||
/**
|
||||
* 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 durumda eski 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startUpdateWatchers() {
|
||||
window.clearInterval(periodicTimer)
|
||||
periodicTimer = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') void runUpdateCheck()
|
||||
}, PERIODIC_CHECK_INTERVAL)
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') void runUpdateCheck()
|
||||
})
|
||||
window.addEventListener('online', () => 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
|
||||
try {
|
||||
if (sessionStorage.getItem(CHUNK_RELOAD_GUARD_KEY)) return
|
||||
sessionStorage.setItem(CHUNK_RELOAD_GUARD_KEY, '1')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
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(() => {
|
||||
try {
|
||||
sessionStorage.removeItem(CHUNK_RELOAD_GUARD_KEY)
|
||||
} catch {
|
||||
/* yoksay */
|
||||
}
|
||||
}, 10_000)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Genel API */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 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'
|
||||
|
||||
/** 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 */
|
||||
}
|
||||
}
|
||||
|
||||
export const registerServiceWorker = () => {
|
||||
if (registrationStarted || !('serviceWorker' in navigator)) return
|
||||
registrationStarted = true
|
||||
if (started || !('serviceWorker' in navigator)) return
|
||||
started = true
|
||||
|
||||
registrationInitialization = navigator.serviceWorker
|
||||
.getRegistration()
|
||||
.catch(() => undefined)
|
||||
.then((existingRegistration) => {
|
||||
if (existingRegistration) {
|
||||
watchForUpdate(existingRegistration)
|
||||
}
|
||||
handleChunkLoadFailures()
|
||||
|
||||
const activateUpdate = registerSW({
|
||||
immediate: true,
|
||||
onRegisteredSW(_swUrl, registration) {
|
||||
if (!registration) return
|
||||
if (!isServiceWorkerEnabled) {
|
||||
// Dev'de SW kapalı. Daha önce kaydedilmiş dev worker'ı bırakırsak script'i
|
||||
// (/dev-sw.js) artık üretilmediği için sunucu index.html döner ve her
|
||||
// güncelleme kontrolü "unsupported MIME type" hatası verir.
|
||||
initialization = unregisterAll()
|
||||
return
|
||||
}
|
||||
|
||||
if (registration !== existingRegistration) {
|
||||
watchForUpdate(registration)
|
||||
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)
|
||||
startUpdateWatchers()
|
||||
|
||||
// Kurulumu tamamlanmış, aktif olandan farklı bir worker bekliyorsa
|
||||
// doğrudan devreye al.
|
||||
if (reg.waiting && navigator.serviceWorker.controller) {
|
||||
activateWaitingWorker()
|
||||
} else {
|
||||
void runUpdateCheck(true)
|
||||
}
|
||||
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
|
||||
}
|
||||
settle()
|
||||
},
|
||||
onNeedRefresh() {
|
||||
// Yeni worker tüm kabuk dosyalarını precache'e aldıktan sonra çalışır.
|
||||
activateWaitingWorker()
|
||||
},
|
||||
onRegisterError(error) {
|
||||
activating = false
|
||||
clearActivationTimers()
|
||||
teardownUpdateUi()
|
||||
console.error('Service worker kaydı başarısız.', error)
|
||||
settle()
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const checkForAppUpdate = async (): Promise<ManualUpdateResult> => {
|
||||
if (!('serviceWorker' in navigator)) return 'unsupported'
|
||||
if (!isServiceWorkerEnabled) return 'disabled'
|
||||
|
||||
// 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))
|
||||
await initialization
|
||||
// `navigator.serviceWorker.ready` kayıt yokken (ör. dev ortamı) hiçbir zaman
|
||||
// resolve etmez; getRegistration() kayıt yoksa undefined döner.
|
||||
const reg =
|
||||
registration ?? (await navigator.serviceWorker.getRegistration().catch(() => undefined))
|
||||
if (!reg) return 'unsupported'
|
||||
|
||||
if (!registration) return 'unsupported'
|
||||
registration ??= reg
|
||||
watchRegistration(reg)
|
||||
|
||||
if (reg.waiting && navigator.serviceWorker.controller) {
|
||||
activateWaitingWorker()
|
||||
return 'updating'
|
||||
}
|
||||
|
||||
let updateFound = false
|
||||
const handleUpdateFound = () => {
|
||||
updateFound = true
|
||||
}
|
||||
registration.addEventListener('updatefound', handleUpdateFound)
|
||||
|
||||
let staleRegistrationCleared = false
|
||||
reg.addEventListener('updatefound', handleUpdateFound)
|
||||
try {
|
||||
await registration.update()
|
||||
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 {
|
||||
registration.removeEventListener('updatefound', handleUpdateFound)
|
||||
reg.removeEventListener('updatefound', handleUpdateFound)
|
||||
lastCheckAt = Date.now()
|
||||
}
|
||||
|
||||
const updateAvailable = Boolean(updateFound || registration.installing || registration.waiting)
|
||||
if (!updateAvailable) return 'up-to-date'
|
||||
// Bozuk kayıt temizlendi; bir sonraki açılışta yeni worker kaydedilecek.
|
||||
if (staleRegistrationCleared) return 'up-to-date'
|
||||
|
||||
if (registration.waiting && activateUpdateHandler) {
|
||||
activateWaitingWorker(registration, activateUpdateHandler, true)
|
||||
}
|
||||
if (!updateFound && !reg.installing && !reg.waiting) return 'up-to-date'
|
||||
|
||||
// installing durumundaysa gerçek onNeedRefresh callback'i indirme bittikten
|
||||
// sonra aynı overlay/aktivasyon akışını başlatır.
|
||||
if (reg.waiting && navigator.serviceWorker.controller) activateWaitingWorker()
|
||||
// `installing` durumundaysa statechange dinleyicisi aynı akışı sürdürür.
|
||||
return 'updating'
|
||||
}
|
||||
|
|
|
|||
77
ui/src/views/version/useAppVersionNotice.tsx
Normal file
77
ui/src/views/version/useAppVersionNotice.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ROUTES_ENUM } from '@/routes/route.constant'
|
||||
import { useStoreActions } from '@/store'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { consumeAppUpdatedVersion } from './swRegistration'
|
||||
|
||||
/**
|
||||
* Açılışta çalışan tek sürüm kaynağı:
|
||||
* - `version.json`'daki güncel sürümü store'a yazar (footer/changelog bunu okur).
|
||||
* - Service worker güncellemesi sonrası yenilenen sayfada "güncellendi"
|
||||
* bildirimini gösterip changelog'a yönlendirme sunar.
|
||||
*
|
||||
* Daha önce bu işi Login ekranı yapıyordu; store'daki başlangıç sürümü
|
||||
* `undefined` olduğu için her girişte changelog'a zorla yönlendiriyordu.
|
||||
*/
|
||||
export const useAppVersionNotice = () => {
|
||||
const navigate = useNavigate()
|
||||
const { translate } = useLocalization()
|
||||
const setUiVersion = useStoreActions((actions) => actions.locale.setUiVersion)
|
||||
const handled = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (handled.current) return
|
||||
handled.current = true
|
||||
|
||||
const label = (key: string, fallback: string) => {
|
||||
const value = translate(key)
|
||||
return !value || value === key || value.includes('::') ? fallback : value
|
||||
}
|
||||
|
||||
const updatedVersion = consumeAppUpdatedVersion()
|
||||
|
||||
const controller = new AbortController()
|
||||
fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store', signal: controller.signal })
|
||||
.then((response) => (response.ok ? response.json() : undefined))
|
||||
.then(async (data?: { releases?: { version: string }[] }) => {
|
||||
const latest = data?.releases?.[0]?.version
|
||||
if (latest) setUiVersion(latest)
|
||||
|
||||
if (!updatedVersion) return
|
||||
|
||||
// toast/Notification (ve bağımlısı framer-motion) yalnızca güncelleme
|
||||
// sonrası bu tek seferlik bildirimde gerekiyor; entry chunk'ta taşınmaz.
|
||||
const [{ default: toast }, { default: Notification }] = await Promise.all([
|
||||
import('@/components/ui/toast'),
|
||||
import('@/components/ui/Notification'),
|
||||
])
|
||||
|
||||
toast.push(
|
||||
<Notification
|
||||
closable
|
||||
duration={10000}
|
||||
title={label('::App.UpdateCompleted', 'Uygulama güncellendi')}
|
||||
type="success"
|
||||
>
|
||||
{label('::App.UpdatedToVersion', 'Yeni sürüm')} v{updatedVersion} —{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => navigate(ROUTES_ENUM.protected.admin.changeLog)}
|
||||
>
|
||||
{label('::App.ChangeLog', 'Değişiklikleri gör')}
|
||||
</button>
|
||||
</Notification>,
|
||||
{ placement: 'bottom-end' },
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
/* sürüm bilgisi okunamadıysa sessiz geç */
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
}
|
||||
|
||||
export default useAppVersionNotice
|
||||
3
ui/src/vite-env.d.ts
vendored
3
ui/src/vite-env.d.ts
vendored
|
|
@ -7,6 +7,9 @@ interface ImportMetaEnv {
|
|||
readonly VITE_REACT_APP_VERSION: string
|
||||
readonly VITE_AI_URL: string
|
||||
readonly VITE_GOOGLE_MAPS_API_KEY: string
|
||||
readonly VITE_USE_POLLING?: string
|
||||
/** Dev sunucusunda service worker'ı açar (varsayılan kapalı). */
|
||||
readonly VITE_PWA_DEV?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
|
|
|||
|
|
@ -2,16 +2,47 @@ import { defineConfig, loadEnv } from 'vite'
|
|||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
// import { fetchTenantNames } from './src/services/tenants'
|
||||
|
||||
export default defineConfig(async ({ mode }) => {
|
||||
/**
|
||||
* Precache stratejisi
|
||||
* ------------------------------------------------------------------
|
||||
* `dist` klasörü ~55 MB (48 adet DevExtreme teması ~33 MB + lazy chunk'lar
|
||||
* ~20 MB). Bunların tamamını precache etmek her deploy'da service worker'ın
|
||||
* on-larca MB indirmesine, mobil/yavaş bağlantıda kurulumun timeout'a düşüp
|
||||
* worker'ın `redundant` olmasına ve güncellemenin hiç tamamlanmamasına yol
|
||||
* açıyordu.
|
||||
*
|
||||
* Bu yüzden precache SADECE uygulama kabuğudur (index.html + entry JS + CSS,
|
||||
* ~2.8 MB). Geri kalan her şey (lazy chunk, tema CSS'i, görsel, font) runtime
|
||||
* cache ile ilk kullanımda saklanır. Dosya adları hash'li olduğu için
|
||||
* CacheFirst güvenlidir; hash'siz public dosyalarda StaleWhileRevalidate
|
||||
* kullanılır.
|
||||
*/
|
||||
const PRECACHE_GLOBS = [
|
||||
'index.html',
|
||||
// manifest.webmanifest ve manifest ikonları vite-plugin-pwa tarafından
|
||||
// otomatik eklenir; buraya yazmak workbox'ta "conflicting entries" hatası verir.
|
||||
'favicon.ico',
|
||||
'assets/js/entry-*.js',
|
||||
// Sadece entry CSS'i. Rapor tasarımcısı/görüntüleyici CSS'leri (~580 KB)
|
||||
// lazy yüklendiği için runtime cache'e bırakılır.
|
||||
'assets/css/index-*.css',
|
||||
]
|
||||
|
||||
// Deploy'da silinen eski build dosyalarını precache manifestine sokmamak için
|
||||
// runtime cache'e alınacak hash'li uygulama varlıkları.
|
||||
const HASHED_ASSET_PATTERN = /\/assets\/.+\.(?:js|css|wasm)$/
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const apiUrl = env.VITE_API_URL
|
||||
const baseDomains = ['sozsoft.com', 'dev.sozsoft.com', '.sozsoft.com']
|
||||
const tenantHosts: string[] = []
|
||||
const isProduction = mode === 'production'
|
||||
const usePolling = env.VITE_USE_POLLING === 'true'
|
||||
// Dev'de service worker varsayılan olarak kapalıdır; açık olduğunda HMR
|
||||
// sonrası eski chunk'lar cache'den servis edilip kafa karıştırıyor.
|
||||
const enableDevSw = env.VITE_PWA_DEV === 'true'
|
||||
|
||||
const allowedHosts = Array.from(new Set(['localhost', ...baseDomains, ...tenantHosts]))
|
||||
console.log('> [vite] allowedHosts:', allowedHosts)
|
||||
const baseDomains = ['sozsoft.com', 'dev.sozsoft.com', '.sozsoft.com']
|
||||
const allowedHosts = Array.from(new Set(['localhost', ...baseDomains]))
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
|
|
@ -20,87 +51,83 @@ export default defineConfig(async ({ mode }) => {
|
|||
// Yeni sürüm önce tamamen indirilir, ardından uygulama kontrollü olarak
|
||||
// yeni service worker'a geçer. Böylece eski ekran ile yeni chunk'lar karışmaz.
|
||||
registerType: 'prompt',
|
||||
// Kayıt ve loading akışı src/views/version/swRegistration.ts tarafından yönetilir.
|
||||
// Kayıt ve güncelleme akışı src/views/version/swRegistration.ts tarafından yönetilir.
|
||||
injectRegister: false,
|
||||
// Dev ortamında SW'yi aç, prod'da kapalı tut (build edilmiş SW prod'da zaten aktif olur)
|
||||
devOptions: {
|
||||
enabled: mode !== 'production',
|
||||
type: 'module', // Modern module worker kullan
|
||||
enabled: enableDevSw,
|
||||
type: 'module',
|
||||
},
|
||||
|
||||
workbox:
|
||||
mode === 'production'
|
||||
? {
|
||||
globDirectory: 'dist',
|
||||
// Eski bir sekme yeni deploy sırasında henüz açmadığı lazy
|
||||
// chunk'ları da çalıştırabilsin; tüm uygulama dosyalarını sakla.
|
||||
globPatterns: ['**/*.{js,css,html,wasm}'],
|
||||
workbox: {
|
||||
globDirectory: 'dist',
|
||||
globPatterns: PRECACHE_GLOBS,
|
||||
|
||||
// Büyük asset'leri de cache'leyebil
|
||||
maximumFileSizeToCacheInBytes: 15 * 1024 * 1024,
|
||||
// Kabuk dosyaları büyük olabilir (entry CSS ~1.3 MB).
|
||||
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
|
||||
|
||||
// Yeni worker tüm precache dosyalarını indirdikten sonra uygulama
|
||||
// SKIP_WAITING mesajını gönderir. clientsClaim açık sekmeleri o anda yeniler.
|
||||
clientsClaim: true,
|
||||
skipWaiting: false,
|
||||
// Yeni worker precache'i bitirdikten sonra uygulama SKIP_WAITING
|
||||
// gönderir; clientsClaim açık sekmelerin kontrolünü o an devralır.
|
||||
clientsClaim: true,
|
||||
skipWaiting: false,
|
||||
cleanupOutdatedCaches: true,
|
||||
|
||||
// Eski workbox cache'lerini temizle
|
||||
cleanupOutdatedCaches: true,
|
||||
navigateFallback: '/index.html',
|
||||
navigateFallbackAllowlist: [/^(?!\/__).*/],
|
||||
navigateFallbackDenylist: [/^\/api\//, /^\/connect\//, /^\/swagger/, /\/version\.json$/],
|
||||
|
||||
// SPA fallback'i API çağrılarına uygulama
|
||||
navigateFallbackDenylist: [/^\/api\//],
|
||||
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /\.(?:js|css|wasm)$/,
|
||||
// Hash'li build dosyaları immutable'dır. Normal kullanımda ağ
|
||||
// beklenmez; deploy güncellemesini precache manifest'i yönetir.
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'static-resources-v2',
|
||||
expiration: {
|
||||
maxEntries: 200,
|
||||
maxAgeSeconds: 30 * 24 * 60 * 60,
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Changelog/versiyon bilgisi cache yüzünden eski kalmamalı.
|
||||
urlPattern: /\/version\.json$/,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'app-version-v1',
|
||||
networkTimeoutSeconds: 3,
|
||||
expiration: {
|
||||
maxEntries: 5,
|
||||
maxAgeSeconds: 24 * 60 * 60,
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'images',
|
||||
expiration: {
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 gün
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// ⭐ YENİ EKLENEN: Additional navigation route for SPA
|
||||
navigateFallback: '/index.html',
|
||||
navigateFallbackAllowlist: [/^(?!\/__).*/],
|
||||
}
|
||||
: undefined,
|
||||
runtimeCaching: [
|
||||
{
|
||||
// Sürüm/changelog bilgisi asla eski kalmamalı.
|
||||
urlPattern: /\/version\.json$/,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'app-version-v2',
|
||||
networkTimeoutSeconds: 3,
|
||||
expiration: { maxEntries: 2, maxAgeSeconds: 24 * 60 * 60 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Hash'li build çıktıları immutable'dır.
|
||||
urlPattern: HASHED_ASSET_PATTERN,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'app-assets-v3',
|
||||
expiration: { maxEntries: 400, maxAgeSeconds: 60 * 24 * 60 * 60 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
// public/css altındaki DevExtreme temaları hash'siz; içerik
|
||||
// sürümle değişebildiği için arka planda tazelenir.
|
||||
urlPattern: /\/css\/[\w.-]+\.css$/,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'dx-themes-v1',
|
||||
expiration: { maxEntries: 12, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:woff2?|ttf|otf|eot)$/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'fonts-v1',
|
||||
expiration: { maxEntries: 40, maxAgeSeconds: 180 * 24 * 60 * 60 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:png|jpe?g|svg|gif|webp|ico)$/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'images-v2',
|
||||
expiration: { maxEntries: 150, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
manifest: {
|
||||
name: 'Sözsoft Platform',
|
||||
|
|
@ -137,10 +164,14 @@ export default defineConfig(async ({ mode }) => {
|
|||
server: {
|
||||
open: true,
|
||||
port: 3000,
|
||||
// ⭐ YENİ EKLENEN: Hot reload için polling
|
||||
watch: {
|
||||
usePolling: env.VITE_USE_POLLING === 'true',
|
||||
interval: env.VITE_USE_POLLING === 'true' ? 1000 : undefined,
|
||||
usePolling,
|
||||
interval: usePolling ? 1000 : undefined,
|
||||
},
|
||||
// Sık dokunulan giriş dosyalarını önceden transform ederek ilk
|
||||
// gezinmedeki dev sunucu gecikmesini azalt.
|
||||
warmup: {
|
||||
clientFiles: ['./src/main.tsx', './src/App.tsx', './src/components/layouts/index.ts'],
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -154,24 +185,85 @@ export default defineConfig(async ({ mode }) => {
|
|||
},
|
||||
},
|
||||
|
||||
optimizeDeps: {
|
||||
// Barrel/CJS ağırlıklı paketleri dev cold-start'ta tek seferde
|
||||
// prebundle et; aksi halde ilk sayfa yüzlerce ayrı istek atıyor.
|
||||
include: [
|
||||
'react',
|
||||
'react-dom',
|
||||
'react-dom/client',
|
||||
'react-router-dom',
|
||||
'@tanstack/react-query',
|
||||
'@tanstack/react-table',
|
||||
'axios',
|
||||
'dayjs',
|
||||
'formik',
|
||||
'yup',
|
||||
'classnames',
|
||||
'lodash',
|
||||
'easy-peasy',
|
||||
'framer-motion',
|
||||
'react-icons/fa',
|
||||
// CJS/UMD bundle. `exclude` edilirse interop bozulup
|
||||
// "Babel.transform is not a function" hatası veriyor. Ayrıca burada
|
||||
// listelenince dinamik import anındaki "new dependency optimized"
|
||||
// kaynaklı sayfa yenilemesi de olmuyor.
|
||||
'@babel/standalone',
|
||||
],
|
||||
},
|
||||
|
||||
esbuild: {
|
||||
legalComments: 'none',
|
||||
drop: isProduction ? ['debugger'] : [],
|
||||
},
|
||||
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
// Vite'ın varsayılan 'baseline-widely-available' hedefi bilerek korunuyor:
|
||||
// es2022'ye sabitlemek esbuild'i downlevel'a zorlayıp entry chunk'ı
|
||||
// ~200 KB büyütüyor (ölçüldü).
|
||||
chunkSizeWarningLimit: 2000,
|
||||
sourcemap: false,
|
||||
emptyOutDir: true,
|
||||
cssCodeSplit: true,
|
||||
// Modern tarayıcı hedefinde polyfill gereksiz; entry'den birkaç KB düşer.
|
||||
modulePreload: { polyfill: false },
|
||||
// Build sonunda gzip boyutu hesaplamak bu boyutta bir çıktıda dakikalar sürüyor.
|
||||
reportCompressedSize: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: `assets/js/[name]-[hash].js`,
|
||||
chunkFileNames: `assets/js/[name]-[hash].js`,
|
||||
assetFileNames: ({ name }) => {
|
||||
if (/\.(css)$/.test(name ?? '')) {
|
||||
return 'assets/css/[name]-[hash][extname]'
|
||||
}
|
||||
// `entry-` öneki precache glob'unun sadece gerçek entry chunk'ı
|
||||
// yakalamasını sağlar (node_modules kaynaklı `index-*.js` chunk'ları
|
||||
// yanlışlıkla precache'e girmesin).
|
||||
entryFileNames: 'assets/js/entry-[name]-[hash].js',
|
||||
chunkFileNames: 'assets/js/[name]-[hash].js',
|
||||
assetFileNames: ({ name }: { name?: string }) => {
|
||||
if (/\.css$/.test(name ?? '')) return 'assets/css/[name]-[hash][extname]'
|
||||
if (/\.(png|jpe?g|svg|gif|ico|webp)$/.test(name ?? '')) {
|
||||
return 'assets/img/[name]-[hash][extname]'
|
||||
}
|
||||
if (/\.(woff2?|ttf|otf|eot)$/.test(name ?? '')) {
|
||||
return 'assets/fonts/[name]-[hash][extname]'
|
||||
}
|
||||
return 'assets/[name]-[hash][extname]'
|
||||
},
|
||||
// SADECE her açılışta zaten yüklenen (%100 eager) çekirdek vendor'lar
|
||||
// ayrılır; bunlar sürümler arası değişmediği için tarayıcı cache'inde
|
||||
// kalır ve deploy sonrası yeniden indirilmez.
|
||||
//
|
||||
// Not: framer-motion / lodash / formik gibi paketleri de buraya eklemek
|
||||
// ölçüldü ve zararlı çıktı — Rollup'ın otomatik chunk'lamasında bu
|
||||
// paketlerin bir kısmı yalnızca lazy view'lara giriyor; tek chunk'a
|
||||
// toplandıklarında ilk yükleme ~650 KB büyüyor.
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) return undefined
|
||||
const file = id.replace(/\\/g, '/')
|
||||
if (/\/node_modules\/(react|react-dom|scheduler)\//.test(file)) return 'vendor-react'
|
||||
if (/\/node_modules\/(react-router|react-router-dom|@remix-run)\//.test(file)) {
|
||||
return 'vendor-router'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -180,16 +272,11 @@ export default defineConfig(async ({ mode }) => {
|
|||
host: '0.0.0.0',
|
||||
port: 80,
|
||||
open: false,
|
||||
// Hesaplanan allowedHosts'u kullan
|
||||
allowedHosts,
|
||||
},
|
||||
|
||||
define: {
|
||||
'process.env': {},
|
||||
// ⭐ YENİ EKLENEN: Version tracking için global değişkenler
|
||||
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || '1.0.0'),
|
||||
__BUILD_DATE__: JSON.stringify(new Date().toISOString()),
|
||||
__APP_MODE__: JSON.stringify(mode),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue