218 lines
6.8 KiB
TypeScript
218 lines
6.8 KiB
TypeScript
"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<void>;
|
||
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<PwaInstallListener>();
|
||
|
||
// ===== 公共 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();
|