fix(pwa): tighten install support detection

This commit is contained in:
2026-07-04 11:18:01 +08:00
parent f470119a1b
commit 82ac9e86a5
5 changed files with 301 additions and 73 deletions
+85 -52
View File
@@ -4,17 +4,17 @@
*
* 设计要点:
* - SSR 安全:所有方法 `typeof window === "undefined"` 守卫,SSR 全部返 false / "unavailable"
* - 内部缓存 `beforeinstallprompt` 事件对象;install() 时如有缓存直接 prompt,否则等
* 事件触发(带超时),超时未到则返 "unavailable"
* - 只支持可触发原生 `beforeinstallprompt` 的平台 / 浏览器组合
* - 内部缓存 `beforeinstallprompt` 事件对象;install() 时没有缓存则直接返 "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 检测
* 浏览器白名单对齐 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
@@ -23,27 +23,56 @@ interface BeforeInstallPromptEvent extends Event {
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
/** install() 等待 beforeinstallprompt 事件的最大时长(ms */
const BEFORE_INSTALL_PROMPT_TIMEOUT_MS = 3000;
type PwaInstallListener = () => void;
export class PwaUtil {
// 模块级状态(单例内)—— 跨多次 install() 调用共享
private deferred: BeforeInstallPromptEvent | null = null;
private listenersAttached = false;
private readonly listeners = new Set<PwaInstallListener>();
// ===== 公共 API =====
/**
* 检查当前浏览器环境是否支持 PWA 安装。
*
* 判定:存在 `window.navigator.serviceWorker`。
* 注意:这只是"能力"层面的检测;具体能否触发安装还取决于 manifest、
* HTTPS 上下文、用户参与度(30s+ 互动)等因素 —— 这些由 `install()` 处理。
* 当前环境是否支持 Web 原生 PWA 安装提示
* 注意:这里判断的是平台 / 浏览器 / IAB 能力,不代表当前已经拿到 prompt 事件。
*/
isSupported(): boolean {
if (typeof window === "undefined") return false;
if (typeof navigator === "undefined") return false;
return "serviceWorker" in navigator;
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;
}
/**
@@ -57,6 +86,7 @@ export class PwaUtil {
*/
isInstalled(): boolean {
if (typeof window === "undefined") return false;
if (typeof navigator === "undefined") return false;
// Android / Chrome / Edge: display-mode media query
if (
@@ -75,6 +105,19 @@ export class PwaUtil {
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` 监听。
*
@@ -82,15 +125,17 @@ export class PwaUtil {
* 很容易错过那次事件,导致后续 `install()` 拿不到 deferred prompt。
*/
prepareInstallPrompt(): void {
if (!this.isSupported()) return;
if (!this.isSupported()) {
this.clearDeferred();
return;
}
if (this.isInstalled()) {
this.clearDeferred();
return;
}
this.attachListener();
}
/** 当前是否已缓存可用的原生安装提示事件。 */
canPromptInstall(): boolean {
return this.deferred !== null;
}
/**
* 触发 PWA 安装(浏览器原生安装提示)。
*
@@ -103,7 +148,7 @@ export class PwaUtil {
* 1) 不支持 / 已安装 → 直接返 "unavailable"
* 2) 挂 beforeinstallprompt 监听(懒初始化,挂一次)
* 3) 如已有 deferred 事件 → 直接 prompt
* 4) 否则等最多 BEFORE_INSTALL_PROMPT_TIMEOUT_MS,事件到了再 prompt
* 4) 否则返 "unavailable",由 UI 等下一次 beforeinstallprompt 通知后再展示入口
* 5) 用完一次 deferred 清空(浏览器规范:同一事件只能 prompt 一次)
*/
async install(): Promise<"accepted" | "dismissed" | "unavailable"> {
@@ -117,15 +162,9 @@ export class PwaUtil {
// 挂监听(首次 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 一次
this.clearDeferred(); // 一次性 —— 浏览器规范:同一事件只能 prompt 一次
try {
await deferred.prompt();
@@ -148,36 +187,30 @@ export class PwaUtil {
if (typeof window === "undefined") return;
const handler = (e: Event) => {
// 阻止浏览器默认行为(让应用自己决定何时 prompt)
// 阻止浏览器默认行为(让应用自己决定何时 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;
}
/**
* 轮询等待 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();
});
private clearDeferred(): void {
if (this.deferred === null) return;
this.deferred = null;
this.notifyListeners();
}
private notifyListeners(): void {
this.listeners.forEach((listener) => listener());
}
}
/** 全局单例(与 `deviceIdentifier` / `SpAsyncUtil` 命名约定一致) */