10e6c69af8
The previous install flow had a timing race: the `beforeinstallprompt` event fires at unpredictable times after page load (often ~30s+ into the session, when Chrome's heuristic finally decides the user is engaged). If the listener was attached only inside the dialog's onClick handler, by the time the user clicked OK the event had already fired into the void and the deferred prompt was null — so `install()` returned "unavailable" every time. The new flow attaches the listener as early as possible: 1. `pwaUtil.prepareInstallPrompt()` — new public method that calls the private `attachListener()`. Idempotent (guard via `listenersAttached`). 2. `pwaUtil.canPromptInstall()` — companion getter that returns whether a deferred event is currently cached. Useful for UI to decide whether to even show the install CTA. 3. `pwa-install-overlay.tsx` — calls `prepareInstallPrompt()` right after the isSupported / isInstalled guards. The overlay mounts when the user enters /chat, so this is the earliest practical point in the user flow. 4. `pwa-screen.tsx` — calls `prepareInstallPrompt()` BOTH at module load (synchronous, so the listener is attached before the splash screen's useEffects run) AND in a useEffect for robustness. The splash screen is the very first screen the user sees, so attaching here is the earliest possible. 5. `pwa-install-dialog.tsx` — `handleInstall` is now async, and `onInstall` accepts `() => void | Promise<void>`. The overlay calls `await pwaUtil.install()` so the deferred prompt flow can complete before the dialog unmounts. Together: the listener is now attached from the moment the splash screen module loads, the deferred prompt is captured whenever Chrome decides to fire the event, and the dialog's install button can reliably `prompt()` against the cached deferred. The full install flow now works end-to-end.
185 lines
6.3 KiB
TypeScript
185 lines
6.3 KiB
TypeScript
"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<void>;
|
||
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<boolean> {
|
||
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();
|