/**
* @namespace qui.pwa
*/
import Logger from '$qui/lib/logger.module.js'
import ConditionVariable from '$qui/base/condition-variable.js'
import {AssertionError} from '$qui/base/errors.js'
import Signal from '$qui/base/signal.js'
import Config from '$qui/config.js'
import * as QUI from '$qui/index.js'
import * as Navigation from '$qui/navigation.js'
import * as Theme from '$qui/theme.js'
import {appendBuildHash} from '$qui/utils/misc.js'
import * as PromiseUtils from '$qui/utils/promise.js'
import URL from '$qui/utils/url.js'
import * as Window from '$qui/window.js'
const SERVICE_WORKER_SCRIPT = 'service-worker.js'
const SERVICE_WORKER_MESSAGE_ACTIVATE = 'qui-activate'
const MANIFEST_FILE = 'manifest.json'
const logger = Logger.get('qui.pwa')
let manifestParams = {}
let serviceWorker = null
let serviceWorkerUpdateCalled = false /* duplicate call protection */
let installElementHandler = null
let installResponseHandler = null
let installPrompted = false
/**
* A condition that is fulfilled as soon as this client becomes controlled by a service worker.
* The condition is passed the service worker as parameter.
* @type {qui.base.ConditionVariable}
*/
export let whenServiceWorkerReady = new ConditionVariable()
/**
* Emitted whenever a message is received from the controlling service worker. Handlers are called with the following
* parameters:
* * `serviceWorker`, the new controlling service worker
* * `message`, the incoming message
* @type {qui.base.Signal}
*/
export const serviceWorkerMessageSignal = new Signal()
function handleServiceWorkerUpdate(sw, updateHandler) {
if (serviceWorkerUpdateCalled) {
return
}
serviceWorkerUpdateCalled = true
logger.info('service worker updated')
if (updateHandler) {
let result = updateHandler(sw)
if (result) {
if (result.then) { /* A promise */
result.then(function () {
provisionServiceWorker(sw)
}).catch(() => {})
}
else { /* Assuming a true value */
provisionServiceWorker(sw)
}
}
}
else {
provisionServiceWorker(sw)
}
}
function handleServiceWorkerReady(sw) {
logger.info('service worker is ready')
serviceWorker = sw
whenServiceWorkerReady.fulfill(serviceWorker)
}
function provisionServiceWorker(sw) {
logger.info('provisioning service worker')
let message = {
type: SERVICE_WORKER_MESSAGE_ACTIVATE,
config: Config.dump()
}
sw.postMessage(message)
}
function handleServiceWorkerMessage(message) {
logger.debug(`received service worker message: ${message.data}`)
serviceWorkerMessageSignal.emit(serviceWorker, message.data)
}
/**
* Tell if service workers are supported.
* @returns {Boolean}
*/
export function isServiceWorkerSupported() {
return 'serviceWorker' in navigator
}
/**
* Enable the service worker functionality.
*
* @param {String} [url] URL at which the service worker lives; {@link qui.config.navigationBasePrefix} +
* `"/service-worker.js"` will be used by default
* @param {Function} [updateHandler] a function to be called when the service worker is updated; should return a promise
* that will be used to control the activation of the new service worker
* @alias qui.pwa.enableServiceWorker
*/
export function enableServiceWorker(url = null, updateHandler = null) {
if (!('serviceWorker' in navigator)) {
throw new Error('service workers not supported')
}
if (!url) {
url = `${Config.navigationBasePrefix}/${SERVICE_WORKER_SCRIPT}`
}
url = appendBuildHash(url)
if (Config.debug) {
url += '&debug=true'
}
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
let refreshing = false
navigator.serviceWorker.addEventListener('controllerchange', function () {
if (refreshing) {
return
}
refreshing = true
Window.reload()
})
navigator.serviceWorker.ready.then(function (registration) {
handleServiceWorkerReady(registration.active)
})
navigator.serviceWorker.register(url).then(function (registration) {
logger.info(`service worker registered with scope "${registration.scope}"`)
registration.update() /* Manually trigger an update on each refresh */
function awaitStateChange() {
registration.installing.addEventListener('statechange', function () {
if (this.state === 'installed') {
handleServiceWorkerUpdate(this, updateHandler)
}
})
}
if (registration.waiting) {
return handleServiceWorkerUpdate(registration.waiting, updateHandler)
}
if (registration.installing) {
awaitStateChange()
}
registration.addEventListener('updatefound', awaitStateChange)
}).catch(function (e) {
logger.error(`service worker registration failed: ${e}`)
})
logger.debug(`service worker setup with URL = "${url}"`)
}
/**
* Return the service worker that currently controls the client.
* @returns {?ServiceWorker}
*/
export function getServiceWorker() {
return serviceWorker
}
/**
* Send a message to the controlling service worker.
* @param {*} message the message to send
*/
export function sendServiceWorkerMessage(message) {
if (!serviceWorker) {
throw new AssertionError('Attempt to send service worker message from uncontrolled client')
}
serviceWorker.postMessage(message)
}
/**
* A handler function responsible for providing a clickable element that prompts user for app installation.
* @callback qui.pwa.InstallElementHandler
* @returns {Promise<jQuery>} a promise that resolves to a clickable element that prompts for app installation upon
* click
*/
/**
* A handler function called after the user responds to installation prompt.
* @callback qui.pwa.InstallResponseHandler
* @param {Boolean} accepted indicates whether user accepted installation or not
*/
/**
* Configures the handler functions for the app installation.
*
* This must be called before {@link qui.init}.
*
* @alias qui.pwa.setInstallHandlers
* @param {qui.pwa.InstallElementHandler} elementHandler
* @param {?qui.pwa.InstallResponseHandler} [responseHandler]
*/
export function setInstallHandlers(elementHandler, responseHandler = null) {
installElementHandler = elementHandler
installResponseHandler = responseHandler
}
/**
* Setup the web app manifest.
* @alias qui.pwa.setupManifest
* @param {String} [url] the URL where the manifest file lives; {@link qui.config.navigationBasePrefix} +
* `"/manifest.json"` will be used if not specified
* @param {String} [displayName] an optional display name to append to the URL as a query argument (must be handled by
* the template rendering engine on the server side); defaults to {@link qui.config.appDisplayName}
* @param {String} [displayShortName] an optional short display name to append to the URL as a query argument (must be
* handled by the template rendering engine on the server side)
* @param {String} [description] an optional description to append to the URL as a query argument (must be handled by
* the template rendering engine on the server side)
* @param {String} [version] an optional version to append to the URL as a query argument (must be handled by the
* template rendering engine on the server side)
* @param {String} [themeColor] an optional theme color to append to the URL as a query argument (must be handled by the
* template rendering engine on the server side); defaults to `@interactive-color`
* @param {String} [backgroundColor] an optional background color to append to the URL as a query argument (must be
* handled by the template rendering engine on the server side); defaults to `@background-color`
*/
export function setupManifest({
url = `${Config.navigationBasePrefix}/${MANIFEST_FILE}`,
displayName = Config.appDisplayName,
displayShortName = null,
description = null,
version = null,
themeColor = Theme.getColor('@interactive-color'),
backgroundColor = Theme.getColor('@background-color')
} = {}) {
manifestParams = {
url,
displayName,
displayShortName,
description,
version,
themeColor,
backgroundColor
}
let manifest = Window.$document.find('link[rel=manifest]')
if (!manifest.length) {
throw new Error('Manifest link element not found')
}
url = appendBuildHash(url)
let parsedURL = URL.parse(url)
if (displayName != null) {
parsedURL.query['display_name'] = displayName
}
if (displayShortName != null) {
parsedURL.query['display_short_name'] = displayShortName
}
if (description != null) {
parsedURL.query['description'] = description
}
if (version != null) {
parsedURL.query['version'] = version
}
if (themeColor != null) {
parsedURL.query['theme_color'] = themeColor
}
if (backgroundColor != null) {
parsedURL.query['background_color'] = backgroundColor
}
url = parsedURL.toString()
manifest.attr('href', url)
logger.debug(`manifest setup with URL = "${url}"`)
}
export function init() {
Window.$window.on('beforeinstallprompt', function (e) {
logger.debug('received install prompt event')
if (installElementHandler) {
let promptEvent = e.originalEvent
promptEvent.preventDefault()
if (installPrompted) {
logger.debug('ignoring subsequent install prompt event')
return
}
installPrompted = true
/* Don't call the install handler before QUI & initial navigation are ready, as it will probably use UI
* elements that need to be initialized first; add an extra delay to allow the UI animations to settle */
Promise.all([QUI.whenReady, Navigation.whenInitialNavigationReady]).then(function () {
return PromiseUtils.later(1000)
}).then(function () {
logger.debug('calling install handler')
installElementHandler().then(function (element) {
element.on('click', function clickHandler() {
element.off('click', clickHandler) /* Don't prompt again */
promptEvent.prompt()
promptEvent.userChoice.then(function (choiceResult) {
if (choiceResult.outcome === 'accepted') {
logger.info('user accepted installation')
if (installResponseHandler) {
installResponseHandler(true)
}
}
else {
logger.info('user rejected installation')
if (installResponseHandler) {
installResponseHandler(false)
}
}
})
})
}).catch(() => {})
})
}
})
}