"use client"; /** * PwaUtil — PWA 工具类 * * 设计要点: * - SSR 安全:所有方法 `typeof window === "undefined"` 守卫,SSR 全部返 false / "unavailable" * - 内部缓存 `beforeinstallprompt` 事件对象;install() 时如有缓存直接 prompt,否则等 * 事件触发(带超时),超时未到则返 "unavailable" * - install() 用完一次 deferred 后清空 —— 同一事件只能 prompt 一次(浏览器规范) * - 与 `usePwaInstall` hook 并存:hook 用于 React 组件内部监听 state, * 本 util 用于业务逻辑(如 ChatArea 想自动触发安装)—— 两者不冲突 * * 已知浏览器差异(2026/06): * - iOS Safari: 不发 `beforeinstallprompt`,需用户手动 Share → Add to Home Screen, * 本 util 在 iOS 上 isSupported()=true 但 install() 永远返 "unavailable" * - 已安装的 PWA: 浏览器不发 `beforeinstallprompt`,isInstalled() 走 standalone 检测 */ // `beforeinstallprompt` 事件 —— 浏览器非标准但主流都支持。 // 类型参考 MDN: https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent interface BeforeInstallPromptEvent extends Event { prompt(): Promise; userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; } /** install() 等待 beforeinstallprompt 事件的最大时长(ms) */ const BEFORE_INSTALL_PROMPT_TIMEOUT_MS = 3000; export class PwaUtil { // 模块级状态(单例内)—— 跨多次 install() 调用共享 private deferred: BeforeInstallPromptEvent | null = null; private listenersAttached = false; // ===== 公共 API ===== /** * 检查当前浏览器环境是否支持 PWA 安装。 * * 判定:存在 `window.navigator.serviceWorker`。 * 注意:这只是"能力"层面的检测;具体能否触发安装还取决于 manifest、 * HTTPS 上下文、用户参与度(30s+ 互动)等因素 —— 这些由 `install()` 处理。 */ isSupported(): boolean { if (typeof window === "undefined") return false; if (typeof navigator === "undefined") return false; return "serviceWorker" in navigator; } /** * 检查 PWA 是否已经以 standalone 模式安装。 * * 判定: * - Android / Chrome: `window.matchMedia("(display-mode: standalone)").matches` * - iOS Safari: `navigator.standalone === true` * * 已安装时不应再弹 install 提示。 */ isInstalled(): boolean { if (typeof window === "undefined") return false; // Android / Chrome / Edge: display-mode media query if ( typeof window.matchMedia === "function" && window.matchMedia("(display-mode: standalone)").matches ) { return true; } // iOS Safari: 私有属性 const navAny = navigator as Navigator & { standalone?: boolean }; if (navAny.standalone === true) { return true; } return false; } /** * 提前挂上 `beforeinstallprompt` 监听。 * * 目的:浏览器事件常常会在页面稳定后自行触发;如果等到用户点"安装"时才挂监听, * 很容易错过那次事件,导致后续 `install()` 拿不到 deferred prompt。 */ prepareInstallPrompt(): void { if (!this.isSupported()) return; this.attachListener(); } /** 当前是否已缓存可用的原生安装提示事件。 */ canPromptInstall(): boolean { return this.deferred !== null; } /** * 触发 PWA 安装(浏览器原生安装提示)。 * * 返回值: * - `"accepted"` 用户接受了安装 * - `"dismissed"` 用户关闭了提示 * - `"unavailable"` 不支持 / 已安装 / 超时未收到 beforeinstallprompt * * 流程: * 1) 不支持 / 已安装 → 直接返 "unavailable" * 2) 挂 beforeinstallprompt 监听(懒初始化,挂一次) * 3) 如已有 deferred 事件 → 直接 prompt * 4) 否则等最多 BEFORE_INSTALL_PROMPT_TIMEOUT_MS,事件到了再 prompt * 5) 用完一次 deferred 清空(浏览器规范:同一事件只能 prompt 一次) */ async install(): Promise<"accepted" | "dismissed" | "unavailable"> { if (!this.isSupported()) { return "unavailable"; } if (this.isInstalled()) { return "unavailable"; } // 挂监听(首次 install() 调用时挂上;之后重复调用复用同一 listener) this.attachListener(); // 如已有缓存的 deferred 直接用;否则等事件触发 if (!this.deferred) { const got = await this.waitForDeferred(); if (!got) return "unavailable"; } const deferred = this.deferred; if (!deferred) return "unavailable"; this.deferred = null; // 一次性 —— 浏览器规范:同一事件只能 prompt 一次 try { await deferred.prompt(); const choice = await deferred.userChoice; return choice.outcome; } catch { return "unavailable"; } } // ===== 私有助手 ===== /** * 挂 beforeinstallprompt 监听(仅首次 install() 调用时挂) * - 必须在客户端环境(isSupported() 已 guard) * - preventDefault() 阻止浏览器自动弹(让上层控制时机) */ private attachListener(): void { if (this.listenersAttached) return; if (typeof window === "undefined") return; const handler = (e: Event) => { // 阻止浏览器默认行为(让应用自己决定何时 prompt) e.preventDefault(); this.deferred = e as BeforeInstallPromptEvent; }; window.addEventListener("beforeinstallprompt", handler); this.listenersAttached = true; } /** * 轮询等待 deferred 事件就绪(带超时) * - 100ms 间隔轮询,足够响应且不阻塞主线程 * - 超时返 false —— 调用方走 "unavailable" 兜底 */ private waitForDeferred(timeoutMs: number = BEFORE_INSTALL_PROMPT_TIMEOUT_MS): Promise { return new Promise((resolve) => { const start = Date.now(); const tick = () => { if (this.deferred) { resolve(true); return; } if (Date.now() - start > timeoutMs) { resolve(false); return; } setTimeout(tick, 100); }; tick(); }); } } /** 全局单例(与 `deviceIdentifier` / `SpAsyncUtil` 命名约定一致) */ export const pwaUtil = new PwaUtil();