Skip to content
← Public packages

@kentcdodds/pocket-piano

A pocket piano for choir practice: your singing part's range on your phone, installable and offline.

FollowForkOpen app

src/update-script.ts

163 lines · 6.0 KB · TypeScript
Raw
// Update check + update toast, adapted from @kentcdodds/package-app-kit starter-remix
// (app/ui/update-banner.tsx and app/ui/about-panel.tsx). Inlined on every page.
// Plain JS in a raw string: no backticks or template placeholders inside.
export const UPDATE_JS = String.raw`
(function () {
	'use strict'
	var APP_ID = 'pocket-piano'
	var root = document.documentElement
	var appBase = root.getAttribute('data-app-base') || ''
	var runningSha = root.getAttribute('data-running-sha') || ''

	function lastCheckKey() { return APP_ID + '-last-update-check' }
	function readLastCheck() {
		try {
			var v = localStorage.getItem(lastCheckKey())
			return v && Date.parse(v) ? v : null
		} catch (e) { return null }
	}
	function writeLastCheck(iso) {
		try { localStorage.setItem(lastCheckKey(), iso) } catch (e) {}
		return iso
	}
	function dismissKey(token) { return APP_ID + '-update-dismissed-' + token }
	function isDismissed(token) {
		try { return localStorage.getItem(dismissKey(token)) !== null } catch (e) { return false }
	}
	function markDismissed(token) {
		try { localStorage.setItem(dismissKey(token), String(Date.now())) } catch (e) {}
	}
	function formatLocal(iso) {
		var raw = String(iso || '').trim()
		if (!raw) return '-'
		var ms = Date.parse(raw)
		if (!isFinite(ms)) return '-'
		try {
			return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(ms))
		} catch (e) { return new Date(ms).toLocaleString() }
	}

	var waitingWorker = null
	var inFlight = null

	// Compare the running shell's SHA with the latest recorded publish, and nudge the
	// service worker to look for a new version. Resolves { kind, label, latest }.
	function check() {
		if (inFlight) return inFlight
		inFlight = fetch(appBase + '/api/version', { cache: 'no-store', credentials: 'same-origin' })
			.then(function (res) {
				if (!res.ok) throw new Error('Version check failed (' + res.status + ')')
				var type = res.headers.get('content-type') || ''
				if (type.indexOf('json') < 0) throw new Error('Version check got an unexpected response. Reopen the app and try again.')
				return res.json()
			})
			.then(function (version) {
				var regsP = 'serviceWorker' in navigator ? navigator.serviceWorker.getRegistrations() : Promise.resolve([])
				return regsP.then(function (regs) {
					waitingWorker = null
					return Promise.all(regs.map(function (reg) {
						if (reg.waiting) waitingWorker = reg.waiting
						return reg.update ? reg.update().catch(function () { return null }) : null
					}))
				}).then(function () { return version })
			})
			.then(function (version) {
				writeLastCheck(new Date().toISOString())
				var latestSha = String(version.sha || '')
				var kind = waitingWorker ? 'worker' : runningSha && latestSha && runningSha !== latestSha ? 'published' : null
				return {
					kind: kind,
					token: kind === 'worker' ? 'worker' : kind ? version.shortSha || latestSha.slice(0, 7) : null,
					latest: version,
				}
			})
			.catch(function (error) {
				writeLastCheck(new Date().toISOString())
				throw error
			})
			.finally(function () { inFlight = null })
		return inFlight
	}

	// Ask the service worker to re-fetch the cached app pages, then reload into the new version.
	function refresh() {
		if (waitingWorker) waitingWorker.postMessage('SKIP_WAITING')
		var controller = navigator.serviceWorker && navigator.serviceWorker.controller
		var done = function () { location.reload() }
		if (!controller) return done()
		var settled = false
		var channel = new MessageChannel()
		channel.port1.onmessage = function () { if (!settled) { settled = true; done() } }
		controller.postMessage({ type: 'REFRESH_SHELL' }, [channel.port2])
		setTimeout(function () { if (!settled) { settled = true; done() } }, 4000)
	}

	// ---------- Fixed, dismissible toast (no layout shift) ----------
	var toast = null
	var available = null
	function renderToast() {
		var show = available && !isDismissed(available)
		if (!show) {
			if (toast) toast.hidden = true
			return
		}
		if (!toast) {
			toast = document.createElement('div')
			toast.className = 'update-toast'
			toast.setAttribute('role', 'status')
			toast.setAttribute('aria-live', 'polite')
			toast.setAttribute('data-update-toast', '')
			toast.innerHTML =
				'<div><strong data-update-label></strong><p>Refresh when you are ready</p></div>' +
				'<div class="update-actions"><button type="button" class="accent" data-update-refresh>Refresh</button>' +
				'<button type="button" data-update-dismiss aria-label="Dismiss update">Dismiss</button></div>'
			toast.querySelector('[data-update-refresh]').addEventListener('click', function (e) {
				var btn = e.currentTarget
				if (btn.getAttribute('aria-busy') === 'true') return
				btn.setAttribute('aria-busy', 'true')
				btn.textContent = 'Refreshing...'
				requestAnimationFrame(refresh)
			})
			toast.querySelector('[data-update-dismiss]').addEventListener('click', function () {
				if (available) markDismissed(available)
				renderToast()
			})
			document.body.appendChild(toast)
		}
		toast.querySelector('[data-update-label]').textContent =
			available === 'worker' ? 'Update ready' : 'Update available (' + available + ')'
		toast.hidden = false
	}
	function checkAndToast() {
		return check().then(function (result) {
			available = result.token
			renderToast()
			return result
		}, function () { return null })
	}

	function showResult(result) {
		available = result ? result.token : null
		renderToast()
	}

	window.pocketPianoUpdate = {
		check: check,
		showResult: showResult,
		checkAndToast: checkAndToast,
		refresh: refresh,
		readLastCheck: readLastCheck,
		formatLocal: formatLocal,
		get runningSha() { return runningSha },
		get available() { return available },
	}

	if ('serviceWorker' in navigator && (location.protocol === 'https:' || location.hostname === '127.0.0.1' || location.hostname === 'localhost')) {
		navigator.serviceWorker.register(appBase + '/sw.js', { scope: appBase + '/' }).catch(function () {})
	}
	checkAndToast()
	document.addEventListener('visibilitychange', function () {
		if (document.visibilityState === 'visible') checkAndToast()
	})
})()
`