"use client" import { useCallback, useEffect, useState } from "react" import { Download, Plus, Share, X } from "lucide-react" // ========================================================== // PwaInstallPrompt // ========================================================== // Bottom-sheet shown on mobile when the Monitor is opened in // a browser (not launched as an installed PWA). Two variants: // iOS Safari → manual 3-step instructions (no browser API) // Android → native install prompt when the browser // fires `beforeinstallprompt` (Chromium 89+ // delivers it based on manifest validity alone, // no Service Worker required), with the manual // "browser menu → Add to Home Screen" steps // always shown below as a fallback. // // Dismissal options: // "Not now" → temporary, hidden for 30 days // "Don't show again" → permanent (no expiry) // Backdrop / X → session-only dismiss (reappears on // the next page load) // ========================================================== const DISMISSED_FOREVER_KEY = "proxmenux-install-dismissed" const DISMISSED_UNTIL_KEY = "proxmenux-install-dismissed-until" const NOT_NOW_DAYS = 30 // `BeforeInstallPromptEvent` isn't in the TS DOM lib yet. interface BeforeInstallPromptEvent extends Event { readonly platforms: string[] readonly userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }> prompt(): Promise } function isMobileDevice(): boolean { if (typeof window === "undefined") return false // Prefer feature detection (coarse pointer + touch) over UA sniffing, // and fall back to UA for the corner case where a mobile browser // reports fine pointer under a desktop-mode toggle. const coarse = window.matchMedia("(pointer: coarse)").matches const ua = navigator.userAgent const uaMobile = /Android|iPhone|iPad|iPod|Mobile|Opera Mini|BlackBerry|IEMobile/i.test(ua) return coarse || uaMobile } function isStandalone(): boolean { if (typeof window === "undefined") return false const displayModeStandalone = window.matchMedia("(display-mode: standalone)").matches const iosStandalone = (window.navigator as Navigator & { standalone?: boolean }).standalone === true return displayModeStandalone || iosStandalone } function isIOS(): boolean { if (typeof window === "undefined") return false const ua = navigator.userAgent // iPadOS 13+ reports as MacIntel — detect that too when maxTouchPoints > 1. const iPadMasqueradingAsMac = ua.includes("Macintosh") && (navigator as Navigator & { maxTouchPoints?: number }).maxTouchPoints! > 1 return /iPhone|iPad|iPod/i.test(ua) || iPadMasqueradingAsMac } export function PwaInstallPrompt() { const [open, setOpen] = useState(false) const [platform, setPlatform] = useState<"ios" | "android" | null>(null) const [deferredPrompt, setDeferredPrompt] = useState(null) useEffect(() => { if (typeof window === "undefined") return if (!isMobileDevice() || isStandalone()) return try { if (localStorage.getItem(DISMISSED_FOREVER_KEY) === "1") return const untilRaw = localStorage.getItem(DISMISSED_UNTIL_KEY) if (untilRaw) { const until = Number.parseInt(untilRaw, 10) // Corrupt / non-numeric values fall through and the prompt shows, // which is the safe default. if (Number.isFinite(until) && until > Date.now()) return } } catch { // localStorage unavailable (private mode etc.) — treat as not dismissed. } setPlatform(isIOS() ? "ios" : "android") setOpen(true) }, []) useEffect(() => { if (typeof window === "undefined") return const onBeforeInstall = (e: Event) => { // Suppress the browser's own mini-infobar so the Install button // in the bottom sheet is the primary path. e.preventDefault() setDeferredPrompt(e as BeforeInstallPromptEvent) } const onInstalled = () => { // The browser confirms the install landed on the home screen — // close the sheet and drop the deferred event. setDeferredPrompt(null) setOpen(false) } window.addEventListener("beforeinstallprompt", onBeforeInstall) window.addEventListener("appinstalled", onInstalled) return () => { window.removeEventListener("beforeinstallprompt", onBeforeInstall) window.removeEventListener("appinstalled", onInstalled) } }, []) const handleNotNow = useCallback(() => { try { const until = Date.now() + NOT_NOW_DAYS * 24 * 60 * 60 * 1000 localStorage.setItem(DISMISSED_UNTIL_KEY, String(until)) } catch { // Best-effort; if localStorage fails the user will see the prompt // again next visit, which is the safe default. } setOpen(false) }, []) const handleNeverAgain = useCallback(() => { try { localStorage.setItem(DISMISSED_FOREVER_KEY, "1") } catch { // Best-effort; if localStorage fails the user will see the prompt // again next visit, which is the safe default. } setOpen(false) }, []) const handleClose = useCallback(() => { // Session-only dismiss: closing via X or backdrop does NOT persist, // so the prompt reappears on the next page load. Users who want to // silence it for longer must use "Not now" (30 d) or "Don't show again". setOpen(false) }, []) const handleInstall = useCallback(async () => { if (!deferredPrompt) return try { await deferredPrompt.prompt() const { outcome } = await deferredPrompt.userChoice // A `beforeinstallprompt` event can only be used once; drop the // reference either way so the button hides. setDeferredPrompt(null) if (outcome === "accepted") { setOpen(false) } } catch { // Browser refused to run the prompt (already handled, race with // appinstalled, etc.) — leave the sheet open so the operator can // fall back to the manual steps rendered below. setDeferredPrompt(null) } }, [deferredPrompt]) if (!open || !platform) return null return (
{ if (e.target === e.currentTarget) handleClose() }} >
) }