new UI and handle cashing
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
export type PwaManifestIcon = {
|
||||
src: string
|
||||
sizes?: string
|
||||
type?: string
|
||||
purpose?: string
|
||||
}
|
||||
|
||||
export type PwaManifest = {
|
||||
id?: string
|
||||
name?: string
|
||||
short_name?: string
|
||||
lang?: string
|
||||
dir?: string
|
||||
start_url?: string
|
||||
scope?: string
|
||||
display?: string
|
||||
background_color?: string
|
||||
theme_color?: string
|
||||
description?: string
|
||||
icons?: PwaManifestIcon[]
|
||||
}
|
||||
|
||||
const MANIFEST_CACHE_KEY = 'PWA_MANIFEST_CACHE_V5'
|
||||
const MANIFEST_CACHE_MAX_AGE_MS = 60 * 60 * 24 * 7
|
||||
|
||||
export type CachedPwaManifest = {
|
||||
cached_at: number
|
||||
user_id: string | number
|
||||
config_base: string
|
||||
manifest_url: string
|
||||
manifest: PwaManifest
|
||||
}
|
||||
|
||||
export function readCachedPwaManifest(
|
||||
userId: string | number | null | undefined,
|
||||
configBase: string
|
||||
): CachedPwaManifest | null {
|
||||
if (userId == null || userId === '' || !configBase) return null
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(MANIFEST_CACHE_KEY)
|
||||
if (!raw) return null
|
||||
|
||||
const payload = JSON.parse(raw) as CachedPwaManifest
|
||||
|
||||
if (!payload?.manifest || !payload.manifest_url) return null
|
||||
if (String(payload.user_id) !== String(userId)) return null
|
||||
if (payload.config_base !== configBase) return null
|
||||
|
||||
if (Date.now() - Number(payload.cached_at || 0) > MANIFEST_CACHE_MAX_AGE_MS) {
|
||||
localStorage.removeItem(MANIFEST_CACHE_KEY)
|
||||
return null
|
||||
}
|
||||
|
||||
return payload
|
||||
} catch {
|
||||
localStorage.removeItem(MANIFEST_CACHE_KEY)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedPwaManifest(entry: CachedPwaManifest) {
|
||||
try {
|
||||
localStorage.setItem(MANIFEST_CACHE_KEY, JSON.stringify(entry))
|
||||
} catch (error) {
|
||||
console.warn('Failed to cache PWA manifest:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCachedPwaManifest() {
|
||||
localStorage.removeItem(MANIFEST_CACHE_KEY)
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import type { AppConfig } from '@/config/types'
|
||||
import { getConfig, getRawAppConfig } from '@/config/app-config'
|
||||
import {
|
||||
clearCachedPwaManifest,
|
||||
readCachedPwaManifest,
|
||||
writeCachedPwaManifest,
|
||||
type PwaManifest
|
||||
} from '@/modules/pwa/manifest-cache'
|
||||
|
||||
export { clearCachedPwaManifest, type PwaManifest, type PwaManifestIcon } from '@/modules/pwa/manifest-cache'
|
||||
|
||||
export const PWA_MANIFEST_READY_KEY = 'pwa_manifest_ready'
|
||||
export const PWA_MANIFEST_READY_EVENT = 'pwa-manifest-ready'
|
||||
|
||||
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
|
||||
let pendingBeforeInstallPrompt: Event | null = null
|
||||
let installCaptureInitialized = false
|
||||
const MANIFEST_RETRY_COUNT = 5
|
||||
const MANIFEST_RETRY_DELAY_MS = 500
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(url: string) {
|
||||
return url.replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/** همان baseای که get_user از آن لود شده (کش شده در localStorage) */
|
||||
export function getConfigBaseFromStorage() {
|
||||
const lastSuccess = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
|
||||
if (!lastSuccess) return ''
|
||||
return normalizeBaseUrl(lastSuccess)
|
||||
}
|
||||
|
||||
/** یک URL — فقط monifist همان tenant/user؛ بدون fallback عمومی */
|
||||
export function resolveManifestUrl(config?: AppConfig | null) {
|
||||
const raw = config ?? getRawAppConfig()
|
||||
const userId = raw?.user?.id
|
||||
const base = getConfigBaseFromStorage()
|
||||
|
||||
if (!base) return ''
|
||||
|
||||
const explicit = getConfig<string>('manifest_url')
|
||||
if (explicit) return explicit
|
||||
|
||||
/* if (userId != null && userId !== '') {
|
||||
return `${base}/get_user/${userId}/manifest.json`
|
||||
}*/
|
||||
|
||||
return `${base}/get_user/manifest.json`
|
||||
}
|
||||
|
||||
function isValidManifest(payload: unknown): payload is PwaManifest {
|
||||
if (!payload || typeof payload !== 'object') return false
|
||||
|
||||
const manifest = payload as PwaManifest
|
||||
const icons = manifest.icons
|
||||
|
||||
if (!Array.isArray(icons) || icons.length === 0) return false
|
||||
|
||||
return icons.some((icon) => typeof icon?.src === 'string' && icon.src.trim().length > 0)
|
||||
}
|
||||
|
||||
async function fetchManifestOnce(url: string, timeout = 6000): Promise<PwaManifest> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/manifest+json, application/json' },
|
||||
cache: 'default',
|
||||
signal: controller.signal,
|
||||
priority: 'high'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Manifest request failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
|
||||
if (!isValidManifest(payload)) {
|
||||
throw new Error('Manifest response is missing valid icons')
|
||||
}
|
||||
|
||||
return payload
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
/** retry فقط روی همان URL — بدون درخواست دوم */
|
||||
async function fetchManifestWithRetry(url: string): Promise<PwaManifest> {
|
||||
if (!url) {
|
||||
throw new Error('No manifest URL (config base or user id missing)')
|
||||
}
|
||||
|
||||
let lastError: unknown = null
|
||||
|
||||
for (let attempt = 1; attempt <= MANIFEST_RETRY_COUNT; attempt++) {
|
||||
try {
|
||||
return await fetchManifestOnce(url)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
console.warn(`PWA manifest failed (${url}), attempt ${attempt}/${MANIFEST_RETRY_COUNT}`, error)
|
||||
}
|
||||
|
||||
if (attempt < MANIFEST_RETRY_COUNT) {
|
||||
await sleep(MANIFEST_RETRY_DELAY_MS * attempt)
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('PWA manifest could not be loaded')
|
||||
}
|
||||
|
||||
let manifestBlobUrl: string | null = null
|
||||
|
||||
function revokeManifestBlobUrl() {
|
||||
if (manifestBlobUrl) {
|
||||
URL.revokeObjectURL(manifestBlobUrl)
|
||||
manifestBlobUrl = null
|
||||
}
|
||||
}
|
||||
|
||||
function isAbsoluteWebUrl(src: string) {
|
||||
return /^https?:\/\//i.test(src) || src.startsWith('//')
|
||||
}
|
||||
|
||||
function toAbsoluteAssetUrl(src: string, base: string) {
|
||||
const value = String(src || '').trim()
|
||||
if (!value) return ''
|
||||
|
||||
if (value.startsWith('//')) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'
|
||||
return `${protocol}${value}`
|
||||
}
|
||||
|
||||
if (/^(https?:\/\/|data:|blob:)/i.test(value)) return value
|
||||
if (!base) return value
|
||||
|
||||
return `${base.replace(/\/+$/, '')}/${value.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
/** path داخل همین اپ — دامنهٔ سرور (montpar.ir) نادیده گرفته میشود */
|
||||
function toManifestPath(url: string | undefined, fallback = '/') {
|
||||
const defaultPath = fallback.startsWith('/') ? fallback : `/${fallback}`
|
||||
|
||||
if (!url?.trim()) return defaultPath
|
||||
|
||||
try {
|
||||
const trimmed = url.trim()
|
||||
|
||||
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const parsed = new URL(trimmed)
|
||||
const path = `${parsed.pathname || '/'}${parsed.search}${parsed.hash}`
|
||||
|
||||
if (parsed.origin !== window.location.origin) {
|
||||
return path || '/'
|
||||
}
|
||||
|
||||
return path || '/'
|
||||
} catch {
|
||||
return defaultPath
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* blob manifest فقط URL مطلق همorigin را میپذیرد (مثلاً http://192.168.x.x:3100/).
|
||||
* مسیر نسبی مثل "/" → "property ignored, URL is invalid".
|
||||
*/
|
||||
function toSameOriginManifestUrl(url: string | undefined, fallbackPath = '/') {
|
||||
const path = toManifestPath(url, fallbackPath)
|
||||
return new URL(path, window.location.origin).href
|
||||
}
|
||||
|
||||
function normalizeManifestId(id: string | undefined, startUrl: string) {
|
||||
const value = String(id || '').trim()
|
||||
if (!value || value === '/') return startUrl
|
||||
|
||||
try {
|
||||
const parsed = new URL(value, window.location.origin)
|
||||
if (parsed.origin === window.location.origin) return parsed.href
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return startUrl
|
||||
}
|
||||
|
||||
function guessMimeType(src: string) {
|
||||
const lower = src.split('?')[0].toLowerCase()
|
||||
|
||||
if (lower.endsWith('.png')) return 'image/png'
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'
|
||||
if (lower.endsWith('.webp')) return 'image/webp'
|
||||
|
||||
return 'image/png'
|
||||
}
|
||||
|
||||
function mapManifestIcons(manifest: PwaManifest, base: string) {
|
||||
return (manifest.icons || [])
|
||||
.map((icon) => {
|
||||
const src = toAbsoluteAssetUrl(icon.src, base)
|
||||
|
||||
return {
|
||||
src,
|
||||
sizes: icon.sizes || '512x512',
|
||||
type: guessMimeType(src),
|
||||
purpose: icon.purpose || 'any'
|
||||
}
|
||||
})
|
||||
.filter((icon) => isAbsoluteWebUrl(icon.src))
|
||||
}
|
||||
|
||||
/** فقط آیکونهای داخل monifist.json — بدون آواتار / pwa-icons / env */
|
||||
function resolveInstallIcons(manifest: PwaManifest) {
|
||||
const base = getConfigBaseFromStorage()
|
||||
const mapped = mapManifestIcons(manifest, base)
|
||||
|
||||
if (!mapped.length) {
|
||||
throw new Error('Manifest icons could not be resolved to absolute URLs')
|
||||
}
|
||||
|
||||
return mapped
|
||||
}
|
||||
|
||||
function prepareManifestForInstall(manifest: PwaManifest) {
|
||||
const scopeUrl = toSameOriginManifestUrl(manifest.scope ?? manifest.start_url, '/')
|
||||
const startUrl = toSameOriginManifestUrl(manifest.start_url, '/')
|
||||
const icons = resolveInstallIcons(manifest)
|
||||
const name = manifest.short_name || manifest.name
|
||||
|
||||
if (!name) {
|
||||
throw new Error('Manifest is missing name/short_name')
|
||||
}
|
||||
|
||||
if (!manifest.theme_color) {
|
||||
throw new Error('Manifest is missing theme_color')
|
||||
}
|
||||
|
||||
// start_url باید داخل scope باشد
|
||||
const scopeBase = scopeUrl.endsWith('/') ? scopeUrl : `${scopeUrl}/`
|
||||
const effectiveScope = startUrl.startsWith(scopeBase) ? scopeUrl : toSameOriginManifestUrl('/', '/')
|
||||
|
||||
return {
|
||||
id: normalizeManifestId(manifest.id, startUrl),
|
||||
name,
|
||||
short_name: manifest.short_name || manifest.name || name,
|
||||
lang: manifest.lang || 'fa',
|
||||
dir: manifest.dir || 'rtl',
|
||||
start_url: startUrl,
|
||||
scope: effectiveScope,
|
||||
display: manifest.display || 'standalone',
|
||||
background_color: manifest.background_color || '#ffffff',
|
||||
theme_color: manifest.theme_color,
|
||||
description: manifest.description || name,
|
||||
icons
|
||||
}
|
||||
}
|
||||
|
||||
function pickManifestIconUrl(manifest: PwaManifest, preferredSizes: number[]) {
|
||||
const icons = manifest.icons ?? []
|
||||
|
||||
for (const size of preferredSizes) {
|
||||
const target = `${size}x${size}`
|
||||
const match = icons.find((icon) => String(icon.sizes || '').includes(target))
|
||||
|
||||
if (match?.src) return match.src
|
||||
}
|
||||
|
||||
const sorted = [...icons].sort((a, b) => {
|
||||
const aSize = Number.parseInt(String(a.sizes || '0'), 10) || 0
|
||||
const bSize = Number.parseInt(String(b.sizes || '0'), 10) || 0
|
||||
return bSize - aSize
|
||||
})
|
||||
|
||||
return sorted[0]?.src || ''
|
||||
}
|
||||
|
||||
function upsertLink(rel: string, attrs: Record<string, string>) {
|
||||
const selectorParts = Object.entries(attrs)
|
||||
.filter(([key]) => key !== 'href')
|
||||
.map(([key, value]) => `[${key}="${value}"]`)
|
||||
.join('')
|
||||
|
||||
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]${selectorParts}`)
|
||||
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.rel = rel
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
Object.entries(attrs).forEach(([key, value]) => {
|
||||
link!.setAttribute(key, value)
|
||||
})
|
||||
|
||||
return link
|
||||
}
|
||||
|
||||
function upsertMeta(name: string, content: string) {
|
||||
let meta = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
|
||||
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta')
|
||||
meta.name = name
|
||||
document.head.appendChild(meta)
|
||||
}
|
||||
|
||||
meta.content = content
|
||||
}
|
||||
|
||||
function removeAllManifestLinks() {
|
||||
document.querySelectorAll<HTMLLinkElement>("link[rel='manifest']").forEach((node) => node.remove())
|
||||
}
|
||||
|
||||
function removeDynamicManifestLinks() {
|
||||
removeAllManifestLinks()
|
||||
revokeManifestBlobUrl()
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLLinkElement>("link[rel='apple-touch-icon'][data-dynamic-pwa='true']")
|
||||
.forEach((node) => node.remove())
|
||||
}
|
||||
|
||||
function applyAppleTouchIconsFromManifest(manifest: PwaManifest) {
|
||||
document
|
||||
.querySelectorAll<HTMLLinkElement>("link[rel='apple-touch-icon'][data-dynamic-pwa='true']")
|
||||
.forEach((node) => node.remove())
|
||||
|
||||
const appleSizes = [180, 167, 152, 120]
|
||||
const used = new Set<string>()
|
||||
|
||||
appleSizes.forEach((size) => {
|
||||
const href = pickManifestIconUrl(manifest, [size, 192, 512])
|
||||
|
||||
if (!href || used.has(href)) return
|
||||
|
||||
used.add(href)
|
||||
|
||||
upsertLink('apple-touch-icon', {
|
||||
'data-dynamic-pwa': 'true',
|
||||
sizes: `${size}x${size}`,
|
||||
href
|
||||
})
|
||||
})
|
||||
|
||||
const primary = pickManifestIconUrl(manifest, [180, 192, 512, 384])
|
||||
|
||||
if (primary) {
|
||||
upsertLink('apple-touch-icon', {
|
||||
'data-dynamic-pwa': 'true',
|
||||
href: primary
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyPreparedManifestToDocument(
|
||||
normalized: PwaManifest,
|
||||
manifestUrl: string
|
||||
) {
|
||||
removeDynamicManifestLinks()
|
||||
|
||||
if (!normalized.icons?.length) {
|
||||
throw new Error('Manifest has no valid absolute icon URLs')
|
||||
}
|
||||
|
||||
const title = normalized.short_name || normalized.name
|
||||
|
||||
if (!title) {
|
||||
throw new Error('Prepared manifest is missing title')
|
||||
}
|
||||
|
||||
document.title = title
|
||||
|
||||
const blob = new Blob([JSON.stringify(normalized)], {
|
||||
type: 'application/manifest+json'
|
||||
})
|
||||
manifestBlobUrl = URL.createObjectURL(blob)
|
||||
|
||||
upsertLink('manifest', {
|
||||
'data-dynamic-pwa': 'true',
|
||||
href: manifestBlobUrl
|
||||
})
|
||||
|
||||
if (normalized.theme_color) {
|
||||
upsertMeta('theme-color', normalized.theme_color)
|
||||
}
|
||||
|
||||
upsertMeta('apple-mobile-web-app-title', title)
|
||||
upsertMeta('mobile-web-app-capable', 'yes')
|
||||
upsertMeta('apple-mobile-web-app-capable', 'yes')
|
||||
|
||||
applyAppleTouchIconsFromManifest(normalized)
|
||||
|
||||
const favicon = pickManifestIconUrl(normalized, [192, 180, 128, 96, 72])
|
||||
|
||||
console.info('PWA manifest applied:', manifestUrl, `icons: ${normalized.icons.length}`)
|
||||
|
||||
if (favicon) {
|
||||
upsertLink('icon', { type: 'image/png', href: favicon })
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyManifestToDocument(manifest: PwaManifest, manifestUrl: string) {
|
||||
const prepared = prepareManifestForInstall(manifest)
|
||||
await applyPreparedManifestToDocument(prepared, manifestUrl)
|
||||
}
|
||||
|
||||
export function setPwaManifestReady(enabled: boolean) {
|
||||
if (enabled) {
|
||||
sessionStorage.setItem(PWA_MANIFEST_READY_KEY, '1')
|
||||
} else {
|
||||
sessionStorage.removeItem(PWA_MANIFEST_READY_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
export function isPwaManifestReady() {
|
||||
return sessionStorage.getItem(PWA_MANIFEST_READY_KEY) === '1'
|
||||
}
|
||||
|
||||
/** هرچه زودتر در main.ts — قبل از bootstrap */
|
||||
export function initPwaInstallCapture() {
|
||||
if (installCaptureInitialized || typeof window === 'undefined') return
|
||||
installCaptureInitialized = true
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (isPwaManifestReady()) {
|
||||
notifyPwaManifestReady(e)
|
||||
return
|
||||
}
|
||||
|
||||
pendingBeforeInstallPrompt = e
|
||||
console.info('PWA install prompt captured, waiting for manifest + service worker')
|
||||
})
|
||||
}
|
||||
|
||||
export function notifyPwaManifestReady(installEvent?: Event | null) {
|
||||
if (!isPwaManifestReady()) return
|
||||
|
||||
const event = installEvent ?? pendingBeforeInstallPrompt
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(PWA_MANIFEST_READY_EVENT, {
|
||||
detail: { installEvent: event }
|
||||
})
|
||||
)
|
||||
|
||||
if (!event) {
|
||||
console.info('PWA ready (manifest + SW). Waiting for beforeinstallprompt…', {
|
||||
secureContext: window.isSecureContext,
|
||||
origin: window.location.origin
|
||||
})
|
||||
}
|
||||
|
||||
pendingBeforeInstallPrompt = null
|
||||
}
|
||||
|
||||
async function applyCachedManifestEntry(
|
||||
cached: ReturnType<typeof readCachedPwaManifest>
|
||||
): Promise<boolean> {
|
||||
if (!cached) return false
|
||||
|
||||
try {
|
||||
const prepared = prepareManifestForInstall(cached.manifest)
|
||||
await applyPreparedManifestToDocument(prepared, cached.manifest_url)
|
||||
setPwaManifestReady(true)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('Cached PWA manifest invalid, refetching:', error)
|
||||
clearCachedPwaManifest()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* بعد از loadAppConfig / setAppConfig صدا بزن.
|
||||
* get_user دوباره fetch نمیشود — فقط monifist.json از base کششده.
|
||||
*/
|
||||
export async function setupPwaManifest(config?: AppConfig | null): Promise<boolean> {
|
||||
const raw = config ?? getRawAppConfig()
|
||||
const userId = raw?.user?.id
|
||||
const configBase = getConfigBaseFromStorage()
|
||||
|
||||
if (!configBase) {
|
||||
console.warn('PWA manifest skipped: CONFIG_Success_URL not set yet')
|
||||
removeDynamicManifestLinks()
|
||||
setPwaManifestReady(false)
|
||||
return false
|
||||
}
|
||||
|
||||
const cached = readCachedPwaManifest(userId, configBase)
|
||||
|
||||
if (cached && (await applyCachedManifestEntry(cached))) {
|
||||
console.info('PWA manifest applied from cache:', cached.manifest_url)
|
||||
return true
|
||||
}
|
||||
|
||||
const manifestUrl = resolveManifestUrl(raw)
|
||||
|
||||
try {
|
||||
const manifest = await fetchManifestWithRetry(manifestUrl)
|
||||
|
||||
const prepared = prepareManifestForInstall(manifest)
|
||||
|
||||
await applyPreparedManifestToDocument(prepared, manifestUrl)
|
||||
setPwaManifestReady(true)
|
||||
|
||||
if (userId != null && userId !== '') {
|
||||
writeCachedPwaManifest({
|
||||
cached_at: Date.now(),
|
||||
user_id: userId,
|
||||
config_base: configBase,
|
||||
manifest_url: manifestUrl,
|
||||
manifest: prepared
|
||||
})
|
||||
}
|
||||
|
||||
console.info('PWA manifest loaded once:', manifestUrl)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('PWA manifest unavailable, PWA disabled this session:', error)
|
||||
|
||||
removeDynamicManifestLinks()
|
||||
setPwaManifestReady(false)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let manifestBootstrapStarted = false
|
||||
|
||||
/**
|
||||
* بلافاصله بعد از setAppConfig — بدون await در bootstrap.
|
||||
* اپ منتظر مانیفست نمیماند؛ SW بعد از موفقیت در پسزمینه ثبت میشود.
|
||||
*/
|
||||
export function startPwaManifest(config?: AppConfig | null) {
|
||||
if (manifestBootstrapStarted) return
|
||||
manifestBootstrapStarted = true
|
||||
|
||||
void setupPwaManifest(config).then((ready) => {
|
||||
if (!ready) return
|
||||
|
||||
void import('@/modules/pwa/register-pwa').then(({ registerAppPwa }) => registerAppPwa())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import { isPwaManifestReady, notifyPwaManifestReady } from '@/modules/pwa/manifest'
|
||||
|
||||
let swRegistrationStarted = false
|
||||
|
||||
export function registerAppPwa() {
|
||||
if (!isPwaManifestReady()) {
|
||||
console.info('Skipping service worker registration: manifest not ready')
|
||||
return
|
||||
}
|
||||
|
||||
if (swRegistrationStarted) return
|
||||
swRegistrationStarted = true
|
||||
|
||||
registerSW({
|
||||
immediate: true,
|
||||
onRegistered() {
|
||||
console.info('PWA service worker registered')
|
||||
notifyPwaManifestReady()
|
||||
},
|
||||
onRegisterError(error: unknown) {
|
||||
console.warn('PWA service worker registration failed:', error)
|
||||
notifyPwaManifestReady()
|
||||
},
|
||||
onNeedRefresh() {
|
||||
console.log('New content available, please refresh.')
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('App ready to work offline.')
|
||||
notifyPwaManifestReady()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="version-update-fade">
|
||||
<div v-if="isUpdating || hasError" class="version-update-overlay" dir="rtl">
|
||||
<div class="version-update-card">
|
||||
<div v-if="!hasError" class="version-update-spinner" />
|
||||
|
||||
<div v-else class="version-update-error-icon">!</div>
|
||||
|
||||
<h2 class="version-update-title">
|
||||
{{ hasError ? 'خطا در بهروزرسانی' : 'در حال بهروزرسانی برنامه' }}
|
||||
</h2>
|
||||
|
||||
<p class="version-update-text">
|
||||
{{ statusText || 'لطفاً چند لحظه صبر کنید...' }}
|
||||
</p>
|
||||
|
||||
<button v-if="hasError" type="button" class="version-update-button" @click="retry">
|
||||
تلاش مجدد
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { setAppConfig } from '@/config/app-config'
|
||||
import { fetchFreshAppConfig } from '@/config/load-config'
|
||||
import { loadRemoteAppVersion, resetVersionCheckCache } from '@/config/load-version'
|
||||
import {
|
||||
shouldUpdateForVersion,
|
||||
canStartVersionUpdate,
|
||||
refreshConfigCacheForVersion,
|
||||
cancelVersionUpdateLock,
|
||||
hardReloadApp
|
||||
} from '@/config/config-cache'
|
||||
|
||||
const isUpdating = ref(false)
|
||||
const hasError = ref(false)
|
||||
const statusText = ref('')
|
||||
const pendingVersion = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await checkRemoteVersion()
|
||||
})
|
||||
|
||||
async function checkRemoteVersion() {
|
||||
try {
|
||||
const version = await loadRemoteAppVersion()
|
||||
|
||||
/**
|
||||
* خیلی مهم:
|
||||
* اگر get_version جواب نداد، هیچ کاری نکن.
|
||||
* نه cache پاک کن، نه reload بزن.
|
||||
* چون ممکنه کاربر فقط اینترنت ضعیف داشته باشه یا endpoint موقتاً 404/timeout بده.
|
||||
*/
|
||||
if (!version) {
|
||||
console.warn('Remote version is empty. Version check skipped.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!shouldUpdateForVersion(version)) return
|
||||
if (!canStartVersionUpdate(version)) return
|
||||
|
||||
pendingVersion.value = version
|
||||
|
||||
await startUpdate(version)
|
||||
} catch (error) {
|
||||
console.warn('Version check failed. Version update skipped:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function startUpdate(version: string) {
|
||||
try {
|
||||
isUpdating.value = true
|
||||
hasError.value = false
|
||||
|
||||
statusText.value = 'در حال آمادهسازی نسخه جدید...'
|
||||
await wait(500)
|
||||
|
||||
statusText.value = 'دریافت تنظیمات جدید برنامه...'
|
||||
|
||||
const freshConfig = await refreshConfigCacheForVersion(version, fetchFreshAppConfig)
|
||||
|
||||
setAppConfig(freshConfig)
|
||||
|
||||
await wait(500)
|
||||
|
||||
statusText.value = 'بارگذاری مجدد برنامه...'
|
||||
await wait(300)
|
||||
|
||||
hardReloadApp()
|
||||
} catch (error) {
|
||||
console.error('Version update failed:', error)
|
||||
|
||||
cancelVersionUpdateLock()
|
||||
|
||||
isUpdating.value = false
|
||||
hasError.value = true
|
||||
statusText.value = 'بهروزرسانی انجام نشد. لطفاً دوباره تلاش کنید.'
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
hasError.value = false
|
||||
|
||||
if (pendingVersion.value) {
|
||||
startUpdate(pendingVersion.value)
|
||||
return
|
||||
}
|
||||
|
||||
resetVersionCheckCache()
|
||||
void checkRemoteVersion()
|
||||
}
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.version-update-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 999999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: radial-gradient(
|
||||
circle at top,
|
||||
rgba(var(--colorPrimaryRGB, 37 99 235) / 0.16),
|
||||
transparent 38%
|
||||
),
|
||||
rgba(15, 23, 42, 0.38);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.version-update-card {
|
||||
width: min(390px, 100%);
|
||||
padding: 30px 22px 26px;
|
||||
border-radius: 24px;
|
||||
text-align: center;
|
||||
color: var(--colorText, #111827);
|
||||
background: var(--bg, #ffffff);
|
||||
border: 1px solid rgba(var(--colorPrimaryRGB, 37 99 235) / 0.18);
|
||||
box-shadow: 0 22px 70px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
|
||||
.version-update-spinner {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
margin: 0 auto 20px;
|
||||
border-radius: 999px;
|
||||
border: 4px solid rgba(var(--colorPrimaryRGB, 37 99 235) / 0.14);
|
||||
border-top-color: var(--colorPrimary, #2563eb);
|
||||
animation: version-update-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.version-update-error-icon {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
margin: 0 auto 20px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color6, #ffffff);
|
||||
background: var(--color5, #f44336);
|
||||
font-size: 34px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.version-update-title {
|
||||
margin: 0;
|
||||
color: var(--colorText, #111827);
|
||||
font-size: 19px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.version-update-text {
|
||||
margin: 12px 0 0;
|
||||
color: var(--colorText, #111827);
|
||||
opacity: 0.72;
|
||||
font-size: 14px;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.version-update-button {
|
||||
width: 100%;
|
||||
margin-top: 22px;
|
||||
padding: 12px 18px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
color: var(--color6, #ffffff);
|
||||
background: var(--colorPrimary, #2563eb);
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.version-update-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.version-update-fade-enter-active,
|
||||
.version-update-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.version-update-fade-enter-from,
|
||||
.version-update-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes version-update-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user