From a255b23be3e40d5d52cbd1fa61d81261eaf45486 Mon Sep 17 00:00:00 2001 From: feie9456 Date: Fri, 17 Apr 2026 13:55:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dpwa=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/pwa-controls.tsx | 113 +++++++++++++++++++++++++++++++++--- 1 file changed, 106 insertions(+), 7 deletions(-) diff --git a/components/pwa-controls.tsx b/components/pwa-controls.tsx index 3f061cc..f751a6a 100644 --- a/components/pwa-controls.tsx +++ b/components/pwa-controls.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from "react"; +const PWA_INSTALL_MARKER_KEY = "dh-pwa-installed"; + type BeforeInstallPromptEvent = Event & { prompt: () => Promise; userChoice: Promise<{ @@ -31,18 +33,68 @@ function urlBase64ToUint8Array(base64String: string) { return outputArray; } +function isStandaloneDisplayMode() { + const navigatorWithStandalone = navigator as Navigator & { + standalone?: boolean; + }; + + return ( + window.matchMedia("(display-mode: standalone)").matches || + window.matchMedia("(display-mode: fullscreen)").matches || + window.matchMedia("(display-mode: minimal-ui)").matches || + Boolean(navigatorWithStandalone.standalone) || + document.referrer.startsWith("android-app://") + ); +} + +function readInstalledMarker() { + try { + return window.localStorage.getItem(PWA_INSTALL_MARKER_KEY) === "1"; + } catch { + return false; + } +} + +function writeInstalledMarker() { + try { + window.localStorage.setItem(PWA_INSTALL_MARKER_KEY, "1"); + } catch { + return; + } +} + export function PwaControls() { const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || ""; const [installPromptEvent, setInstallPromptEvent] = useState(null); const [installing, setInstalling] = useState(false); const [isStandalone, setIsStandalone] = useState(false); + const [hasInstalledHint, setHasInstalledHint] = useState(false); const [notificationState, setNotificationState] = useState("checking"); const [notice, setNotice] = useState(null); useEffect(() => { let active = true; + let delayedSyncTimer: number | null = null; + + function syncInstallState() { + const standalone = isStandaloneDisplayMode(); + const installed = standalone || readInstalledMarker(); + + if (standalone) { + writeInstalledMarker(); + } + + if (!active) { + return standalone; + } + + setIsStandalone(standalone); + setHasInstalledHint(installed); + + return standalone; + } function handleBeforeInstallPrompt(event: Event) { event.preventDefault(); @@ -53,6 +105,30 @@ export function PwaControls() { setInstallPromptEvent(event as BeforeInstallPromptEvent); } + function handleAppInstalled() { + writeInstalledMarker(); + + if (!active) { + return; + } + + setHasInstalledHint(true); + } + + function handleVisibilityChange() { + if (!document.hidden) { + syncInstallState(); + } + } + + function handlePageShow() { + syncInstallState(); + } + + function handleFocus() { + syncInstallState(); + } + async function syncExistingSubscription(subscription: PushSubscription) { await fetch("/api/push/subscribe", { method: "POST", @@ -66,10 +142,7 @@ export function PwaControls() { } async function bootstrap() { - const standalone = - window.matchMedia("(display-mode: standalone)").matches || - Boolean((navigator as Navigator & { standalone?: boolean }).standalone); - setIsStandalone(standalone); + syncInstallState(); if ( !("serviceWorker" in navigator) || @@ -109,11 +182,27 @@ export function PwaControls() { } window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt); + window.addEventListener("appinstalled", handleAppInstalled); + window.addEventListener("pageshow", handlePageShow); + window.addEventListener("focus", handleFocus); + document.addEventListener("visibilitychange", handleVisibilityChange); + void bootstrap(); + delayedSyncTimer = window.setTimeout(() => { + syncInstallState(); + }, 350); return () => { active = false; window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt); + window.removeEventListener("appinstalled", handleAppInstalled); + window.removeEventListener("pageshow", handlePageShow); + window.removeEventListener("focus", handleFocus); + document.removeEventListener("visibilitychange", handleVisibilityChange); + + if (delayedSyncTimer) { + window.clearTimeout(delayedSyncTimer); + } }; }, [vapidPublicKey]); @@ -207,6 +296,7 @@ export function PwaControls() { if (choice.outcome === "accepted") { setInstallPromptEvent(null); + setHasInstalledHint(true); setNotice("安装提示已经确认,完成后就能像应用一样直接打开。\n"); } else { setNotice("这次先关闭了安装提示,之后仍可从浏览器菜单安装。\n"); @@ -230,7 +320,10 @@ export function PwaControls() {
- {isStandalone ? "已安装到桌面" : "当前在浏览器中打开"} + {isStandalone ? "当前正以桌面应用方式运行" : "当前正以浏览器方式运行"} + + + {hasInstalledHint ? "这台设备已有主屏幕安装记录" : "暂未检测到主屏幕安装记录"} {notificationState === "enabled" @@ -247,10 +340,16 @@ export function PwaControls() { onClick={() => { void installApp(); }} - disabled={installing} + disabled={installing || isStandalone} className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--ink)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--ink-soft)] disabled:cursor-not-allowed disabled:opacity-60" > - {installing ? "正在呼起安装" : "安装到桌面"} + {isStandalone + ? "已从桌面版打开" + : installing + ? "正在呼起安装" + : hasInstalledHint + ? "查看安装说明" + : "安装到桌面"} {notificationState === "enabled" ? (