fix(pwa): tighten install support detection
This commit is contained in:
@@ -19,7 +19,7 @@ import {
|
|||||||
canShowPwaInstallPromptOnce,
|
canShowPwaInstallPromptOnce,
|
||||||
recordPwaInstallPromptShown,
|
recordPwaInstallPromptShown,
|
||||||
} from "@/lib/chat/pwa_install_prompt";
|
} from "@/lib/chat/pwa_install_prompt";
|
||||||
import { BrowserDetector, pwaUtil } from "@/utils";
|
import { pwaUtil } from "@/utils";
|
||||||
|
|
||||||
import { PwaInstallDialog } from "./pwa-install-dialog";
|
import { PwaInstallDialog } from "./pwa-install-dialog";
|
||||||
import styles from "./pwa-install-overlay.module.css";
|
import styles from "./pwa-install-overlay.module.css";
|
||||||
@@ -37,29 +37,39 @@ export function PwaInstallOverlay({ enabled }: PwaInstallOverlayProps) {
|
|||||||
|
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
// const isDev = AppEnvUtil.isDevelopment();
|
// const isDev = AppEnvUtil.isDevelopment();
|
||||||
const isDev = false;
|
const isDev = false;
|
||||||
const shouldShowDialog = await shouldShowPwaInstallDialog(isDev);
|
|
||||||
if (!shouldShowDialog) return;
|
|
||||||
|
|
||||||
// 提前挂 beforeinstallprompt 监听,避免用户点 OK 时事件已在更早时机丢失。
|
// 提前挂 beforeinstallprompt 监听,避免用户点 OK 时事件已在更早时机丢失。
|
||||||
pwaUtil.prepareInstallPrompt();
|
pwaUtil.prepareInstallPrompt();
|
||||||
|
|
||||||
// 达到聊天数量阈值后稍作停顿,避免弹窗与消息刷新挤在同一瞬间。
|
const tryScheduleDialog = async () => {
|
||||||
const delay = isDev
|
const shouldShowDialog = await shouldShowPwaInstallDialog(isDev);
|
||||||
? DEVELOPMENT_DIALOG_DELAY_MS
|
if (!mounted || !shouldShowDialog || timer !== undefined) return;
|
||||||
: PRODUCTION_DIALOG_DELAY_MS;
|
|
||||||
timer = window.setTimeout(() => {
|
// 达到聊天数量阈值后稍作停顿,避免弹窗与消息刷新挤在同一瞬间。
|
||||||
if (!mounted) return;
|
const delay = isDev
|
||||||
showPwaDialog();
|
? DEVELOPMENT_DIALOG_DELAY_MS
|
||||||
}, delay);
|
: PRODUCTION_DIALOG_DELAY_MS;
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
showPwaDialog();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
await tryScheduleDialog();
|
||||||
|
unsubscribe = pwaUtil.subscribe(() => {
|
||||||
|
void tryScheduleDialog();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
void init();
|
void init();
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
|
unsubscribe?.();
|
||||||
if (timer !== undefined) window.clearTimeout(timer);
|
if (timer !== undefined) window.clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [enabled]);
|
}, [enabled]);
|
||||||
@@ -69,9 +79,7 @@ export function PwaInstallOverlay({ enabled }: PwaInstallOverlayProps) {
|
|||||||
|
|
||||||
async function shouldShowPwaInstallDialog(isDevelopment: boolean) {
|
async function shouldShowPwaInstallDialog(isDevelopment: boolean) {
|
||||||
// PWA 支持 / 安装 双重门禁(两种环境都检查)。
|
// PWA 支持 / 安装 双重门禁(两种环境都检查)。
|
||||||
if (!pwaUtil.isSupported()) return false;
|
if (!pwaUtil.canShowInstallEntry()) return false;
|
||||||
if (pwaUtil.isInstalled()) return false;
|
|
||||||
if (BrowserDetector.isInAppBrowser()) return false;
|
|
||||||
|
|
||||||
// 开发环境:跳过"只弹一次"门禁,每次进入都弹,方便 UI 测试。
|
// 开发环境:跳过"只弹一次"门禁,每次进入都弹,方便 UI 测试。
|
||||||
if (isDevelopment) return true;
|
if (isDevelopment) return true;
|
||||||
|
|||||||
@@ -43,22 +43,27 @@ export function SidebarScreen() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const updateInstallEntryVisibility = () => {
|
||||||
|
setCanShowInstallApp(pwaUtil.canShowInstallEntry());
|
||||||
|
};
|
||||||
|
|
||||||
pwaUtil.prepareInstallPrompt();
|
pwaUtil.prepareInstallPrompt();
|
||||||
const timer = window.setTimeout(() => {
|
updateInstallEntryVisibility();
|
||||||
setCanShowInstallApp(pwaUtil.isSupported() && !pwaUtil.isInstalled());
|
const unsubscribe = pwaUtil.subscribe(updateInstallEntryVisibility);
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(timer);
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleInstallAppClick = async () => {
|
const handleInstallAppClick = async () => {
|
||||||
pwaUtil.prepareInstallPrompt();
|
pwaUtil.prepareInstallPrompt();
|
||||||
const result = await pwaUtil.install();
|
const result = await pwaUtil.install();
|
||||||
if (result === "accepted" || pwaUtil.isInstalled()) {
|
setCanShowInstallApp(
|
||||||
setCanShowInstallApp(false);
|
result !== "accepted" &&
|
||||||
}
|
!pwaUtil.isInstalled() &&
|
||||||
|
pwaUtil.canShowInstallEntry(),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogoutClick = () => {
|
const handleLogoutClick = () => {
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { PwaUtil } from "../pwa";
|
||||||
|
|
||||||
|
const ANDROID_CHROME_UA =
|
||||||
|
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36";
|
||||||
|
const ANDROID_FACEBOOK_UA =
|
||||||
|
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/126.0.0.0 Mobile Safari/537.36 [FB_IAB/FB4A;FBAV/566.0.0.48.73;]";
|
||||||
|
const ANDROID_SAMSUNG_UA =
|
||||||
|
"Mozilla/5.0 (Linux; Android 14; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36";
|
||||||
|
const IOS_SAFARI_UA =
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1";
|
||||||
|
const DESKTOP_CHROME_UA =
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
|
||||||
|
const DESKTOP_FIREFOX_UA =
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14.5; rv:126.0) Gecko/20100101 Firefox/126.0";
|
||||||
|
|
||||||
|
describe("PwaUtil", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setUserAgent(DESKTOP_CHROME_UA);
|
||||||
|
setServiceWorkerSupported(true);
|
||||||
|
setStandalone(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports native install prompt on desktop Chrome", () => {
|
||||||
|
setUserAgent(DESKTOP_CHROME_UA);
|
||||||
|
|
||||||
|
expect(new PwaUtil().isSupported()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports native install prompt on Android Chrome", () => {
|
||||||
|
setUserAgent(ANDROID_CHROME_UA);
|
||||||
|
|
||||||
|
expect(new PwaUtil().isSupported()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports native install prompt on Samsung Internet", () => {
|
||||||
|
setUserAgent(ANDROID_SAMSUNG_UA);
|
||||||
|
|
||||||
|
expect(new PwaUtil().isSupported()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not support native install prompt on iOS Safari", () => {
|
||||||
|
setUserAgent(IOS_SAFARI_UA);
|
||||||
|
|
||||||
|
expect(new PwaUtil().isSupported()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not support install prompt in Facebook in-app browser", () => {
|
||||||
|
setUserAgent(ANDROID_FACEBOOK_UA);
|
||||||
|
|
||||||
|
expect(new PwaUtil().isSupported()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not support native install prompt on desktop Firefox", () => {
|
||||||
|
setUserAgent(DESKTOP_FIREFOX_UA);
|
||||||
|
|
||||||
|
expect(new PwaUtil().isSupported()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show install entry before beforeinstallprompt is captured", () => {
|
||||||
|
const util = new PwaUtil();
|
||||||
|
util.prepareInstallPrompt();
|
||||||
|
|
||||||
|
expect(util.canShowInstallEntry()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows install entry after beforeinstallprompt is captured", () => {
|
||||||
|
const util = new PwaUtil();
|
||||||
|
const listener = vi.fn();
|
||||||
|
util.subscribe(listener);
|
||||||
|
util.prepareInstallPrompt();
|
||||||
|
|
||||||
|
dispatchBeforeInstallPrompt();
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
expect(util.canShowInstallEntry()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show install entry when already installed", () => {
|
||||||
|
const util = new PwaUtil();
|
||||||
|
setStandalone(true);
|
||||||
|
util.prepareInstallPrompt();
|
||||||
|
|
||||||
|
dispatchBeforeInstallPrompt();
|
||||||
|
|
||||||
|
expect(util.canShowInstallEntry()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompts native install when a deferred event is available", async () => {
|
||||||
|
const util = new PwaUtil();
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
util.prepareInstallPrompt();
|
||||||
|
dispatchBeforeInstallPrompt({
|
||||||
|
prompt,
|
||||||
|
outcome: "accepted",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(util.install()).resolves.toBe("accepted");
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(util.canShowInstallEntry()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unavailable when no deferred prompt is available", async () => {
|
||||||
|
const util = new PwaUtil();
|
||||||
|
util.prepareInstallPrompt();
|
||||||
|
|
||||||
|
await expect(util.install()).resolves.toBe("unavailable");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function setUserAgent(userAgent: string): void {
|
||||||
|
Object.defineProperty(window.navigator, "userAgent", {
|
||||||
|
value: userAgent,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setServiceWorkerSupported(supported: boolean): void {
|
||||||
|
if (supported) {
|
||||||
|
Object.defineProperty(window.navigator, "serviceWorker", {
|
||||||
|
value: {},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(window.navigator, "serviceWorker", {
|
||||||
|
value: undefined,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStandalone(installed: boolean): void {
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: installed && query === "(display-mode: standalone)",
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window.navigator, "standalone", {
|
||||||
|
value: installed,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchBeforeInstallPrompt({
|
||||||
|
prompt = vi.fn().mockResolvedValue(undefined),
|
||||||
|
outcome = "dismissed",
|
||||||
|
}: {
|
||||||
|
prompt?: () => Promise<void>;
|
||||||
|
outcome?: "accepted" | "dismissed";
|
||||||
|
} = {}): void {
|
||||||
|
const event = new Event("beforeinstallprompt", {
|
||||||
|
cancelable: true,
|
||||||
|
}) as Event & {
|
||||||
|
prompt: () => Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||||
|
};
|
||||||
|
event.prompt = prompt;
|
||||||
|
event.userChoice = Promise.resolve({ outcome });
|
||||||
|
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}
|
||||||
@@ -67,6 +67,16 @@ export class BrowserDetector {
|
|||||||
return /Edge/i.test(BrowserDetector.getBrowserName(ua));
|
return /Edge/i.test(BrowserDetector.getBrowserName(ua));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static isOpera(ua: string | null = null): boolean {
|
||||||
|
const target = `${BrowserDetector.getBrowserName(ua)} ${BrowserDetector.getUserAgent(ua)}`;
|
||||||
|
return /Opera|OPR/i.test(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
static isSamsungInternet(ua: string | null = null): boolean {
|
||||||
|
const target = `${BrowserDetector.getBrowserName(ua)} ${BrowserDetector.getUserAgent(ua)}`;
|
||||||
|
return /Samsung|SamsungBrowser/i.test(target);
|
||||||
|
}
|
||||||
|
|
||||||
/** 当前是否在 Facebook 内嵌浏览器 */
|
/** 当前是否在 Facebook 内嵌浏览器 */
|
||||||
static isFacebookInAppBrowser(ua: string | null = null): boolean {
|
static isFacebookInAppBrowser(ua: string | null = null): boolean {
|
||||||
return /FBAN|FBAV|FB_IAB|FB4A|FBIOS/i.test(BrowserDetector.getUserAgent(ua));
|
return /FBAN|FBAV|FB_IAB|FB4A|FBIOS/i.test(BrowserDetector.getUserAgent(ua));
|
||||||
|
|||||||
+85
-52
@@ -4,17 +4,17 @@
|
|||||||
*
|
*
|
||||||
* 设计要点:
|
* 设计要点:
|
||||||
* - SSR 安全:所有方法 `typeof window === "undefined"` 守卫,SSR 全部返 false / "unavailable"
|
* - SSR 安全:所有方法 `typeof window === "undefined"` 守卫,SSR 全部返 false / "unavailable"
|
||||||
* - 内部缓存 `beforeinstallprompt` 事件对象;install() 时如有缓存直接 prompt,否则等
|
* - 只支持可触发原生 `beforeinstallprompt` 的平台 / 浏览器组合
|
||||||
* 事件触发(带超时),超时未到则返 "unavailable"
|
* - 内部缓存 `beforeinstallprompt` 事件对象;install() 时没有缓存则直接返 "unavailable"
|
||||||
* - install() 用完一次 deferred 后清空 —— 同一事件只能 prompt 一次(浏览器规范)
|
* - install() 用完一次 deferred 后清空 —— 同一事件只能 prompt 一次(浏览器规范)
|
||||||
* - 与 `usePwaInstall` hook 并存:hook 用于 React 组件内部监听 state,
|
|
||||||
* 本 util 用于业务逻辑(如 ChatArea 想自动触发安装)—— 两者不冲突
|
|
||||||
*
|
*
|
||||||
* 已知浏览器差异(2026/06):
|
* 浏览器白名单对齐 Flutter 侧 pwa_utils.dart:
|
||||||
* - iOS Safari: 不发 `beforeinstallprompt`,需用户手动 Share → Add to Home Screen,
|
* - Desktop: Chrome, Edge, Opera
|
||||||
* 本 util 在 iOS 上 isSupported()=true 但 install() 永远返 "unavailable"
|
* - Mobile: Android Chrome, Android Opera, Samsung Internet
|
||||||
* - 已安装的 PWA: 浏览器不发 `beforeinstallprompt`,isInstalled() 走 standalone 检测
|
* - 应用内浏览器 / WebView 不展示安装入口
|
||||||
*/
|
*/
|
||||||
|
import { BrowserDetector } from "./browser-detect";
|
||||||
|
import { PlatformDetector } from "./platform-detect";
|
||||||
|
|
||||||
// `beforeinstallprompt` 事件 —— 浏览器非标准但主流都支持。
|
// `beforeinstallprompt` 事件 —— 浏览器非标准但主流都支持。
|
||||||
// 类型参考 MDN: https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent
|
// 类型参考 MDN: https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent
|
||||||
@@ -23,27 +23,56 @@ interface BeforeInstallPromptEvent extends Event {
|
|||||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** install() 等待 beforeinstallprompt 事件的最大时长(ms) */
|
type PwaInstallListener = () => void;
|
||||||
const BEFORE_INSTALL_PROMPT_TIMEOUT_MS = 3000;
|
|
||||||
|
|
||||||
export class PwaUtil {
|
export class PwaUtil {
|
||||||
// 模块级状态(单例内)—— 跨多次 install() 调用共享
|
// 模块级状态(单例内)—— 跨多次 install() 调用共享
|
||||||
private deferred: BeforeInstallPromptEvent | null = null;
|
private deferred: BeforeInstallPromptEvent | null = null;
|
||||||
private listenersAttached = false;
|
private listenersAttached = false;
|
||||||
|
private readonly listeners = new Set<PwaInstallListener>();
|
||||||
|
|
||||||
// ===== 公共 API =====
|
// ===== 公共 API =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查当前浏览器环境是否支持 PWA 安装。
|
* 当前环境是否支持 Web 原生 PWA 安装提示。
|
||||||
*
|
* 注意:这里判断的是平台 / 浏览器 / IAB 能力,不代表当前已经拿到 prompt 事件。
|
||||||
* 判定:存在 `window.navigator.serviceWorker`。
|
|
||||||
* 注意:这只是"能力"层面的检测;具体能否触发安装还取决于 manifest、
|
|
||||||
* HTTPS 上下文、用户参与度(30s+ 互动)等因素 —— 这些由 `install()` 处理。
|
|
||||||
*/
|
*/
|
||||||
isSupported(): boolean {
|
isSupported(): boolean {
|
||||||
if (typeof window === "undefined") return false;
|
if (typeof window === "undefined") return false;
|
||||||
if (typeof navigator === "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 {
|
isInstalled(): boolean {
|
||||||
if (typeof window === "undefined") return false;
|
if (typeof window === "undefined") return false;
|
||||||
|
if (typeof navigator === "undefined") return false;
|
||||||
|
|
||||||
// Android / Chrome / Edge: display-mode media query
|
// Android / Chrome / Edge: display-mode media query
|
||||||
if (
|
if (
|
||||||
@@ -75,6 +105,19 @@ export class PwaUtil {
|
|||||||
return false;
|
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` 监听。
|
* 提前挂上 `beforeinstallprompt` 监听。
|
||||||
*
|
*
|
||||||
@@ -82,15 +125,17 @@ export class PwaUtil {
|
|||||||
* 很容易错过那次事件,导致后续 `install()` 拿不到 deferred prompt。
|
* 很容易错过那次事件,导致后续 `install()` 拿不到 deferred prompt。
|
||||||
*/
|
*/
|
||||||
prepareInstallPrompt(): void {
|
prepareInstallPrompt(): void {
|
||||||
if (!this.isSupported()) return;
|
if (!this.isSupported()) {
|
||||||
|
this.clearDeferred();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.isInstalled()) {
|
||||||
|
this.clearDeferred();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.attachListener();
|
this.attachListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前是否已缓存可用的原生安装提示事件。 */
|
|
||||||
canPromptInstall(): boolean {
|
|
||||||
return this.deferred !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 触发 PWA 安装(浏览器原生安装提示)。
|
* 触发 PWA 安装(浏览器原生安装提示)。
|
||||||
*
|
*
|
||||||
@@ -103,7 +148,7 @@ export class PwaUtil {
|
|||||||
* 1) 不支持 / 已安装 → 直接返 "unavailable"
|
* 1) 不支持 / 已安装 → 直接返 "unavailable"
|
||||||
* 2) 挂 beforeinstallprompt 监听(懒初始化,挂一次)
|
* 2) 挂 beforeinstallprompt 监听(懒初始化,挂一次)
|
||||||
* 3) 如已有 deferred 事件 → 直接 prompt
|
* 3) 如已有 deferred 事件 → 直接 prompt
|
||||||
* 4) 否则等最多 BEFORE_INSTALL_PROMPT_TIMEOUT_MS,事件到了再 prompt
|
* 4) 否则返 "unavailable",由 UI 等下一次 beforeinstallprompt 通知后再展示入口
|
||||||
* 5) 用完一次 deferred 清空(浏览器规范:同一事件只能 prompt 一次)
|
* 5) 用完一次 deferred 清空(浏览器规范:同一事件只能 prompt 一次)
|
||||||
*/
|
*/
|
||||||
async install(): Promise<"accepted" | "dismissed" | "unavailable"> {
|
async install(): Promise<"accepted" | "dismissed" | "unavailable"> {
|
||||||
@@ -117,15 +162,9 @@ export class PwaUtil {
|
|||||||
// 挂监听(首次 install() 调用时挂上;之后重复调用复用同一 listener)
|
// 挂监听(首次 install() 调用时挂上;之后重复调用复用同一 listener)
|
||||||
this.attachListener();
|
this.attachListener();
|
||||||
|
|
||||||
// 如已有缓存的 deferred 直接用;否则等事件触发
|
|
||||||
if (!this.deferred) {
|
|
||||||
const got = await this.waitForDeferred();
|
|
||||||
if (!got) return "unavailable";
|
|
||||||
}
|
|
||||||
|
|
||||||
const deferred = this.deferred;
|
const deferred = this.deferred;
|
||||||
if (!deferred) return "unavailable";
|
if (!deferred) return "unavailable";
|
||||||
this.deferred = null; // 一次性 —— 浏览器规范:同一事件只能 prompt 一次
|
this.clearDeferred(); // 一次性 —— 浏览器规范:同一事件只能 prompt 一次
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deferred.prompt();
|
await deferred.prompt();
|
||||||
@@ -148,36 +187,30 @@ export class PwaUtil {
|
|||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const handler = (e: Event) => {
|
const handler = (e: Event) => {
|
||||||
// 阻止浏览器默认行为(让应用自己决定何时 prompt)
|
// 阻止浏览器默认行为(让应用自己决定何时 prompt)。
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!this.isSupported() || this.isInstalled()) {
|
||||||
|
this.clearDeferred();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.deferred = e as BeforeInstallPromptEvent;
|
this.deferred = e as BeforeInstallPromptEvent;
|
||||||
|
this.notifyListeners();
|
||||||
};
|
};
|
||||||
window.addEventListener("beforeinstallprompt", handler);
|
window.addEventListener("beforeinstallprompt", handler);
|
||||||
this.listenersAttached = true;
|
this.listenersAttached = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private clearDeferred(): void {
|
||||||
* 轮询等待 deferred 事件就绪(带超时)
|
if (this.deferred === null) return;
|
||||||
* - 100ms 间隔轮询,足够响应且不阻塞主线程
|
this.deferred = null;
|
||||||
* - 超时返 false —— 调用方走 "unavailable" 兜底
|
this.notifyListeners();
|
||||||
*/
|
|
||||||
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 notifyListeners(): void {
|
||||||
|
this.listeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 全局单例(与 `deviceIdentifier` / `SpAsyncUtil` 命名约定一致) */
|
/** 全局单例(与 `deviceIdentifier` / `SpAsyncUtil` 命名约定一致) */
|
||||||
|
|||||||
Reference in New Issue
Block a user