How to get a PWA to prompt you to install your website as an app

Progressive Web Applications

Getting Laravel up and running as an PWA application isn't tat hard. The challenge is rather what exactly do you want to do with said PWA? Here are some choices:

  • Have sexy icons
  • Send notifications
  • Install itself as an app
  • Do offline stuff
  • Online/offline sync

Since doing offline stuff is a bit of a rabbit hole, we're going to focus on install itself as an app. During the journey to get this working, we'll actually cover the entire lifecycle of getting a Laravel application up and running with PWA.

Note: Apple has bastardised PWAs in favour of their monopoly. If you need advice on getting this working on iPhone, see here:

Skipping some terminology and catchphrases, getting an PWA up and running means getting a Javascript service working running when a page is loaded, ie. your home page. You could of course to this on another page, but for most people the home page is it.

When we started this journey we found many articles referencing this repository: https://github.com/silviolleite/laravel-pwa

Most of this article's beginning is about reverse engineering that repo. The repo is great, but don't fool yourself. Actually the repo does very little, and the author is quite clear what is does in the readme. My opinion is you can skip a lot in the repo or install the repo and then roll your own, because mostly you would want control. Also, remember PWA programming involves a lot of Javascript so at the end of the day the repo just assist with the boilerplate and you'll be doing more Javascript than anything else.

Continuing, here is the most important bit of boilerplate you'll need to get started, namely the head section:

@include('pwa')
lang-php

What are we including? Here is it:

@php($config = config('pwa.manifest'))

<!-- Web Application Manifest -->
<link rel="manifest" href="{{ route('pwa.manifest') }}">
<!-- Chrome for Android theme color -->
<meta name="theme-color" content="{{ $config['theme_color'] }}">

<!-- Add to homescreen for Chrome on Android -->
<meta name="mobile-web-app-capable" content="{{ $config['display'] == 'standalone' ? 'yes' : 'no' }}">
<meta name="application-name" content="{{ $config['short_name'] }}">
<link rel="icon" sizes="{{ data_get(end($config['icons']), 'sizes') }}" href="{{ data_get(end($config['icons']), 'src') }}">

<!-- Add to homescreen for Safari on iOS -->
<meta name="apple-mobile-web-app-capable" content="{{ $config['display'] == 'standalone' ? 'yes' : 'no' }}">
<meta name="apple-mobile-web-app-status-bar-style" content="{{  $config['status_bar'] }}">
<meta name="apple-mobile-web-app-title" content="{{ $config['short_name'] }}">
<link rel="apple-touch-icon" href="{{ data_get(end($config['icons']), 'src') }}">


<link href="{{ $config['splash']['640x1136'] }}" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="{{ $config['splash']['750x1334'] }}" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="{{ $config['splash']['1242x2208'] }}" media="(device-width: 621px) and (device-height: 1104px) and (-webkit-device-pixel-ratio: 3)" rel="apple-touch-startup-image" />
<link href="{{ $config['splash']['1125x2436'] }}" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)" rel="apple-touch-startup-image" />
<link href="{{ $config['splash']['828x1792'] }}" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="{{ $config['splash']['1242x2688'] }}" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3)" rel="apple-touch-startup-image" />
<link href="{{ $config['splash']['1536x2048'] }}" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="{{ $config['splash']['1668x2224'] }}" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="{{ $config['splash']['1668x2388'] }}" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="{{ $config['splash']['2048x2732'] }}" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />

<!-- Tile for Win8 -->
<meta name="msapplication-TileColor" content="{{ $config['background_color'] }}">
<meta name="msapplication-TileImage" content="{{ data_get(end($config['icons']), 'src') }}">

<script type="text/javascript">
    // Initialize the service worker
    if ('serviceWorker' in navigator) {
        navigator.serviceWorker.register('/serviceworker.js', {
            scope: '.'
        }).then(function (registration) {
            // Registration was successful
            console.log('ServiceWorker registration successful with scope: ', registration.scope);
            console.log("Online status: " + navigator.onLine);
        }, function (err) {
            // registration failed :(
            console.log('ServiceWorker registration failed: ', err);
            console.log("Online status: " + navigator.onLine);
        });
    }
</script>
lang-html

Note the extra bit of Javascript console output added:

console.log("Online status: " + navigator.onLine);

Next let's understand the `pwa.manifest` config and `pwa.manifest` routes:

<?php

return [
    'name' => 'PWA Name Placeholder in config',
    'manifest' => [
        'name' => env('APP_NAME', 'Progressive Web App'),
        'short_name' => 'Vitacare',
        'start_url' => '/',
        'background_color' => '#ffffff',
        'theme_color' => '#000000',
        'display' => 'standalone',
        'orientation'=> 'any',
        'status_bar'=> 'black',
        'icons' => [
            '72x72' => [
                'path' => '/images/icons/icon-72x72.png',
                'purpose' => 'any'
            ],
            '96x96' => [
                'path' => '/images/icons/icon-96x96.png',
                'purpose' => 'any'
            ],
            '128x128' => [
                'path' => '/images/icons/icon-128x128.png',
                'purpose' => 'any'
            ],
            '144x144' => [
                'path' => '/images/icons/icon-144x144.png',
                'purpose' => 'any'
            ],
            '152x152' => [
                'path' => '/images/icons/icon-152x152.png',
                'purpose' => 'any'
            ],
            '192x192' => [
                'path' => '/images/icons/icon-192x192.png',
                'purpose' => 'any'
            ],
            '384x384' => [
                'path' => '/images/icons/icon-384x384.png',
                'purpose' => 'any'
            ],
            '512x512' => [
                'path' => '/images/icons/icon-512x512.png',
                'purpose' => 'any'
            ],
        ],
        'splash' => [
            '640x1136' => '/images/icons/splash-640x1136.png',
            '750x1334' => '/images/icons/splash-750x1334.png',
            '828x1792' => '/images/icons/splash-828x1792.png',
            '1125x2436' => '/images/icons/splash-1125x2436.png',
            '1242x2208' => '/images/icons/splash-1242x2208.png',
            '1242x2688' => '/images/icons/splash-1242x2688.png',
            '1536x2048' => '/images/icons/splash-1536x2048.png',
            '1668x2224' => '/images/icons/splash-1668x2224.png',
            '1668x2388' => '/images/icons/splash-1668x2388.png',
            '2048x2732' => '/images/icons/splash-2048x2732.png',
        ],
        'shortcuts' => [
            [
                'name' => 'Shortcut Link 1',
                'description' => 'Shortcut Link 1 Description',
                'url' => '/shortcutlink1',
                'icons' => [
                    "src" => "/images/icons/icon-72x72.png",
                    "purpose" => "any"
                ]
            ],
            [
                'name' => 'Shortcut Link 2',
                'description' => 'Shortcut Link 2 Description',
                'url' => '/shortcutlink2'
            ]
        ],
        'custom' => []
    ]
];
lang-php

The PWA routes look like this:

<?php

Route::group(['as' => 'pwa.'], function()
{
    Route::get('/manifest.json', 'PWAController@manifestJson')
    ->name('manifest');
    Route::get('/offline/', 'PWAController@offline');
});
lang-php

So far most items are entirely self explanatory.

We'll cover controller methods, because what we really want to do is get our hands dirty on some Javascript. So here is the key file you'll be spending time on:

It's copied to /public by the repository:

var staticCacheName = "pwa-v" + new Date().getTime();
var filesToCache = [
    '/offline',
    '/css/app.css',
    '/js/app.js',
    '/images/icons/icon-72x72.png',
    '/images/icons/icon-96x96.png',
    '/images/icons/icon-128x128.png',
    '/images/icons/icon-144x144.png',
    '/images/icons/icon-152x152.png',
    '/images/icons/icon-192x192.png',
    '/images/icons/icon-384x384.png',
    '/images/icons/icon-512x512.png',
];

// Cache on install
self.addEventListener("install", event => {
    this.skipWaiting();
    event.waitUntil(
        caches.open(staticCacheName)
            .then(cache => {
                return cache.addAll(filesToCache);
            })
    )
});

// Clear cache on activate
self.addEventListener('activate', event => {
    event.waitUntil(
        caches.keys().then(cacheNames => {
            return Promise.all(
                cacheNames
                    .filter(cacheName => (cacheName.startsWith("pwa-")))
                    .filter(cacheName => (cacheName !== staticCacheName))
                    .map(cacheName => caches.delete(cacheName))
            );
        })
    );
});

// Serve from Cache
self.addEventListener("fetch", event => {
    event.respondWith(
        caches.match(event.request)
            .then(response => {
                return response || fetch(event.request);
            })
            .catch(() => {
                return caches.match('offline');
            })
    )
});
lang-js

Great! Now we have a foundation and can move to the actual step of asking about installation.


Once the app is installed, you might want to uninstall it. To do so, we followed this article:

https://support.google.com/chrome/answer/9658361

  1. On your computer, open Chrome.
  2. Go to a website you want to uninstall.
  3. At the top right, click More
    Uninstall [app name].
  4. Click Remove.
    • To delete app data from Chrome, select "Also delete data from Chrome."

The problem was then that the app never wanted to install again.

You can see all Chrome apps like this:

chrome://apps

But it was gone. Advice was to clear cookies, cache, and history, but this seemed radical to me.

Next problem is where is the install icon?

https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/How_to/Define_app_icons

Commenting out this work on Chrome, Mac:

// If you comment out this icon, on Chrome, Mac, Storm's icon will be used
'152x152' => [
    'path' => '/images/icons/pwa/icon-152x152.png',
    'purpose' => 'any'
],
lang-php

References:

The official documentation is pretty good:

https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable

What nice about the official docs is it mentions the various stores for Android and Apple and guides how to get your app listed.

https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeinstallprompt_event

Here is more about icons:

https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/How_to/Define_app_icons