Files
boursecu_landing/src/modules/pwa/manifest.ts
T
2026-08-09 11:07:42 +03:30

590 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
})
}