feat(pwa): installable app — manifest + SW + Settings install button
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
The captive-portal Step-2 QR opens pictureframe.edholm.me in Safari, which is the perfect moment to also offer "pin this to your home screen" so the recipient gets one-tap access without typing the URL again. Two pieces: * Service worker at /sw.js (document root, scope "/"). Minimal — install/activate calls skipWaiting + clients.claim, fetch is passthrough. Real offline caching is intentionally out of scope; we only need the SW to exist so Chrome's PWA-install heuristic fires. * Settings → Install app section, hidden when display-mode standalone. Android Chrome path: native beforeinstallprompt button. iOS Safari (and any other non-prompt browser): button opens a modal with step-by-step Share → Add to Home Screen instructions. usePwaInstall composable handles the singleton lifecycle — beforeinstallprompt fires once per page load and may fire before the user navigates to Settings, so we register on module import and stash the event for later. Tests cover: install button rendered when not standalone, modal opens on click without a native prompt, modal close button works. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Wraps the browser PWA-install lifecycle. Three reactive flags drive the
|
||||
* Settings UI:
|
||||
*
|
||||
* isStandalone — already installed (display-mode standalone or iOS
|
||||
* navigator.standalone). UI hides the install row
|
||||
* when true.
|
||||
* isIOS — iOS Safari, where there is no programmatic install.
|
||||
* UI shows a "show me how" button that opens an
|
||||
* instructions modal.
|
||||
* canPromptInstall — Android Chrome captured a beforeinstallprompt
|
||||
* event we can fire. UI shows a native "Install"
|
||||
* button that calls install().
|
||||
*
|
||||
* Module-level singleton: beforeinstallprompt fires once per page load and
|
||||
* may fire before the user navigates to Settings, so we register the
|
||||
* listener immediately on first import and cache the event.
|
||||
*/
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>
|
||||
}
|
||||
|
||||
const installEvent: Ref<BeforeInstallPromptEvent | null> = ref(null)
|
||||
const isStandalone = ref(false)
|
||||
|
||||
function detectStandalone(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
if (window.matchMedia?.('(display-mode: standalone)').matches) return true
|
||||
// iOS Safari uses a non-standard navigator.standalone
|
||||
return (window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
}
|
||||
|
||||
function detectIOS(): boolean {
|
||||
if (typeof navigator === 'undefined') return false
|
||||
const ua = navigator.userAgent
|
||||
// iPad on iOS 13+ reports as Macintosh; check for touch points.
|
||||
const iPadOS = ua.includes('Mac') && navigator.maxTouchPoints > 1
|
||||
return /iPhone|iPod/.test(ua) || iPadOS
|
||||
}
|
||||
|
||||
let registered = false
|
||||
function registerOnce() {
|
||||
if (registered || typeof window === 'undefined') return
|
||||
registered = true
|
||||
|
||||
isStandalone.value = detectStandalone()
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e: Event) => {
|
||||
// Stash the event so we can fire it later from the install button.
|
||||
// preventDefault stops Chrome's default mini-info bar so we control
|
||||
// the timing.
|
||||
e.preventDefault()
|
||||
installEvent.value = e as BeforeInstallPromptEvent
|
||||
})
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
installEvent.value = null
|
||||
isStandalone.value = true
|
||||
})
|
||||
|
||||
window.matchMedia?.('(display-mode: standalone)').addEventListener('change', (e) => {
|
||||
isStandalone.value = e.matches
|
||||
})
|
||||
}
|
||||
|
||||
registerOnce()
|
||||
|
||||
export function usePwaInstall() {
|
||||
const isIOS = detectIOS()
|
||||
const canPromptInstall = computed(() => installEvent.value !== null)
|
||||
|
||||
/**
|
||||
* Trigger the native install prompt (Android Chrome). Resolves to true if
|
||||
* the user accepted, false if they dismissed or no prompt was available.
|
||||
* The event is one-shot; canPromptInstall flips false after a call.
|
||||
*/
|
||||
async function install(): Promise<boolean> {
|
||||
const e = installEvent.value
|
||||
if (!e) return false
|
||||
await e.prompt()
|
||||
const choice = await e.userChoice
|
||||
installEvent.value = null
|
||||
return choice.outcome === 'accepted'
|
||||
}
|
||||
|
||||
return {
|
||||
isStandalone,
|
||||
isIOS,
|
||||
canPromptInstall,
|
||||
install,
|
||||
}
|
||||
}
|
||||
|
||||
// Test-only — reset the singleton state so each test starts clean.
|
||||
export function __resetPwaInstallForTests() {
|
||||
installEvent.value = null
|
||||
isStandalone.value = false
|
||||
registered = false
|
||||
}
|
||||
Reference in New Issue
Block a user