"use client"; /** * PwaUtil — PWA 工具类 * * 设计要点: * - SSR 安全:所有方法 `typeof window === "undefined"` 守卫,SSR 全部返 false / "unavailable" * - 只支持可触发原生 `beforeinstallprompt` 的平台 / 浏览器组合 * - 内部缓存 `beforeinstallprompt` 事件对象;install() 时没有缓存则直接返 "unavailable" * - install() 用完一次 deferred 后清空 —— 同一事件只能 prompt 一次(浏览器规范) * * 浏览器白名单对齐 Flutter 侧 pwa_utils.dart: * - Desktop: Chrome, Edge, Opera * - Mobile: Android Chrome, Android Opera, Samsung Internet * - 应用内浏览器 / WebView 不展示安装入口 */ import { BrowserDetector } from "./browser-detect"; import { PlatformDetector } from "./platform-detect"; // `beforeinstallprompt` 事件 —— 浏览器非标准但主流都支持。 // 类型参考 MDN: https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent interface BeforeInstallPromptEvent extends Event { prompt(): Promise; userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; } type PwaInstallListener = () => void; export class PwaUtil { // 模块级状态(单例内)—— 跨多次 install() 调用共享 private deferred: BeforeInstallPromptEvent | null = null; private listenersAttached = false; private readonly listeners = new Set(); // ===== 公共 API ===== /** * 当前环境是否支持 Web 原生 PWA 安装提示。 * 注意:这里判断的是平台 / 浏览器 / IAB 能力,不代表当前已经拿到 prompt 事件。 */ isSupported(): boolean { if (typeof window === "undefined") return false; if (typeof navigator === "undefined") return false; if (!("serviceWorker" in navigator)) return false; if (BrowserDetector.isInAppBrowser()) return false; return this.isBrowserSupported(); } /** * 当前浏览器是否位于原生安装提示白名单。 * * 对齐 Flutter 侧逻辑: * - Desktop: Chrome, Edge, Opera * - Mobile: Android Chrome, Android Opera, Samsung Internet */ isBrowserSupported(): boolean { if (typeof window === "undefined") return false; const platform = PlatformDetector.getPlatformInfo(); if (platform.isDesktop) { return ( BrowserDetector.isChrome() || BrowserDetector.isEdge() || BrowserDetector.isOpera() ); } if (platform.isMobile && platform.isAndroid) { return ( BrowserDetector.isChrome() || BrowserDetector.isOpera() || BrowserDetector.isSamsungInternet() ); } return false; } /** * 检查 PWA 是否已经以 standalone 模式安装。 * * 判定: * - Android / Chrome: `window.matchMedia("(display-mode: standalone)").matches` * - iOS Safari: `navigator.standalone === true` * * 已安装时不应再弹 install 提示。 */ isInstalled(): boolean { if (typeof window === "undefined") return false; if (typeof navigator === "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; } /** UI 是否应该展示“安装应用”入口。 */ canShowInstallEntry(): boolean { return this.isSupported() && !this.isInstalled() && this.deferred !== null; } /** 监听 beforeinstallprompt 捕获 / 安装状态变化,用于 UI 刷新安装入口展示状态。 */ subscribe(listener: PwaInstallListener): () => void { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } /** * 提前挂上 `beforeinstallprompt` 监听。 * * 目的:浏览器事件常常会在页面稳定后自行触发;如果等到用户点"安装"时才挂监听, * 很容易错过那次事件,导致后续 `install()` 拿不到 deferred prompt。 */ prepareInstallPrompt(): void { if (!this.isSupported()) { this.clearDeferred(); return; } if (this.isInstalled()) { this.clearDeferred(); return; } this.attachListener(); } /** * 触发 PWA 安装(浏览器原生安装提示)。 * * 返回值: * - `"accepted"` 用户接受了安装 * - `"dismissed"` 用户关闭了提示 * - `"unavailable"` 不支持 / 已安装 / 超时未收到 beforeinstallprompt * * 流程: * 1) 不支持 / 已安装 → 直接返 "unavailable" * 2) 挂 beforeinstallprompt 监听(懒初始化,挂一次) * 3) 如已有 deferred 事件 → 直接 prompt * 4) 否则返 "unavailable",由 UI 等下一次 beforeinstallprompt 通知后再展示入口 * 5) 用完一次 deferred 清空(浏览器规范:同一事件只能 prompt 一次) */ async install(): Promise<"accepted" | "dismissed" | "unavailable"> { if (!this.isSupported()) { return "unavailable"; } if (this.isInstalled()) { return "unavailable"; } // 挂监听(首次 install() 调用时挂上;之后重复调用复用同一 listener) this.attachListener(); const deferred = this.deferred; if (!deferred) return "unavailable"; this.clearDeferred(); // 一次性 —— 浏览器规范:同一事件只能 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(); if (!this.isSupported() || this.isInstalled()) { this.clearDeferred(); return; } this.deferred = e as BeforeInstallPromptEvent; this.notifyListeners(); }; window.addEventListener("beforeinstallprompt", handler); this.listenersAttached = true; } private clearDeferred(): void { if (this.deferred === null) return; this.deferred = null; this.notifyListeners(); } private notifyListeners(): void { this.listeners.forEach((listener) => listener()); } } /** 全局单例(与 `deviceIdentifier` / `SpAsyncUtil` 命名约定一致) */ export const pwaUtil = new PwaUtil();