← Public packages
@kentcdodds/package-app-kit
Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.
starter-fetch/src/client/install.ts
168 lines · 5.0 KB · TypeScript/** Install CTA + iOS hint + first-run splash. Config via data-app-name / data-install-hint-key. */
import { readClientConfig } from './config.ts'
export function bootInstall() {
const config = readClientConfig()
let deferredInstall = null
/** iOS A2HS overlay opens only from the header icon — never auto-show on load. */
let iosHintOpen = false
function isStandaloneDisplay() {
const nav = navigator
if (nav.standalone === true) return true
try {
return window.matchMedia(
'(display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui)',
).matches
} catch {
try {
return window.matchMedia('(display-mode: standalone)').matches
} catch {
return false
}
}
}
function isIosBrowser() {
if (/iphone|ipad|ipod/i.test(navigator.userAgent)) return true
return navigator.platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1
}
function iosHintHtml() {
const safe = config.appName.replace(/</g, '<')
return `Add <strong>${safe}</strong> to your Home Screen: tap <strong>Share</strong>, then <strong>Add to Home Screen</strong>.`
}
function isHintDismissed() {
try {
return localStorage.getItem(config.hintDismissKey) !== null
} catch {
return false
}
}
function syncInstallUi() {
const wrap = document.querySelector('[data-install-wrap]')
const installBtn = document.querySelector('[data-install]')
const hint = document.querySelector('[data-install-hint]')
const hintCopy = document.querySelector('[data-install-hint-copy]')
const splash = document.querySelector('[data-first-run-splash]')
const ios = isIosBrowser()
const standalone = isStandaloneDisplay()
const canNative = Boolean(deferredInstall?.prompt)
const hintDismissed = isHintDismissed()
const showIos = !standalone && ios && !hintDismissed && !canNative
const available = !standalone && (canNative || showIos)
// Reserved header slot: toggle data-available, do not collapse width (no CLS).
if (wrap) {
wrap.setAttribute('data-available', available ? 'true' : 'false')
wrap.hidden = standalone
}
if (installBtn) {
installBtn.setAttribute('aria-hidden', available ? 'false' : 'true')
if (available) installBtn.removeAttribute('tabindex')
else installBtn.setAttribute('tabindex', '-1')
}
if (hint) {
const open = showIos && iosHintOpen
if (hintCopy && open) hintCopy.innerHTML = iosHintHtml()
// Always set the hidden attribute; CSS must honor [hidden] (see styles).
hint.hidden = !open
if (!open) hint.setAttribute('hidden', '')
else hint.removeAttribute('hidden')
}
if (splash) {
let splashDismissed = false
try {
splashDismissed = localStorage.getItem(config.splashDismissKey) !== null
} catch {
/* ignore */
}
splash.hidden = standalone || splashDismissed
if (splash.hidden) splash.setAttribute('hidden', '')
else splash.removeAttribute('hidden')
}
}
async function promptInstall() {
const promptEvent = deferredInstall
if (typeof promptEvent?.prompt !== 'function') return false
deferredInstall = null
try {
// prompt() must run in the user-gesture turn — don't re-render first.
await promptEvent.prompt()
if (promptEvent.userChoice) await promptEvent.userChoice
syncInstallUi()
return true
} catch {
syncInstallUi()
return false
}
}
document.addEventListener('click', (e) => {
let t = e.target
if (t && t.nodeType === 3) t = t.parentElement
const el = t
if (!el?.closest) return
if (el.closest('[data-install-hint-dismiss]')) {
e.preventDefault()
e.stopPropagation()
try {
localStorage.setItem(config.hintDismissKey, String(Date.now()))
} catch {
/* ignore */
}
iosHintOpen = false
syncInstallUi()
return
}
if (el.closest('[data-first-run-dismiss]')) {
try {
localStorage.setItem(config.splashDismissKey, String(Date.now()))
} catch {
/* ignore */
}
syncInstallUi()
return
}
if (el.closest('[data-install]')) {
e.preventDefault()
if (deferredInstall?.prompt) {
void promptInstall()
return
}
// iOS: toggle A2HS hint overlay from the header icon (never auto-show).
if (isIosBrowser() && !isHintDismissed()) {
iosHintOpen = !iosHintOpen
syncInstallUi()
}
}
})
function canUseCustomInstallCta() {
if (isStandaloneDisplay()) return false
return Boolean(document.querySelector('[data-install]'))
}
window.addEventListener('beforeinstallprompt', (event) => {
// Only suppress the native banner when we have a header CTA that will
// call event.prompt() on tap. Otherwise Chrome logs
// "Banner not shown: preventDefault() called" and never shows a prompt.
const promptFn = (event as { prompt?: () => Promise<void> }).prompt
if (typeof promptFn !== 'function' || !canUseCustomInstallCta()) {
return
}
event.preventDefault()
deferredInstall = event
syncInstallUi()
})
window.addEventListener('appinstalled', () => {
deferredInstall = null
iosHintOpen = false
syncInstallUi()
})
window.pakPromptInstall = promptInstall
syncInstallUi()
}