All files / Bandstand/src/components/pwa pwa.ts

14.68% Statements 21/143
33.33% Branches 2/6
13.33% Functions 2/15
14.68% Lines 21/143

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200                  1x                     1x                           1x                           1x                 1x 4x                     4x 4x 4x 4x                         1x                                                         1x 1x   1x 4x       4x 4x     4x 4x   1x       1x                                                                                                                                    
'use client'
 
/**
 * The app as an app: the service worker (offline pages, notifications),
 * whether it's been added to the home screen, the browser's own "Install"
 * offer, and turning notifications on and off for this device.
 */
 
/** Running from the home screen (or as an installed desktop app). */
export function isInstalled() {
  if (typeof window === 'undefined') return false
  return (
    window.matchMedia?.('(display-mode: standalone)').matches ||
    window.matchMedia?.('(display-mode: fullscreen)').matches ||
    (navigator as Navigator & {standalone?: boolean}).standalone === true
  )
}
 
export type Platform = 'ios' | 'android' | 'desktop'
 
export function platform(): Platform {
  if (typeof navigator === 'undefined') return 'desktop'
  const ua = navigator.userAgent
  // iPadOS says it's a Mac; it's the one with a touchscreen
  if (
    /iPad|iPhone|iPod/.test(ua) ||
    (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1)
  )
    return 'ios'
  if (/Android/.test(ua)) return 'android'
  return 'desktop'
}
 
/** "iPhone", "iPad", "Android", "Mac"…: so Account can name each device. */
export function deviceName() {
  const ua = navigator.userAgent
  if (/iPhone/.test(ua)) return 'iPhone'
  if (/iPad/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1))
    return 'iPad'
  if (/Android/.test(ua))
    return /Mobile/.test(ua) ? 'Android phone' : 'Android tablet'
  if (/Macintosh/.test(ua)) return 'Mac'
  if (/Windows/.test(ua)) return 'Windows'
  if (/Linux/.test(ua)) return 'Linux'
  return 'This device'
}
 
/** This browser can show notifications at all (iPhone: only as an app). */
export function pushSupported() {
  return (
    typeof window !== 'undefined' &&
    'serviceWorker' in navigator &&
    'PushManager' in window &&
    'Notification' in window
  )
}
 
export function registerServiceWorker() {
  if (typeof window === 'undefined' || !('serviceWorker' in navigator)) return
  // The public demo is static files; nothing to keep offline
  if (process.env.NEXT_PUBLIC_DEMO === '1') return
  const dev = process.env.NODE_ENV !== 'production'
  // The dev server only when asked: localStorage ms:sw-dev = "1" (pages
  // only; its code changes on every save) or "full" (as in production, to
  // try offline performance mode)
  let devMode: string | null = null
  try {
    devMode = localStorage.getItem('ms:sw-dev')
  } catch {}
  if (dev && !devMode) return
  const url = dev && devMode !== 'full' ? '/sw.js?dev=1' : '/sw.js'
  navigator.serviceWorker.register(url).catch(() => {})
}
 
/** Forget every saved page and photo (signing out, switching band). */
export async function clearSaved() {
  try {
    await Promise.all([caches.delete('pages'), caches.delete('media')])
  } catch {}
}
 
/**
 * Save pages ahead of time, so they open without signal. Resolves once the
 * service worker has them: how many of them it kept (null: no answer).
 */
export function saveForOffline(
  urls: string[],
): Promise<{saved: number; total: number} | null> {
  return new Promise((resolve) => {
    const sw = navigator.serviceWorker
    if (!sw) return resolve(null)
    const timer = setTimeout(() => resolve(null), 120_000)
    sw.ready
      .then((reg) => {
        const channel = new MessageChannel()
        channel.port1.onmessage = (e) => {
          clearTimeout(timer)
          resolve(e.data)
        }
        reg.active?.postMessage({type: 'save', urls}, [channel.port2])
      })
      .catch(() => resolve(null))
  })
}
 
// ---------------------------------------------------------------------------
// The browser's "Install" offer (Chrome, Edge, Android). iPhone and iPad
// have none: Share → Add to Home Screen.
// ---------------------------------------------------------------------------
 
type InstallEvent = Event & {
  prompt: () => Promise<void>
  userChoice: Promise<{outcome: string}>
}
let offer: InstallEvent | null = null
const listeners = new Set<() => void>()
 
export function watchInstallOffer() {
  window.addEventListener('beforeinstallprompt', (e) => {
    e.preventDefault()
    offer = e as InstallEvent
    listeners.forEach((l) => l())
  })
  window.addEventListener('appinstalled', () => {
    offer = null
    listeners.forEach((l) => l())
  })
}
 
export function canOfferInstall() {
  return offer !== null
}
 
export function onInstallOffer(l: () => void) {
  listeners.add(l)
  return () => {
    listeners.delete(l)
  }
}
 
export async function install() {
  if (!offer) return false
  await offer.prompt()
  const {outcome} = await offer.userChoice
  offer = null
  listeners.forEach((l) => l())
  return outcome === 'accepted'
}
 
// ---------------------------------------------------------------------------
// Notifications on this device
// ---------------------------------------------------------------------------
 
function fromBase64Url(s: string) {
  const b64 = (s + '='.repeat((4 - (s.length % 4)) % 4))
    .replace(/-/g, '+')
    .replace(/_/g, '/')
  return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
}
 
/** This device's subscription, if notifications are on here. */
export async function currentSubscription() {
  if (!pushSupported()) return null
  const reg = await navigator.serviceWorker.getRegistration()
  return (await reg?.pushManager.getSubscription()) ?? null
}
 
/** Ask (the browser shows its own question), then tell the server. */
export async function turnOn(): Promise<'on' | 'denied' | 'unavailable'> {
  if (!pushSupported()) return 'unavailable'
  const {key} = await (await fetch('/api/push/key')).json()
  if (!key) return 'unavailable'
  const permission = await Notification.requestPermission()
  if (permission !== 'granted') return 'denied'
  const reg = await navigator.serviceWorker.ready
  const sub =
    (await reg.pushManager.getSubscription()) ??
    (await reg.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: fromBase64Url(key),
    }))
  const r = await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({...sub.toJSON(), device: deviceName()}),
  })
  return r.ok ? 'on' : 'unavailable'
}
 
export async function turnOff() {
  const sub = await currentSubscription()
  if (!sub) return
  await fetch('/api/push/subscribe', {
    method: 'DELETE',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({endpoint: sub.endpoint}),
  }).catch(() => {})
  await sub.unsubscribe().catch(() => {})
}