Files
cozsweet-frontend-nextjs/src/utils/pwa.ts
T

218 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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();