From 5be40949a1bfba15eee091e44c2a51265f7d5863 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 16 Jun 2026 18:59:17 +0800 Subject: [PATCH] feat(pwa): implement PwaUtil class for PWA support and installation checks --- .../chat/components/pwa-install-overlay.tsx | 14 +- src/utils/pwa.ts | 173 ++++++++++++++++++ 2 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 src/utils/pwa.ts diff --git a/src/app/chat/components/pwa-install-overlay.tsx b/src/app/chat/components/pwa-install-overlay.tsx index 2014f2be..3cbac902 100644 --- a/src/app/chat/components/pwa-install-overlay.tsx +++ b/src/app/chat/components/pwa-install-overlay.tsx @@ -3,7 +3,8 @@ * PwaInstallOverlay PWA 安装提示触发器 * * 行为: - * - 检查 PWA 支持 + * - 检查 PWA 支持(pwaUtil.isSupported) + * - 检查 PWA 是否已安装(pwaUtil.isInstalled)—— 已安装则不弹 * - 检查每日是否已显示(使用 AppStorage 持久化) * - 延迟 3.5s 后弹出 PwaInstallDialog * - 生产环境有效;开发环境 1s 后立即弹出(测试用) @@ -14,6 +15,7 @@ import { useEffect } from "react"; import { SpAsyncUtil } from "@/utils/storage"; +import { pwaUtil } from "@/utils/pwa"; import { PwaInstallDialog } from "./pwa-install-dialog"; import styles from "./pwa-install-overlay.module.css"; @@ -25,16 +27,18 @@ export function PwaInstallOverlay() { let mounted = true; const init = async () => { + // 0) PWA 支持 / 安装 双重门禁: + // isSupported() 检查 serviceWorker + window(SSR 安全) + // isInstalled() 检查 standalone display-mode(避免给已安装用户再弹) + if (!pwaUtil.isSupported()) return; + if (pwaUtil.isInstalled()) return; + // 检查每日是否已显示 const lastResult = await SpAsyncUtil.getString(PWA_DIALOG_KEY); if (!lastResult.success) return; const today = new Date().toISOString().slice(0, 10); if (lastResult.data === today) return; // 今天已显示过 - // 检查 PWA 支持 - if (typeof window === "undefined") return; - if (!("serviceWorker" in navigator)) return; - // 延迟 3.5s 后显示 setTimeout(() => { if (!mounted) return; diff --git a/src/utils/pwa.ts b/src/utils/pwa.ts new file mode 100644 index 00000000..835f1b04 --- /dev/null +++ b/src/utils/pwa.ts @@ -0,0 +1,173 @@ +"use client"; +/** + * PwaUtil — PWA 工具类 + * + * 三个核心能力(与 [src/hooks/use-pwa-install.ts] 对齐,但面向非 React 代码): + * - `isSupported()` 浏览器是否支持 PWA 安装(serviceWorker + manifest) + * - `isInstalled()` PWA 是否已安装为 standalone(避免重复提示) + * - `install()` 触发浏览器原生安装流程(`beforeinstallprompt`) + * + * 设计要点: + * - 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; + } + + /** + * 触发 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(); \ No newline at end of file