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 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('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 { 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) { const selectorParts = Object.entries(attrs) .filter(([key]) => key !== 'href') .map(([key, value]) => `[${key}="${value}"]`) .join('') let link = document.querySelector(`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(`meta[name="${name}"]`) if (!meta) { meta = document.createElement('meta') meta.name = name document.head.appendChild(meta) } meta.content = content } function removeAllManifestLinks() { document.querySelectorAll("link[rel='manifest']").forEach((node) => node.remove()) } function removeDynamicManifestLinks() { removeAllManifestLinks() revokeManifestBlobUrl() document .querySelectorAll("link[rel='apple-touch-icon'][data-dynamic-pwa='true']") .forEach((node) => node.remove()) } function applyAppleTouchIconsFromManifest(manifest: PwaManifest) { document .querySelectorAll("link[rel='apple-touch-icon'][data-dynamic-pwa='true']") .forEach((node) => node.remove()) const appleSizes = [180, 167, 152, 120] const used = new Set() 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 { 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 ): Promise { 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 { 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()) }) }