AppUpdateDialog güncellemesi

This commit is contained in:
Sedat ÖZTÜRK 2026-08-15 12:42:34 +03:00
parent abece2b33e
commit c4311c82e2
3 changed files with 169 additions and 292 deletions

View file

@ -20418,156 +20418,6 @@
"en": "An error occurred while generating the table script.",
"tr": "Tablo scripti oluşturulurken hata oluştu."
},
{
"resourceName": "Platform",
"key": "App.Update.AssetProgress",
"en": "{done}/{total} assets",
"tr": "{done}/{total} bileşen"
},
{
"resourceName": "Platform",
"key": "App.Update.ChangeLogTitle",
"en": "What's new in v{version}",
"tr": "v{version} ile gelen yenilikler"
},
{
"resourceName": "Platform",
"key": "App.Update.Countdown",
"en": "({seconds} s)",
"tr": "({seconds} sn)"
},
{
"resourceName": "Platform",
"key": "App.Update.DurationMinutes",
"en": "{value} min",
"tr": "{value} dk"
},
{
"resourceName": "Platform",
"key": "App.Update.DurationSeconds",
"en": "{value} s",
"tr": "{value} sn"
},
{
"resourceName": "Platform",
"key": "App.UpdateDescription.Activating",
"en": "The page will refresh automatically.",
"tr": "Sayfa otomatik olarak yenilenecek."
},
{
"resourceName": "Platform",
"key": "App.UpdateDescription.Checking",
"en": "Reading the version information from the server...",
"tr": "Sunucudaki sürüm bilgisi okunuyor..."
},
{
"resourceName": "Platform",
"key": "App.UpdateDescription.Completed",
"en": "All assets have been downloaded, the application is ready to use.",
"tr": "Tüm bileşenler indirildi, uygulama kullanıma hazır."
},
{
"resourceName": "Platform",
"key": "App.UpdateDescription.Error",
"en": "The switch could not be completed.",
"tr": "Geçiş tamamlanamadı."
},
{
"resourceName": "Platform",
"key": "App.UpdateDescription.Ready",
"en": "Download complete, switching to the new version.",
"tr": "İndirme tamamlandı, yeni sürüme geçiliyor."
},
{
"resourceName": "Platform",
"key": "App.Update.DownloadedAssets",
"en": "Downloaded assets",
"tr": "İndirilen bileşenler"
},
{
"resourceName": "Platform",
"key": "App.UpdateMessage.ActivationSlow",
"en": "The switch took longer than expected, retrying.",
"tr": "Geçiş beklenenden uzun sürdü, yeniden deneniyor."
},
{
"resourceName": "Platform",
"key": "App.UpdateMessage.DownloadFailed",
"en": "The new version could not be downloaded, retrying.",
"tr": "Yeni sürüm indirilemedi, yeniden deneniyor."
},
{
"resourceName": "Platform",
"key": "App.UpdateMessage.InstallFailed",
"en": "The new version could not be installed, retrying.",
"tr": "Yeni sürüm kurulamadı, yeniden deneniyor."
},
{
"resourceName": "Platform",
"key": "App.UpdateMessage.PartialDownload",
"en": "The assets could not be fully downloaded, they will be loaded as needed.",
"tr": "Bileşenler tamamen indirilemedi, gerektikçe yüklenecek."
},
{
"resourceName": "Platform",
"key": "App.UpdateMessage.WaitingConnection",
"en": "Waiting for a connection. The update will continue when the internet is back.",
"tr": "Bağlantı bekleniyor. İnternet geldiğinde güncelleme sürdürülecek."
},
{
"resourceName": "Platform",
"key": "App.Update.Remaining",
"en": "~{duration} remaining",
"tr": "kalan ~{duration}"
},
{
"resourceName": "Platform",
"key": "App.Update.RetryNow",
"en": "Retry now",
"tr": "Şimdi tekrar dene"
},
{
"resourceName": "Platform",
"key": "App.Update.ReusedFromCache",
"en": "{count} assets from cache",
"tr": "{count} bileşen önbellekten"
},
{
"resourceName": "Platform",
"key": "App.UpdateTitle.Activating",
"en": "The application is restarting",
"tr": "Uygulama yeniden başlatılıyor"
},
{
"resourceName": "Platform",
"key": "App.UpdateTitle.Checking",
"en": "Checking for updates",
"tr": "Güncelleme kontrol ediliyor"
},
{
"resourceName": "Platform",
"key": "App.UpdateTitle.Completed",
"en": "Update completed",
"tr": "Güncelleme tamamlandı"
},
{
"resourceName": "Platform",
"key": "App.UpdateTitle.Error",
"en": "Update could not be completed",
"tr": "Güncelleme tamamlanamadı"
},
{
"resourceName": "Platform",
"key": "App.UpdateTitle.Ready",
"en": "Preparing the new version",
"tr": "Yeni sürüm hazırlanıyor"
},
{
"resourceName": "Platform",
"key": "App.Update.Version",
"en": "Version",
"tr": "Sürüm"
},
{
"resourceName": "Platform",
"key": "App.Platform.Uploading",
@ -25482,48 +25332,6 @@
"en": "Light mode",
"tr": "Açık tema"
},
{
"resourceName": "Platform",
"key": "App.Update.ContinueInBackground",
"en": "Continue in the background",
"tr": "Arka planda devam et"
},
{
"resourceName": "Platform",
"key": "App.UpdateDescription.DownloadingBlocking",
"en": "Assets are being downloaded, do not close the tab. The new version is applied automatically when the download finishes.",
"tr": "Bileşenler indiriliyor, sekmeyi kapatmayın. İndirme bitince yeni sürüme otomatik geçilecek."
},
{
"resourceName": "Platform",
"key": "App.UpdateDescription.DownloadingDismissible",
"en": "All assets are being downloaded; screens will open instantly once it finishes. You can close this window and continue.",
"tr": "Tüm bileşenler indiriliyor; bittiğinde ekranlar anında açılacak. Bu pencereyi kapatıp devam edebilirsiniz."
},
{
"resourceName": "Platform",
"key": "App.UpdateFooter.Blocking",
"en": "This window stays open until the update is completed.",
"tr": "Güncelleme tamamlanana kadar bu pencere kapanmaz."
},
{
"resourceName": "Platform",
"key": "App.UpdateFooter.Dismissible",
"en": "The download continues in the background even if you close this window.",
"tr": "Pencereyi kapatsanız da indirme arka planda sürer."
},
{
"resourceName": "Platform",
"key": "App.UpdateTitle.DownloadingAssets",
"en": "Downloading assets",
"tr": "Bileşenler indiriliyor"
},
{
"resourceName": "Platform",
"key": "App.UpdateTitle.DownloadingVersion",
"en": "Downloading the new version",
"tr": "Yeni sürüm indiriliyor"
},
{
"resourceName": "Platform",
"key": "App.VideoRoom.ExitFullscreen",

View file

@ -1,4 +1,4 @@
import { memo, useSyncExternalStore } from 'react'
import { memo, useState, useSyncExternalStore } from 'react'
import {
applyAppUpdate,
dismissAppUpdateDialog,
@ -6,8 +6,6 @@ import {
subscribeAppUpdate,
type AppUpdateState,
} from './swRegistration'
import { getLocalization } from '@/services/localization.service'
import { store } from '@/store'
/**
* Sürüm indirme penceresi.
@ -22,25 +20,57 @@ import { store } from '@/store'
*/
/**
* Metin çözümü.
* Metinler bilerek statik (İngilizce) tutulur, localization kullanılmaz.
*
* Pencere uygulama ağacının DIŞINDA, kendi React kökünde mount edilir
* (`mountAppUpdateDialog`); orada `StoreProvider` yoktur. Bu yüzden burada
* `useLocalization` (dolayısıyla `useStoreState`) KULLANILAMAZ hook Provider'ı
* bulamayıp hata fırlatıyor, kök çöküyor ve indirme ilerlemesi hiç görünmüyordu.
*
* Metinler store'dan doğrudan (`getState`) okunur; bileşende statik metin
* tutulmaz. Karşılık yoksa `useLocalization` ile aynı davranış: anahtar adı
* döner.
* (`mountAppUpdateDialog`) ve service worker kontrolü uygulama ılışında
* başlar. Localization metinleri ise `application-localization` isteğiyle sonra
* geldiği için pencere ilk boyamada anahtarları çevrilmemiş gösteriyor, metinler
* ancak birkaç saniye sonra yerine oturuyordu. Statik metinle ilk kare doğru.
*/
const t = (key: string, params?: Record<string, string | number>) => {
try {
const { texts, config } = store.getState().abpConfig
if (!texts) return key
return getLocalization(texts, config?.localization?.defaultResourceName, key, params)
} catch {
return key
}
const TEXT = {
title: {
downloadingAssets: 'Downloading assets',
downloadingVersion: 'Downloading the new version',
completed: 'Update completed',
ready: 'Preparing the new version',
activating: 'The application is restarting',
error: 'Update could not be completed',
checking: 'Checking for updates',
},
description: {
downloadingDismissible:
'All assets are being downloaded; screens will open instantly once it finishes. You can close this window and continue.',
downloadingBlocking:
'Assets are being downloaded, do not close the tab. The new version is applied automatically when the download finishes.',
completed: 'All assets have been downloaded, the application is ready to use.',
ready: 'Download complete, switching to the new version.',
activating: 'The page will refresh automatically.',
error: 'The switch could not be completed.',
checking: 'Reading the version information from the server...',
},
footer: {
dismissible: 'The download continues in the background even if you close this window.',
blocking: 'This window stays open until the update is completed.',
},
version: 'Version',
showAssets: 'Show assets',
hideAssets: 'Hide assets',
whatsNew: "What's new",
continueInBackground: 'Continue in the background',
ok: 'OK',
retryNow: 'Retry now',
} as const
/** `swRegistration` yalnızca anahtar taşır; karşılıkları burada çözülür. */
const UPDATE_MESSAGE: Record<string, string> = {
'App.UpdateMessage.ActivationSlow': 'The switch took longer than expected, retrying.',
'App.UpdateMessage.DownloadFailed': 'The new version could not be downloaded, retrying.',
'App.UpdateMessage.InstallFailed': 'The new version could not be installed, retrying.',
'App.UpdateMessage.PartialDownload':
'The assets could not be fully downloaded, they will be loaded as needed.',
'App.UpdateMessage.WaitingConnection':
'Waiting for a connection. The update will continue when the internet is back.',
}
const formatBytes = (bytes: number) => {
@ -49,58 +79,48 @@ const formatBytes = (bytes: number) => {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const formatDuration = (seconds: number) => {
if (seconds < 60) {
return t('::App.Update.DurationSeconds', { value: Math.max(1, seconds) })
}
return t('::App.Update.DurationMinutes', { value: Math.round(seconds / 60) })
}
const formatDuration = (seconds: number) =>
seconds < 60 ? `${Math.max(1, seconds)} s` : `${Math.round(seconds / 60)} min`
const titleFor = (state: AppUpdateState) => {
switch (state.status) {
case 'downloading':
return t(
state.dismissible
? '::App.UpdateTitle.DownloadingAssets'
: '::App.UpdateTitle.DownloadingVersion',
)
return state.dismissible ? TEXT.title.downloadingAssets : TEXT.title.downloadingVersion
case 'completed':
return t('::App.UpdateTitle.Completed')
return TEXT.title.completed
case 'ready':
return t('::App.UpdateTitle.Ready')
return TEXT.title.ready
case 'activating':
return t('::App.UpdateTitle.Activating')
return TEXT.title.activating
case 'error':
return t('::App.UpdateTitle.Error')
return TEXT.title.error
default:
return t('::App.UpdateTitle.Checking')
return TEXT.title.checking
}
}
const countdownText = (state: AppUpdateState) =>
state.secondsLeft && state.secondsLeft > 0
? ` ${t('::App.Update.Countdown', { seconds: state.secondsLeft })}`
: ''
state.secondsLeft && state.secondsLeft > 0 ? ` (${state.secondsLeft} s)` : ''
const descriptionFor = (state: AppUpdateState) => {
switch (state.status) {
case 'downloading':
return t(
state.dismissible
? '::App.UpdateDescription.DownloadingDismissible'
: '::App.UpdateDescription.DownloadingBlocking',
)
return state.dismissible
? TEXT.description.downloadingDismissible
: TEXT.description.downloadingBlocking
case 'completed':
return t('::App.UpdateDescription.Completed')
return TEXT.description.completed
case 'ready':
return t('::App.UpdateDescription.Ready')
return TEXT.description.ready
case 'activating':
return t('::App.UpdateDescription.Activating')
case 'error':
// `swRegistration` yalnızca anahtar taşır (ör. App.UpdateMessage.*).
return `${t(state.message ? `::${state.message}` : '::App.UpdateDescription.Error')}${countdownText(state)}`
return TEXT.description.activating
case 'error': {
const message =
(state.message && UPDATE_MESSAGE[state.message]) || TEXT.description.error
return `${message}${countdownText(state)}`
}
default:
return t('::App.UpdateDescription.Checking')
return TEXT.description.checking
}
}
@ -156,14 +176,31 @@ const byActivity = (a: AppUpdateState['files'][number], b: AppUpdateState['files
(b.seq ?? 0) - (a.seq ?? 0) ||
a.index - b.index
/** Bağımlılık eklememek için ikon satır içi; buton dışında kullanılmaz. */
const Chevron = ({ open }: { open: boolean }) => (
<svg
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
className={`h-3.5 w-3.5 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`}
>
<path d="M7.5 5.5 12 10l-4.5 4.5V5.5Z" />
</svg>
)
const AppUpdateDialog = () => {
const state = useSyncExternalStore(subscribeAppUpdate, getAppUpdateState, getAppUpdateState)
// Bileşen listesi varsayılan olarak kapalı: ana ekran sürüm + ilerleme +
// changelog'a ayrılır, ~500 satırlık liste yalnızca istendiğinde çizilir.
const [assetsOpen, setAssetsOpen] = useState(false)
if (!state.visible) return null
const busy = state.status === 'downloading' || state.status === 'ready'
const hasFiles = state.files.length > 0
const doneCount = state.files.filter((file) => file.status === 'done').length
const ordered = state.files.slice().sort(byActivity)
// Sıralama yalnızca liste açıkken yapılır.
const ordered = assetsOpen ? state.files.slice().sort(byActivity) : []
return (
<div
@ -182,31 +219,22 @@ const AppUpdateDialog = () => {
</div>
</div>
<div className="flex flex-wrap items-center gap-2 px-6 pt-4 text-xs">
<span className="rounded-full bg-gray-100 px-2.5 py-1 font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{t('::App.Update.Version')}: v{state.currentVersion}
</span>
</div>
{!!state.currentVersion && (
<div className="flex flex-wrap items-center gap-2 px-6 pt-4 text-xs">
<span className="rounded-full bg-gray-100 px-2.5 py-1 font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{TEXT.version}: v{state.currentVersion}
</span>
</div>
)}
{(busy || state.files.length > 0) && (
{(busy || hasFiles) && (
<div className="px-6 pt-4">
<div className="flex flex-wrap items-center justify-between gap-x-3 text-xs text-gray-500 dark:text-gray-400">
<span>
{t('::App.Update.AssetProgress', {
done: doneCount,
total: state.files.length,
})}
{state.reusedCount > 0 &&
` · ${t('::App.Update.ReusedFromCache', { count: state.reusedCount })}`}
{state.status === 'downloading' && state.secondsRemaining !== undefined && (
<>
{' '}
·{' '}
{t('::App.Update.Remaining', {
duration: formatDuration(state.secondsRemaining),
})}
</>
)}
{`${doneCount}/${state.files.length} assets`}
{state.reusedCount > 0 && ` · ${state.reusedCount} assets from cache`}
{state.secondsRemaining !== undefined &&
` · ~${formatDuration(state.secondsRemaining)} remaining`}
</span>
<span className="tabular-nums">
{formatBytes(state.loadedBytes)}
@ -222,24 +250,33 @@ const AppUpdateDialog = () => {
</div>
)}
{hasFiles && (
<div className="px-6 pt-3">
<button
type="button"
aria-expanded={assetsOpen}
className="inline-flex items-center gap-1.5 rounded-md text-xs font-semibold uppercase tracking-wide text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-200"
onClick={() => setAssetsOpen((open) => !open)}
>
<Chevron open={assetsOpen} />
{assetsOpen ? TEXT.hideAssets : TEXT.showAssets} ({state.files.length})
</button>
</div>
)}
<div className="mt-4 min-h-0 flex-1 overflow-y-auto px-6">
{state.files.length > 0 && (
<>
<p className="text-xs font-bold uppercase tracking-wide text-gray-400">
{t('::App.Update.DownloadedAssets')}
</p>
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
{ordered.map((file) => (
<FileRow key={file.url} file={file} />
))}
</ul>
</>
{assetsOpen && hasFiles && (
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
{ordered.map((file) => (
<FileRow key={file.url} file={file} />
))}
</ul>
)}
{state.changeLog.length > 0 && (
<div className="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700">
<div className={assetsOpen && hasFiles ? 'mt-4 border-t border-gray-100 pt-4 dark:border-gray-700' : ''}>
<p className="text-xs font-bold uppercase tracking-wide text-gray-400">
{t('::App.Update.ChangeLogTitle', { version: state.currentVersion })}
{state.currentVersion ? `${TEXT.whatsNew} in v${state.currentVersion}` : TEXT.whatsNew}
</p>
<ul className="mt-2 flex flex-col gap-1.5 text-sm text-gray-600 dark:text-gray-300">
{state.changeLog.map((line, index) => (
@ -255,9 +292,7 @@ const AppUpdateDialog = () => {
<div className="flex items-center justify-between gap-3 px-6 py-4">
<span className="text-xs text-gray-400">
{t(
state.dismissible ? '::App.UpdateFooter.Dismissible' : '::App.UpdateFooter.Blocking',
)}
{state.dismissible ? TEXT.footer.dismissible : TEXT.footer.blocking}
</span>
{state.dismissible ? (
<button
@ -265,11 +300,7 @@ const AppUpdateDialog = () => {
className="shrink-0 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
onClick={dismissAppUpdateDialog}
>
{t(
state.status === 'completed'
? '::App.Platform.OK'
: '::App.Update.ContinueInBackground',
)}
{state.status === 'completed' ? TEXT.ok : TEXT.continueInBackground}
</button>
) : (
state.status === 'error' && (
@ -278,7 +309,7 @@ const AppUpdateDialog = () => {
className="shrink-0 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
onClick={applyAppUpdate}
>
{t('::App.Update.RetryNow')}
{TEXT.retryNow}
{countdownText(state)}
</button>
)

View file

@ -78,6 +78,11 @@ export type AppUpdateState = {
percent: number
/** Değişmediği için yeniden indirilmeyen dosya sayısı. */
reusedCount: number
/**
* Durum mesajının kimliği (ör. `App.UpdateMessage.DownloadFailed`).
* Localization anahtarı DEĞİLDİR; karşılığı `AppUpdateDialog` içindeki
* `UPDATE_MESSAGE` tablosundan çözülür.
*/
message?: string
/** Otomatik geçiş/yeniden deneme için kalan saniye. */
secondsLeft?: number
@ -187,6 +192,25 @@ function setState(patch: Partial<AppUpdateState>) {
listeners.forEach((listener) => listener(state))
}
/**
* Kullanıcı pencereyi kapattı mı? ("arka planda devam et")
*
* İlerleme mesajları saniyede birkaç kez gelir ve her biri durumu tazeler;
* bayrak olmadan `visible: true` yazılıp kapatma bir sonraki tick'te geri
* alınıyor, pencere kendiliğinden ılıyordu. Bayrak yalnızca çalışan sürümü
* değiştirecek bir geçiş başladığında `forceDialog` ile sıfırlanır o akışta
* pencere zaten kapatılamaz (`dismissible: false`).
*/
let dismissedByUser = false
/** Geçiş gerektiren akışlarda kapatma tercihi geçersizdir. */
const forceDialog = () => {
dismissedByUser = false
}
/** İlerleme güncellemelerinde kullanılacak görünürlük. */
const dialogVisible = () => !dismissedByUser
/* Kalan süre tahmini: oturum başından beri ölçülen ortalama hız. */
let rateStartedAt = 0
let rateStartBytes = 0
@ -437,6 +461,8 @@ function postSkipWaiting() {
}
function activateWaitingWorker() {
// Yeni sürüme geçiliyor: kapatma tercihi burada geçersiz.
forceDialog()
if (activating) {
setState({ visible: true })
return
@ -575,12 +601,14 @@ function handleWorkerMessage(event: MessageEvent) {
case 'SW_PRECACHE_START':
// İndirme başladı: bekleyen geri sayım varsa iptal edilir.
clearAutoAction()
// Geçişli akış kapatılamaz; önceki kapatma tercihi burada geçersizleşir.
if (transitions) forceDialog()
void showUpdateDialog('downloading')
resetRate()
setState({ percent: 0 })
recalculate(toFiles(data.files), totals, {
status: 'downloading',
visible: true,
visible: dialogVisible(),
reusedCount: data.reusedCount ?? 0,
secondsLeft: undefined,
dismissible: !transitions,
@ -591,7 +619,7 @@ function handleWorkerMessage(event: MessageEvent) {
if (!dialogMountRequested) void showUpdateDialog('downloading')
recalculate(mergeFiles(toFiles(data.updates)), totals, {
status: 'downloading',
visible: true,
visible: dialogVisible(),
dismissible: !transitions,
})
break
@ -599,7 +627,7 @@ function handleWorkerMessage(event: MessageEvent) {
recalculate(keepSeq(toFiles(data.files)), totals, {
// Yeni sürüme geçilecekse 'ready'; ısıtmada iş bitti.
status: transitions ? 'ready' : 'completed',
visible: true,
visible: transitions || dialogVisible(),
percent: 100,
})
if (transitions) scheduleActivationHandoff()
@ -610,13 +638,14 @@ function handleWorkerMessage(event: MessageEvent) {
// Çalışan sürüm etkilenmedi; kullanıcıyı yeniden yükleme döngüsüne sokma.
setState({
status: 'error',
visible: true,
visible: dialogVisible(),
dismissible: true,
message: 'App.UpdateMessage.PartialDownload',
})
hideDialogLater()
break
}
forceDialog()
setState({
status: 'error',
visible: true,
@ -630,7 +659,10 @@ function handleWorkerMessage(event: MessageEvent) {
}
/** Kapatılabilir bildirimlerde pencereyi gizler (indirme arka planda sürer). */
export const dismissAppUpdateDialog = () => setState({ visible: false })
export const dismissAppUpdateDialog = () => {
dismissedByUser = true
setState({ visible: false })
}
/* ------------------------------------------------------------------ */
/* Güncelleme penceresi (lazy) */
@ -643,7 +675,7 @@ let dialogMountRequested = false
* olduğunda indirilir ve body'ye kendi kökünde mount edilir.
*/
async function showUpdateDialog(status: AppUpdateStatus) {
setState({ status, visible: true })
setState({ status, visible: dialogVisible() })
if (dialogMountRequested) return
dialogMountRequested = true
@ -752,6 +784,8 @@ function trackInstallingWorker(reg: ServiceWorkerRegistration) {
if (!isUpdate) return
watchedWorkers.add(worker)
// Güncelleme kurulumu: geçiş kaçınılmaz, kapatma tercihi geçersiz.
forceDialog()
void showUpdateDialog('downloading')
worker.addEventListener('statechange', () => {
@ -763,6 +797,7 @@ function trackInstallingWorker(reg: ServiceWorkerRegistration) {
// sürüm çalışmaya devam eder ve kullanıcı kilitli ekranda kalmaz.
if (worker.state === 'redundant' && !activating) {
console.warn('Yeni service worker kurulamadı, yeniden denenecek.')
forceDialog()
setState({
status: 'error',
visible: true,
@ -903,6 +938,9 @@ export const registerServiceWorker = () => {
/** Kullanıcının menüden tetiklediği manuel kontrol. */
export const checkForAppUpdate = async (): Promise<ManualUpdateResult> => {
// Kullanıcı kontrolü kendisi istedi; önceki "arka planda devam et" tercihi
// bu akışta geçerli değil.
forceDialog()
setState({ status: state.status === 'idle' ? 'checking' : state.status })
if (!('serviceWorker' in navigator)) return 'unsupported'