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.

src/install.ts

133 lines · 4.9 KB · TypeScript
/**
 * Client helpers for PWA install CTA + standalone detection.
 * Inspired by kody.video / court-projector; adapted for package-app fetch + SW.
 *
 * Client requirements (wire via Remix `InstallCta` or `installChromeHtml` + boot):
 * 1. Capture `beforeinstallprompt`. Call `preventDefault()` **only** when a
 *    custom header CTA exists and `event.prompt` is callable, then store it.
 *    If we cannot show that CTA, do not preventDefault (let the native banner).
 * 2. Keep a reserved header install slot; reveal the icon only when BIP fires
 *    (Chromium) or iOS A2HS guidance applies — visibility, not layout toggle.
 * 3. On Install click, call that stored `event.prompt()` (native install UI).
 *    Never leave a prevented event that is never prompted.
 * 4. Show Add-to-Home-Screen instructions only when UA is iOS (fixed overlay).
 * 5. Hide install UI when already standalone / installed.
 *
 * Tip pattern: icon-only install control uses `data-tip` + `aria-label`.
 * Labeled buttons (nav, Add, Check for updates) rely on their visible text.
 */

export type InstallUiOptions = {
	/** App display name used in install copy. */
	appName?: string
	/** localStorage key for dismissing the iOS hint. */
	hintDismissKey?: string
}

/**
 * Detect installed / standalone display modes (iOS + Chromium).
 * Use to hide install CTAs when the app is already installed.
 *
 * @returns Whether the app is running as an installed PWA-like display
 *
 * @example
 * import { isStandaloneDisplay } from 'kody:@kentcdodds/package-app-kit/install'
 * // browser only
 * const installed = isStandaloneDisplay()
 */
export function isStandaloneDisplay(
	nav: { standalone?: boolean } = typeof navigator !== 'undefined' ? (navigator as { standalone?: boolean }) : {},
	win: { matchMedia?: (q: string) => { matches: boolean } } = typeof window !== 'undefined' ? window : {},
): boolean {
	if (nav.standalone === true) return true
	try {
		if (win.matchMedia?.('(display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui)').matches) {
			return true
		}
	} catch {
		/* ignore */
	}
	try {
		return Boolean(win.matchMedia?.('(display-mode: standalone)').matches)
	} catch {
		return false
	}
}

/**
 * First-run Welcome splash is retired — install guidance lives on the header
 * icon (and iOS overlay). Kept as a stable export that always returns `false`
 * so older callers compile without painting in-flow Welcome chrome.
 *
 * @example
 * import { shouldShowFirstRunSplash } from 'kody:@kentcdodds/package-app-kit/install'
 * shouldShowFirstRunSplash({ standalone: false }) // always false
 */
export function shouldShowFirstRunSplash(_input: {
	standalone: boolean
	dismissed?: boolean
}): boolean {
	return false
}

/**
 * Detect iOS / iPadOS Safari (needs Add-to-Home-Screen instructions).
 *
 * @example
 * import { isIosBrowser } from 'kody:@kentcdodds/package-app-kit/install'
 * const ios = isIosBrowser()
 */
export function isIosBrowser(
	nav: { userAgent?: string; platform?: string; maxTouchPoints?: number } = typeof navigator !== 'undefined'
		? navigator
		: {},
): boolean {
	const ua = String(nav.userAgent || '')
	if (/iphone|ipad|ipod/i.test(ua)) return true
	return nav.platform === 'MacIntel' && (nav.maxTouchPoints || 0) > 1
}

/**
 * HTML for a reserved header install icon slot (+ optional iOS hint overlay host).
 * Slot stays in layout; the icon starts unavailable (`data-available="false"`) until
 * client JS reveals it for BIP or iOS — avoids CLS when install becomes available.
 *
 * Place this **to the right of the site title** in the header brand row.
 *
 * @example
 * import { installChromeHtml } from 'kody:@kentcdodds/package-app-kit/install'
 * const html = installChromeHtml({ appName: 'My App' })
 */
export function installChromeHtml(options: InstallUiOptions = {}): string {
	const name = options.appName || 'this app'
	const tip = `Install ${name}`
	return `
<span class="pak-install-slot" data-install-wrap data-available="false">
  <button class="pak-btn pak-btn-icon" type="button" data-install data-tip="${escapeAttr(tip)}" aria-label="${escapeAttr(tip)}" aria-hidden="true" tabindex="-1">
    <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="m8 11 4 4 4-4"/><path d="M5 21h14"/></svg>
  </button>
</span>
<div class="pak-install-hint-overlay" data-install-hint hidden>
  <div class="pak-card pak-stack pak-install-hint-card">
    <p class="pak-muted" data-install-hint-copy></p>
    <button class="pak-btn" type="button" data-install-hint-dismiss>Dismiss</button>
  </div>
</div>
`.trim()
}


function escapeHtml(value: string) {
	return value
		.replaceAll('&', '&amp;')
		.replaceAll('<', '&lt;')
		.replaceAll('>', '&gt;')
		.replaceAll('"', '&quot;')
}

function escapeAttr(value: string) {
	return escapeHtml(value).replaceAll("'", '&#39;')
}

/** Primary callable export for this subpath. */
export default installChromeHtml