Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← 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, '&lt;')
		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()
}