diff --git a/src/app/chat/components/pwa-install-overlay.tsx b/src/app/chat/components/pwa-install-overlay.tsx index 818db4de..1bc551d9 100644 --- a/src/app/chat/components/pwa-install-overlay.tsx +++ b/src/app/chat/components/pwa-install-overlay.tsx @@ -19,7 +19,7 @@ import { canShowPwaInstallPromptOnce, recordPwaInstallPromptShown, } from "@/lib/chat/pwa_install_prompt"; -import { BrowserDetector, pwaUtil } from "@/utils"; +import { pwaUtil } from "@/utils"; import { PwaInstallDialog } from "./pwa-install-dialog"; import styles from "./pwa-install-overlay.module.css"; @@ -37,29 +37,39 @@ export function PwaInstallOverlay({ enabled }: PwaInstallOverlayProps) { let mounted = true; let timer: number | undefined; + let unsubscribe: (() => void) | undefined; const init = async () => { // const isDev = AppEnvUtil.isDevelopment(); const isDev = false; - const shouldShowDialog = await shouldShowPwaInstallDialog(isDev); - if (!shouldShowDialog) return; // 提前挂 beforeinstallprompt 监听,避免用户点 OK 时事件已在更早时机丢失。 pwaUtil.prepareInstallPrompt(); - // 达到聊天数量阈值后稍作停顿,避免弹窗与消息刷新挤在同一瞬间。 - const delay = isDev - ? DEVELOPMENT_DIALOG_DELAY_MS - : PRODUCTION_DIALOG_DELAY_MS; - timer = window.setTimeout(() => { - if (!mounted) return; - showPwaDialog(); - }, delay); + const tryScheduleDialog = async () => { + const shouldShowDialog = await shouldShowPwaInstallDialog(isDev); + if (!mounted || !shouldShowDialog || timer !== undefined) return; + + // 达到聊天数量阈值后稍作停顿,避免弹窗与消息刷新挤在同一瞬间。 + const delay = isDev + ? DEVELOPMENT_DIALOG_DELAY_MS + : PRODUCTION_DIALOG_DELAY_MS; + timer = window.setTimeout(() => { + if (!mounted) return; + showPwaDialog(); + }, delay); + }; + + await tryScheduleDialog(); + unsubscribe = pwaUtil.subscribe(() => { + void tryScheduleDialog(); + }); }; void init(); return () => { mounted = false; + unsubscribe?.(); if (timer !== undefined) window.clearTimeout(timer); }; }, [enabled]); @@ -69,9 +79,7 @@ export function PwaInstallOverlay({ enabled }: PwaInstallOverlayProps) { async function shouldShowPwaInstallDialog(isDevelopment: boolean) { // PWA 支持 / 安装 双重门禁(两种环境都检查)。 - if (!pwaUtil.isSupported()) return false; - if (pwaUtil.isInstalled()) return false; - if (BrowserDetector.isInAppBrowser()) return false; + if (!pwaUtil.canShowInstallEntry()) return false; // 开发环境:跳过"只弹一次"门禁,每次进入都弹,方便 UI 测试。 if (isDevelopment) return true; diff --git a/src/app/sidebar/sidebar-screen.tsx b/src/app/sidebar/sidebar-screen.tsx index 87c0a518..dca25a19 100644 --- a/src/app/sidebar/sidebar-screen.tsx +++ b/src/app/sidebar/sidebar-screen.tsx @@ -43,22 +43,27 @@ export function SidebarScreen() { ]); useEffect(() => { + const updateInstallEntryVisibility = () => { + setCanShowInstallApp(pwaUtil.canShowInstallEntry()); + }; + pwaUtil.prepareInstallPrompt(); - const timer = window.setTimeout(() => { - setCanShowInstallApp(pwaUtil.isSupported() && !pwaUtil.isInstalled()); - }, 0); + updateInstallEntryVisibility(); + const unsubscribe = pwaUtil.subscribe(updateInstallEntryVisibility); return () => { - window.clearTimeout(timer); + unsubscribe(); }; }, []); const handleInstallAppClick = async () => { pwaUtil.prepareInstallPrompt(); const result = await pwaUtil.install(); - if (result === "accepted" || pwaUtil.isInstalled()) { - setCanShowInstallApp(false); - } + setCanShowInstallApp( + result !== "accepted" && + !pwaUtil.isInstalled() && + pwaUtil.canShowInstallEntry(), + ); }; const handleLogoutClick = () => { diff --git a/src/utils/__tests__/pwa.test.ts b/src/utils/__tests__/pwa.test.ts new file mode 100644 index 00000000..39646fde --- /dev/null +++ b/src/utils/__tests__/pwa.test.ts @@ -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; + outcome?: "accepted" | "dismissed"; +} = {}): void { + const event = new Event("beforeinstallprompt", { + cancelable: true, + }) as Event & { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; + }; + event.prompt = prompt; + event.userChoice = Promise.resolve({ outcome }); + + window.dispatchEvent(event); +} diff --git a/src/utils/browser-detect.ts b/src/utils/browser-detect.ts index 3e814c2e..40ed6bdb 100644 --- a/src/utils/browser-detect.ts +++ b/src/utils/browser-detect.ts @@ -67,6 +67,16 @@ export class BrowserDetector { 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 内嵌浏览器 */ static isFacebookInAppBrowser(ua: string | null = null): boolean { return /FBAN|FBAV|FB_IAB|FB4A|FBIOS/i.test(BrowserDetector.getUserAgent(ua)); diff --git a/src/utils/pwa.ts b/src/utils/pwa.ts index 99658b63..7adc96dc 100644 --- a/src/utils/pwa.ts +++ b/src/utils/pwa.ts @@ -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(); // ===== 公共 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 { - 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` 命名约定一致) */