Newer
Older
navi-1 / webclient / src / composables / usePwa.js
/**
 * Install state for the App settings page.
 *
 * `beforeinstallprompt` fires once and early, and only if nobody has called
 * preventDefault() on it — a listener registered when the panel mounts is
 * already too late. So it lives here, at module scope, and main.js imports
 * this module so the listener is armed on every start.
 */
import { computed, ref } from 'vue'
import { bridgeAvailable } from './nativeBridge.js'

function standalone() {
  if (typeof window === 'undefined') return false
  if (window.matchMedia?.('(display-mode: standalone)')?.matches) return true
  return window.navigator?.standalone === true // iOS Safari
}

/** iPadOS reports itself as a Mac; the touch points give it away. */
export const isIos =
  typeof navigator !== 'undefined' &&
  (/iPad|iPhone|iPod/.test(navigator.userAgent) ||
    (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1))

const deferred = ref(null)
const installed = ref(standalone())

if (typeof window !== 'undefined') {
  window.addEventListener('beforeinstallprompt', (event) => {
    // Keep the event instead of letting the browser show its own banner: the
    // button on the App page replays it on a real user gesture.
    event.preventDefault()
    deferred.value = event
  })
  window.addEventListener('appinstalled', () => {
    deferred.value = null
    installed.value = true
  })
  window
    .matchMedia?.('(display-mode: standalone)')
    ?.addEventListener?.('change', (event) => {
      installed.value = event.matches
    })
}

export function usePwa() {
  /** 'android-app' | 'installed' | 'prompt' | 'manual' */
  const state = computed(() => {
    if (bridgeAvailable()) return 'android-app'
    if (installed.value) return 'installed'
    if (deferred.value) return 'prompt'
    return 'manual'
  })

  /** Replay the captured prompt. Resolves to 'accepted'/'dismissed', or null. */
  async function install() {
    const event = deferred.value
    if (!event) return null
    event.prompt()
    const choice = await event.userChoice
    // Whatever the answer, the event is spent — never keep it for a second try.
    deferred.value = null
    if (choice?.outcome === 'accepted') installed.value = true
    return choice?.outcome ?? null
  }

  return { state, install, isIos }
}