From a15c486847d905f5b984f2f95b1dede89d053537 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 17:28:05 +0000 Subject: [PATCH] feat: add PWA support and local launch scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PWA: manifest, service worker with game-aware caching (cache-first for assets/WASM/models/audio, stale-while-revalidate for app shell/data), install prompt UI, Apple web app meta tags, placeholder icons. Launch scripts: play.sh (macOS/Linux) and play.bat (Windows) in launch/ folder — auto-installs deps, starts server, opens Chrome/Edge in --app mode for a native-feeling window. Supports dev and prod modes. https://claude.ai/code/session_0172J363kooEWWteTsyt5yU8 --- launch/play.bat | 140 +++++++++++++++ launch/play.sh | 161 +++++++++++++++++ next.config.js | 14 ++ public/icon-192x192.png | Bin 0 -> 3688 bytes public/icon-512x512.png | Bin 0 -> 10867 bytes public/sw.js | 170 ++++++++++++++++++ src/app/layout.tsx | 21 ++- src/app/manifest.ts | 34 ++++ src/components/pwa/InstallPrompt.tsx | 76 ++++++++ src/components/pwa/ServiceWorkerRegistrar.tsx | 55 ++++++ src/store/pwaStore.ts | 29 +++ src/types/pwa.d.ts | 16 ++ 12 files changed, 714 insertions(+), 2 deletions(-) create mode 100644 launch/play.bat create mode 100755 launch/play.sh create mode 100644 public/icon-192x192.png create mode 100644 public/icon-512x512.png create mode 100644 public/sw.js create mode 100644 src/app/manifest.ts create mode 100644 src/components/pwa/InstallPrompt.tsx create mode 100644 src/components/pwa/ServiceWorkerRegistrar.tsx create mode 100644 src/store/pwaStore.ts create mode 100644 src/types/pwa.d.ts diff --git a/launch/play.bat b/launch/play.bat new file mode 100644 index 00000000..2459abe8 --- /dev/null +++ b/launch/play.bat @@ -0,0 +1,140 @@ +@echo off +REM VOIDSTRIKE — Local Play Launcher (Windows) +REM Double-click this file to launch the game + +setlocal enabledelayedexpansion + +set "SCRIPT_DIR=%~dp0" +set "PROJECT_DIR=%SCRIPT_DIR%.." +set "PORT=3000" +if defined VOIDSTRIKE_PORT set "PORT=%VOIDSTRIKE_PORT%" +set "URL=http://localhost:%PORT%" + +cd /d "%PROJECT_DIR%" + +echo. +echo ======================================== +echo V O I D S T R I K E +echo Local Play Launcher +echo ======================================== +echo. + +REM ============================================ +REM Check Node.js +REM ============================================ +where node >nul 2>nul +if %errorlevel% neq 0 ( + echo [ERROR] Node.js not found. Install it from https://nodejs.org + pause + exit /b 1 +) + +for /f "tokens=1 delims=v." %%a in ('node -v') do set "NODE_MAJOR=%%a" +REM node -v returns "v20.x.x" — strip the 'v' prefix +for /f "tokens=1 delims=." %%a in ('node -v') do set "NODE_VER=%%a" +set "NODE_VER=%NODE_VER:v=%" + +if %NODE_VER% lss 18 ( + echo [ERROR] Node.js 18+ required + pause + exit /b 1 +) + +REM ============================================ +REM Install dependencies if needed +REM ============================================ +if not exist "node_modules" ( + echo Installing dependencies... + call npm install + echo. +) + +REM ============================================ +REM Determine mode (dev or prod) +REM ============================================ +set "MODE=dev" +if "%~1"=="build" set "MODE=build" +if "%~1"=="prod" set "MODE=build" + +if "%MODE%"=="build" ( + echo Building for production... + call npm run build + echo. + echo Starting production server on port %PORT%... + start "VOIDSTRIKE Server" /b cmd /c "npx next start -p %PORT%" +) else ( + echo Starting dev server on port %PORT%... + start "VOIDSTRIKE Server" /b cmd /c "npx next dev -p %PORT%" +) + +REM ============================================ +REM Wait for server to be ready +REM ============================================ +echo Waiting for server... +set "RETRIES=0" +:wait_loop +timeout /t 1 /nobreak >nul +curl -s "%URL%" >nul 2>nul +if %errorlevel% equ 0 goto server_ready +set /a RETRIES+=1 +if %RETRIES% geq 60 ( + echo [ERROR] Server failed to start after 60s + pause + exit /b 1 +) +goto wait_loop + +:server_ready +echo Server ready! +echo. + +REM ============================================ +REM Open browser in app mode +REM ============================================ +echo Launching VOIDSTRIKE... + +REM Try Chrome first (app mode for native-feeling window) +set "CHROME_PATH=" +for %%p in ( + "%ProgramFiles%\Google\Chrome\Application\chrome.exe" + "%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe" + "%LocalAppData%\Google\Chrome\Application\chrome.exe" +) do ( + if exist %%p ( + set "CHROME_PATH=%%~p" + goto found_chrome + ) +) + +REM Try Edge (also supports app mode) +for %%p in ( + "%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe" + "%ProgramFiles%\Microsoft\Edge\Application\msedge.exe" +) do ( + if exist %%p ( + set "CHROME_PATH=%%~p" + goto found_chrome + ) +) + +REM Fallback: open default browser +echo No Chrome/Edge found, opening default browser... +start "" "%URL%" +goto after_launch + +:found_chrome +start "" "%CHROME_PATH%" --app="%URL%" --start-maximized + +:after_launch +echo. +echo Game running at: %URL% +echo. +echo Press any key to stop the server and exit... +pause >nul + +REM Kill the server +taskkill /fi "WINDOWTITLE eq VOIDSTRIKE Server" >nul 2>nul +taskkill /f /im node.exe >nul 2>nul + +echo Shutting down. +exit /b 0 diff --git a/launch/play.sh b/launch/play.sh new file mode 100755 index 00000000..43ed6f8c --- /dev/null +++ b/launch/play.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# VOIDSTRIKE — Local Play Launcher (macOS / Linux) +# Double-click or run: ./launch/play.sh + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +PORT="${VOIDSTRIKE_PORT:-3000}" +URL="http://localhost:$PORT" + +cd "$PROJECT_DIR" + +# ============================================ +# Colors +# ============================================ +RED='\033[0;31m' +GREEN='\033[0;32m' +PURPLE='\033[0;35m' +BOLD='\033[1m' +NC='\033[0m' + +echo -e "${PURPLE}${BOLD}" +echo " ╔═══════════════════════════════════╗" +echo " ║ V O I D S T R I K E ║" +echo " ║ Local Play Launcher ║" +echo " ╚═══════════════════════════════════╝" +echo -e "${NC}" + +# ============================================ +# Check Node.js +# ============================================ +if ! command -v node &>/dev/null; then + echo -e "${RED}Node.js not found. Install it from https://nodejs.org${NC}" + exit 1 +fi + +NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1) +if [ "$NODE_VERSION" -lt 18 ]; then + echo -e "${RED}Node.js 18+ required (found $(node -v))${NC}" + exit 1 +fi + +# ============================================ +# Install dependencies if needed +# ============================================ +if [ ! -d "node_modules" ]; then + echo -e "${PURPLE}Installing dependencies...${NC}" + npm install + echo "" +fi + +# ============================================ +# Build or Dev mode +# ============================================ +MODE="${1:-dev}" + +if [ "$MODE" = "build" ] || [ "$MODE" = "prod" ]; then + echo -e "${PURPLE}Building for production...${NC}" + npm run build + echo "" + echo -e "${GREEN}Starting production server on port $PORT...${NC}" + # Start server in background + npx next start -p "$PORT" & + SERVER_PID=$! +else + echo -e "${GREEN}Starting dev server on port $PORT...${NC}" + # Start dev server in background + npx next dev -p "$PORT" & + SERVER_PID=$! +fi + +# ============================================ +# Wait for server to be ready +# ============================================ +echo -n "Waiting for server" +RETRIES=0 +MAX_RETRIES=60 +while ! curl -s "$URL" >/dev/null 2>&1; do + echo -n "." + sleep 1 + RETRIES=$((RETRIES + 1)) + if [ "$RETRIES" -ge "$MAX_RETRIES" ]; then + echo "" + echo -e "${RED}Server failed to start after ${MAX_RETRIES}s${NC}" + kill "$SERVER_PID" 2>/dev/null + exit 1 + fi +done +echo "" +echo -e "${GREEN}Server ready!${NC}" +echo "" + +# ============================================ +# Open browser in app mode +# ============================================ +open_app_mode() { + local url="$1" + + # Try Chrome first (app mode for native-feeling window) + if command -v google-chrome &>/dev/null; then + google-chrome --app="$url" --start-maximized 2>/dev/null & + return 0 + fi + if command -v google-chrome-stable &>/dev/null; then + google-chrome-stable --app="$url" --start-maximized 2>/dev/null & + return 0 + fi + if command -v chromium &>/dev/null; then + chromium --app="$url" --start-maximized 2>/dev/null & + return 0 + fi + if command -v chromium-browser &>/dev/null; then + chromium-browser --app="$url" --start-maximized 2>/dev/null & + return 0 + fi + + # macOS: try Chrome, then Edge, then default browser + if [ "$(uname)" = "Darwin" ]; then + if [ -d "/Applications/Google Chrome.app" ]; then + open -a "Google Chrome" --args --app="$url" --start-maximized 2>/dev/null + return 0 + fi + if [ -d "/Applications/Microsoft Edge.app" ]; then + open -a "Microsoft Edge" --args --app="$url" --start-maximized 2>/dev/null + return 0 + fi + # Fallback to default browser + open "$url" + return 0 + fi + + # Try Edge on Linux + if command -v microsoft-edge &>/dev/null; then + microsoft-edge --app="$url" --start-maximized 2>/dev/null & + return 0 + fi + + # Fallback: xdg-open + if command -v xdg-open &>/dev/null; then + xdg-open "$url" 2>/dev/null & + return 0 + fi + + echo -e "${PURPLE}Open manually: $url${NC}" + return 1 +} + +echo -e "${PURPLE}Launching VOIDSTRIKE...${NC}" +open_app_mode "$URL" + +echo "" +echo -e "${BOLD}Game running at: ${PURPLE}$URL${NC}" +echo -e "Press ${BOLD}Ctrl+C${NC} to stop the server." +echo "" + +# ============================================ +# Cleanup on exit +# ============================================ +trap "echo ''; echo 'Shutting down...'; kill $SERVER_PID 2>/dev/null; exit 0" INT TERM +wait "$SERVER_PID" diff --git a/next.config.js b/next.config.js index 7c398003..708ef7c6 100644 --- a/next.config.js +++ b/next.config.js @@ -31,6 +31,20 @@ const nextConfig = { }, ], }, + { + // Service worker must never be cached by the browser + source: '/sw.js', + headers: [ + { + key: 'Cache-Control', + value: 'no-cache, no-store, must-revalidate', + }, + { + key: 'Content-Type', + value: 'application/javascript; charset=utf-8', + }, + ], + }, ]; }, diff --git a/public/icon-192x192.png b/public/icon-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..5525131fd586fec642bd566697eaa42fccbe4d4d GIT binary patch literal 3688 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 K{c3#U1^)v9vbog& literal 0 HcmV?d00001 diff --git a/public/icon-512x512.png b/public/icon-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..6d6934ff98a12ae4b0ee87a9590ee5ae821e56cc GIT binary patch literal 10867 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 literal 0 HcmV?d00001 diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 00000000..8cd04e36 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,170 @@ +// VOIDSTRIKE Service Worker +// Runtime caching only — no precaching of game assets (260MB+ would be insane) + +const CACHE_VERSION = 1; +const SHELL_CACHE = `voidstrike-shell-v${CACHE_VERSION}`; +const ASSET_CACHE = `voidstrike-assets-v${CACHE_VERSION}`; +const DATA_CACHE = `voidstrike-data-v${CACHE_VERSION}`; + +const VALID_CACHES = [SHELL_CACHE, ASSET_CACHE, DATA_CACHE]; + +// Max asset cache size in entries (not bytes) — evict oldest when exceeded +const MAX_ASSET_ENTRIES = 500; + +// ============================================ +// INSTALL +// ============================================ + +self.addEventListener('install', () => { + // Skip waiting to activate immediately + self.skipWaiting(); +}); + +// ============================================ +// ACTIVATE +// ============================================ + +self.addEventListener('activate', (event) => { + // Clean up old versioned caches + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((key) => !VALID_CACHES.includes(key)).map((key) => caches.delete(key))) + ) + .then(() => self.clients.claim()) + ); +}); + +// ============================================ +// FETCH +// ============================================ + +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Only handle GET requests for same-origin + if (request.method !== 'GET') return; + if (url.origin !== self.location.origin) return; + + // Skip Next.js HMR / dev websocket / webpack chunks in dev + if (url.pathname.startsWith('/_next/webpack-hmr')) return; + if (url.pathname.includes('__nextjs')) return; + + // Route to appropriate caching strategy + if (isStaticGameAsset(url.pathname)) { + event.respondWith(cacheFirst(request, ASSET_CACHE)); + return; + } + + if (isGameData(url.pathname)) { + event.respondWith(staleWhileRevalidate(request, DATA_CACHE)); + return; + } + + if (isAppShell(request, url.pathname)) { + event.respondWith(staleWhileRevalidate(request, SHELL_CACHE)); + return; + } +}); + +// ============================================ +// ASSET CLASSIFICATION +// ============================================ + +function isStaticGameAsset(pathname) { + // WASM binaries + if (pathname.endsWith('.wasm')) return true; + // 3D models + if (/\.(glb|gltf|bin)$/.test(pathname)) return true; + // Textures (including compressed formats) + if (/\.(png|jpg|jpeg|webp|ktx2|basis)$/.test(pathname)) return true; + // Audio + if (/\.(mp3|ogg|wav|m4a)$/.test(pathname)) return true; + // Draco decoder + if (pathname.startsWith('/draco/')) return true; + return false; +} + +function isGameData(pathname) { + // Game definitions, configs, map data + if (pathname.startsWith('/data/') && pathname.endsWith('.json')) return true; + if (pathname.startsWith('/config/') && pathname.endsWith('.json')) return true; + return false; +} + +function isAppShell(request, pathname) { + // Navigation requests (HTML pages) + if (request.mode === 'navigate') return true; + // Next.js static assets (JS, CSS) — content-hashed, safe to cache + if (pathname.startsWith('/_next/static/')) return true; + // Fonts + if (/\.(woff2?|ttf|otf|eot)$/.test(pathname)) return true; + return false; +} + +// ============================================ +// CACHING STRATEGIES +// ============================================ + +// Cache-First: check cache, fallback to network, cache the response +// Best for immutable assets (WASM, models, textures, audio) +async function cacheFirst(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + if (cached) return cached; + + try { + const response = await fetch(request); + if (response.ok) { + cache.put(request, response.clone()); + trimCache(cacheName, MAX_ASSET_ENTRIES); + } + return response; + } catch { + // Network failed and nothing in cache + return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); + } +} + +// Stale-While-Revalidate: return cache immediately, update in background +// Best for app shell and game data that may change between deploys +async function staleWhileRevalidate(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + + const fetchPromise = fetch(request) + .then((response) => { + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + }) + .catch(() => null); + + // Return cached version immediately if available, otherwise wait for network + if (cached) return cached; + + const response = await fetchPromise; + if (response) return response; + + return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); +} + +// ============================================ +// CACHE MANAGEMENT +// ============================================ + +// Evict oldest entries when cache exceeds max size +async function trimCache(cacheName, maxEntries) { + const cache = await caches.open(cacheName); + const keys = await cache.keys(); + if (keys.length <= maxEntries) return; + + // Delete oldest entries (first in the list) + const deleteCount = keys.length - maxEntries; + for (let i = 0; i < deleteCount; i++) { + await cache.delete(keys[i]); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d70741e9..3dbcf55f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,10 +1,24 @@ -import type { Metadata } from 'next'; +import type { Metadata, Viewport } from 'next'; import './globals.css'; +import { ServiceWorkerRegistrar } from '@/components/pwa/ServiceWorkerRegistrar'; +import { InstallPrompt } from '@/components/pwa/InstallPrompt'; export const metadata: Metadata = { title: 'VOIDSTRIKE - Browser-Based RTS', - description: 'A competitive real-time strategy game built for the browser. Command your forces, gather resources, and dominate the battlefield.', + description: + 'A competitive real-time strategy game built for the browser. Command your forces, gather resources, and dominate the battlefield.', keywords: ['RTS', 'strategy', 'game', 'browser', 'multiplayer', 'competitive'], + appleWebApp: { + capable: true, + statusBarStyle: 'black-translucent', + title: 'VOIDSTRIKE', + }, + applicationName: 'VOIDSTRIKE', +}; + +export const viewport: Viewport = { + themeColor: '#0a0015', + colorScheme: 'dark', }; export default function RootLayout({ @@ -21,9 +35,12 @@ export default function RootLayout({ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Orbitron:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" /> + + {children} + ); diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 00000000..e136f534 --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,34 @@ +import type { MetadataRoute } from 'next'; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: 'VOIDSTRIKE', + short_name: 'VOIDSTRIKE', + description: + 'A competitive real-time strategy game built for the browser. Command your forces, gather resources, and dominate the battlefield.', + start_url: '/', + display: 'standalone', + orientation: 'landscape', + background_color: '#000000', + theme_color: '#0a0015', + categories: ['games', 'entertainment'], + icons: [ + { + src: '/icon-192x192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: '/icon-512x512.png', + sizes: '512x512', + type: 'image/png', + }, + { + src: '/icon-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'maskable', + }, + ], + }; +} diff --git a/src/components/pwa/InstallPrompt.tsx b/src/components/pwa/InstallPrompt.tsx new file mode 100644 index 00000000..358c9e63 --- /dev/null +++ b/src/components/pwa/InstallPrompt.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { useCallback } from 'react'; +import { usePWAStore } from '@/store/pwaStore'; + +/** + * Non-intrusive "Install App" button. Only renders when the browser + * has fired beforeinstallprompt and the app is not already installed. + */ +export function InstallPrompt() { + const installPrompt = usePWAStore((s) => s.installPrompt); + const isInstalled = usePWAStore((s) => s.isInstalled); + const isDismissed = usePWAStore((s) => s.isDismissed); + const dismiss = usePWAStore((s) => s.dismiss); + const setInstalled = usePWAStore((s) => s.setInstalled); + const setInstallPrompt = usePWAStore((s) => s.setInstallPrompt); + + const handleInstall = useCallback(async () => { + if (!installPrompt) return; + + await installPrompt.prompt(); + const result = await installPrompt.userChoice; + + if (result.outcome === 'accepted') { + setInstalled(true); + } + // Prompt can only be used once + setInstallPrompt(null); + }, [installPrompt, setInstalled, setInstallPrompt]); + + // Don't render if no prompt, already installed, or dismissed + if (!installPrompt || isInstalled || isDismissed) return null; + + return ( +
+ + +
+ ); +} diff --git a/src/components/pwa/ServiceWorkerRegistrar.tsx b/src/components/pwa/ServiceWorkerRegistrar.tsx new file mode 100644 index 00000000..c507ab46 --- /dev/null +++ b/src/components/pwa/ServiceWorkerRegistrar.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useEffect } from 'react'; +import { usePWAStore } from '@/store/pwaStore'; + +/** + * Registers the service worker and captures the beforeinstallprompt event. + * Renders nothing — include once in the root layout. + */ +export function ServiceWorkerRegistrar() { + const setInstallPrompt = usePWAStore((s) => s.setInstallPrompt); + const setInstalled = usePWAStore((s) => s.setInstalled); + const setServiceWorkerReady = usePWAStore((s) => s.setServiceWorkerReady); + + useEffect(() => { + // Service worker registration + if ('serviceWorker' in navigator) { + navigator.serviceWorker + .register('/sw.js', { scope: '/', updateViaCache: 'none' }) + .then(() => { + setServiceWorkerReady(true); + }) + .catch((error) => { + console.warn('[PWA] Service worker registration failed:', error); + }); + } + + // Capture install prompt + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault(); + setInstallPrompt(e as BeforeInstallPromptEvent); + }; + + // Detect if already installed + const handleAppInstalled = () => { + setInstalled(true); + setInstallPrompt(null); + }; + + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + window.addEventListener('appinstalled', handleAppInstalled); + + // Check if running in standalone mode (already installed) + if (window.matchMedia('(display-mode: standalone)').matches) { + setInstalled(true); + } + + return () => { + window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + window.removeEventListener('appinstalled', handleAppInstalled); + }; + }, [setInstallPrompt, setInstalled, setServiceWorkerReady]); + + return null; +} diff --git a/src/store/pwaStore.ts b/src/store/pwaStore.ts new file mode 100644 index 00000000..4676236f --- /dev/null +++ b/src/store/pwaStore.ts @@ -0,0 +1,29 @@ +import { create } from 'zustand'; + +/** + * Browser's BeforeInstallPromptEvent — not in lib.dom.d.ts yet. + * Declared globally in src/types/pwa.d.ts + */ +interface PWAState { + installPrompt: BeforeInstallPromptEvent | null; + isInstalled: boolean; + isDismissed: boolean; + serviceWorkerReady: boolean; + + setInstallPrompt: (prompt: BeforeInstallPromptEvent | null) => void; + setInstalled: (installed: boolean) => void; + setServiceWorkerReady: (ready: boolean) => void; + dismiss: () => void; +} + +export const usePWAStore = create((set) => ({ + installPrompt: null, + isInstalled: false, + isDismissed: false, + serviceWorkerReady: false, + + setInstallPrompt: (prompt) => set({ installPrompt: prompt }), + setInstalled: (installed) => set({ isInstalled: installed }), + setServiceWorkerReady: (ready) => set({ serviceWorkerReady: ready }), + dismiss: () => set({ isDismissed: true, installPrompt: null }), +})); diff --git a/src/types/pwa.d.ts b/src/types/pwa.d.ts new file mode 100644 index 00000000..5491249e --- /dev/null +++ b/src/types/pwa.d.ts @@ -0,0 +1,16 @@ +// BeforeInstallPromptEvent — not in lib.dom.d.ts as of TS 5.x +// https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent + +interface BeforeInstallPromptEvent extends Event { + readonly platforms: string[]; + readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>; + prompt(): Promise; +} + +declare global { + interface WindowEventMap { + beforeinstallprompt: BeforeInstallPromptEvent; + } +} + +export {};