590 lines
17 KiB
TypeScript
590 lines
17 KiB
TypeScript
import type { AppConfig } from '@/config/types'
|
||||
|
|
import { getConfig, getRawAppConfig } from '@/config/app-config'
|
|||
|
|
import { getConfigUrls, sortConfigUrls } from '@/config/load-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'
|
|||
|
|
|
|||
|
|
type BeforeInstallPromptEventLike = Event & {
|
|||
|
|
prompt: () => Promise<void>
|
|||
|
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
|
|||
|
|
/** نگهداری رویداد beforeinstallprompt تا فراخوانی prompt() */
|
|||
|
|
let deferredInstallPrompt: BeforeInstallPromptEventLike | null = null
|
|||
|
|
let installCaptureInitialized = false
|
|||
|
|
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)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function configUrlToManifestUrl(configUrl: string) {
|
|||
|
|
return `${configUrl.replace(/\/+$/, '')}/manifest.json`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** همان ۵ URL پایهٔ get_user — اولویت با CONFIG_Success_URL */
|
|||
|
|
export function resolveManifestUrls(config?: AppConfig | null): string[] {
|
|||
|
|
const explicit = getConfig<string>('manifest_url')
|
|||
|
|
if (explicit) return [explicit]
|
|||
|
|
|
|||
|
|
return sortConfigUrls(getConfigUrls()).map(configUrlToManifestUrl)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** اولین کاندید — برای سازگاری */
|
|||
|
|
export function resolveManifestUrl(config?: AppConfig | null) {
|
|||
|
|
return resolveManifestUrls(config)[0] ?? ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** هر URL یک بار — مثل لوپ get_user */
|
|||
|
|
async function fetchManifestTryingUrls(
|
|||
|
|
urls: string[]
|
|||
|
|
): Promise<{ manifest: PwaManifest; manifestUrl: string }> {
|
|||
|
|
if (!urls.length) {
|
|||
|
|
throw new Error('No manifest URL candidates')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let lastError: unknown = null
|
|||
|
|
|
|||
|
|
for (let index = 0; index < urls.length; index++) {
|
|||
|
|
const url = urls[index]
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const manifest = await fetchManifestOnce(url)
|
|||
|
|
return { manifest, manifestUrl: url }
|
|||
|
|
} catch (error) {
|
|||
|
|
lastError = error
|
|||
|
|
console.warn(`PWA manifest failed (${url}), candidate ${index + 1}/${urls.length}`, error)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (index < urls.length - 1) {
|
|||
|
|
await sleep(MANIFEST_RETRY_DELAY_MS)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
throw lastError ?? new Error('PWA manifest could not be loaded from any server')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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')
|
|||
|
|
upsertMeta('apple-mobile-web-app-status-bar-style', 'black-translucent')
|
|||
|
|
|
|||
|
|
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'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getDeferredPwaInstallPrompt() {
|
|||
|
|
return deferredInstallPrompt
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** اگر deferred prompt موجود باشد، دیالوگ نصب مرورگر را نشان میدهد */
|
|||
|
|
export async function promptDeferredPwaInstall(): Promise<boolean> {
|
|||
|
|
const event = deferredInstallPrompt
|
|||
|
|
if (!event || typeof event.prompt !== 'function') return false
|
|||
|
|
|
|||
|
|
deferredInstallPrompt = null
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await event.prompt()
|
|||
|
|
await event.userChoice.catch(() => undefined)
|
|||
|
|
return true
|
|||
|
|
} catch {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** هرچه زودتر در main.ts — قبل از bootstrap */
|
|||
|
|
export function initPwaInstallCapture() {
|
|||
|
|
if (installCaptureInitialized || typeof window === 'undefined') return
|
|||
|
|
installCaptureInitialized = true
|
|||
|
|
|
|||
|
|
window.addEventListener('beforeinstallprompt', (e) => {
|
|||
|
|
e.preventDefault()
|
|||
|
|
deferredInstallPrompt = e as BeforeInstallPromptEventLike
|
|||
|
|
|
|||
|
|
if (isPwaManifestReady()) {
|
|||
|
|
notifyPwaManifestReady(e)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.info('PWA install prompt captured, waiting for manifest + service worker')
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function notifyPwaManifestReady(installEvent?: Event | null) {
|
|||
|
|
if (!isPwaManifestReady()) return
|
|||
|
|
|
|||
|
|
if (installEvent) {
|
|||
|
|
deferredInstallPrompt = installEvent as BeforeInstallPromptEventLike
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const event = installEvent ?? deferredInstallPrompt
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 نمیشود — manifest از همان ۵ URL پایهٔ get_user امتحان میشود.
|
|||
|
|
*/
|
|||
|
|
export async function setupPwaManifest(config?: AppConfig | null): Promise<boolean> {
|
|||
|
|
const raw = config ?? getRawAppConfig()
|
|||
|
|
const userId = raw?.user?.id
|
|||
|
|
const configBase = getConfigBaseFromStorage()
|
|||
|
|
const manifestUrls = resolveManifestUrls(raw)
|
|||
|
|
|
|||
|
|
if (!manifestUrls.length) {
|
|||
|
|
console.warn('PWA manifest skipped: no manifest URL candidates')
|
|||
|
|
removeDynamicManifestLinks()
|
|||
|
|
setPwaManifestReady(false)
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const cached = configBase ? readCachedPwaManifest(userId, configBase) : null
|
|||
|
|
|
|||
|
|
if (cached && (await applyCachedManifestEntry(cached))) {
|
|||
|
|
console.info('PWA manifest applied from cache:', cached.manifest_url)
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const { manifest, manifestUrl } = await fetchManifestTryingUrls(manifestUrls)
|
|||
|
|
|
|||
|
|
const prepared = prepareManifestForInstall(manifest)
|
|||
|
|
|
|||
|
|
await applyPreparedManifestToDocument(prepared, manifestUrl)
|
|||
|
|
setPwaManifestReady(true)
|
|||
|
|
|
|||
|
|
if (userId != null && userId !== '') {
|
|||
|
|
const effectiveConfigBase =
|
|||
|
|
configBase || normalizeBaseUrl(manifestUrl.replace(/\/manifest\.json$/i, ''))
|
|||
|
|
|
|||
|
|
writeCachedPwaManifest({
|
|||
|
|
cached_at: Date.now(),
|
|||
|
|
user_id: userId,
|
|||
|
|
config_base: effectiveConfigBase,
|
|||
|
|
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())
|
|||
|
|
})
|
|||
|
|
}
|