Skip to main content
Topic: Elk Progressive Web App (Read 2763 times) previous topic - next topic
0 Members and 1 Guest are viewing this topic.

Elk Progressive Web App

What is a Progressive Web App?  You've probably spotted the talk here on Elk recently. In nutshell, a PWA isn't a native app installing fully on your mobile device.  It isn't just a web page either.  It's somewhere in between: a hybrid.  It installs files and scripts from your site to your device. During operation it works like a native app yet looks like your site. It even has your site's icon on the device homescreen! All this without going through app stores, sign ups, signatures, moon phases, wind shifts, and high seas pirates. Cool huh?

Great!!  I'm sold!!  How do I get this wonderful technology?  So glad you asked. The good news is it's easy.  And it's not.  Say what?  It's easy once you've met certain prerequisite criteria.  Your site must be served over an HTTPS connection.  OK, that's the tough one.  HTTPS is beyond the scope of this thread.  If you don't have it you really should look into it anyway....  The others are a Manifest and Service Worker must be utilized - but we will cover the how-to here.  ;)  So let's get started!

Note:  This is the result of badmonkeying around, and much hackering.  It's guaranteed not to be the best way to do this. The only claim here is I made it operational using this method on three Elk sites.  The hope is more knowledgeable people will add to this thread, thus improving this method.  I will update this post with such improvements.  Thanks in advance to those who contribute!    8)

The specs on my server: Nginx 1.13.9, PHP 7.2, MariaDB 10.2


Create the icons: You will need to create a series of png icons for the app.  It will need various sizes.  It is important to note the canvas areas must be square even if the image itself is not.  The sizes needed are: 48x48, 96x96, 144x144, 192x192, 256x256, and 512x512.  Next, in the webroot, create a folder called /app.  Upload these images to it.  Some people get creative with file structures.  Scope is critical here.  The directory needs to be in the webroot to work.

Here is an example of one of mine:  X

Create the manifest: In the webroot, create a file named manifest.json.  Give it permissions similar to the other operational files in your forum.  Below is a sample of a basic manifest file you may copy and paste into your manifest.json:

Code: [Select]
{
  "version": "1.0.1",
  "short_name": "My Site!",
  "name": "Mine!!",
  "description": "My application",
  "icons": [
    {
      "src": "/app/AC48.png",
      "type": "image/png",
      "sizes": "48x48"
    },
     {
      "src": "/app/AC96.png",
      "type": "image/png",
      "sizes": "96x96"
    },
    {
      "src": "/app/AC144.png",
      "type": "image/png",
      "sizes": "144x144"
    },
    {
      "src": "/app/AC192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "/app/AC256.png",
      "type": "image/png",
      "sizes": "256x256"
    },
    {
      "src": "/app/AC512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ],
  "start_url": "/index.php",
  "display": "standalone",
  "theme_color": "#384556",
  "background_color": "#384556"
}

Change the name to your site name, etc.  Change the icon names.  "Standalone" is configurable as well, to something like "fullscreen", though I don't recommend it.  Theme and background colors can be tweaked to match your site theme.  These change the colors of the header bar and the color of the splash screen, respectively.

Create the Service Worker:  OK, *technically* the script does this when the page loads. What we're doing here is creating the js file which performs this role.  Once again comes the warning: this must go in the webroot.  Create a file named sw.js.  Same permissions as you gave manifest.json.

Code: [Select]
// use a cacheName for cache versioning
var cacheName = 'v1:static';

// during the install phase you usually want to cache static assets
self.addEventListener('install', function(e) {
    // once the SW is installed, go ahead and fetch the resources to make this work offline
    e.waitUntil(
        caches.open(cacheName).then(function(cache) {
            return cache.addAll([
      '/themes/default/css/index.css',
      '/themes/default/css/_besocial/index_besocial.css',
      '/themes/default/css/_besocial/custom_besocial.css',
      '/themes/default/scripts/jquery-ui-1.12.1.min.js',
      '/themes/default/scripts/jquery.sceditor.bbcode.min.js',
    ]).then(function() {
                self.skipWaiting();
            });
        })
    );
});

// when the browser fetches a url
self.addEventListener('fetch', function(event) {
    // either respond with the cached object or go ahead and fetch the actual url
    event.respondWith(
        caches.match(event.request).then(function(response) {
            if (response) {
                // retrieve from cache
                return response;
            }
            // fetch as normal
            return fetch(event.request);
        })
    );
});


Notice the comma delimited cache list.  The function here is upon first app use, the files are downloaded and stored in the cache so they are always locally available.  Therefore the app can retrieve them locally, saving data over the air.  That said, I'm not personally convinced this has great value.  PWAs are heavily Chrome based, which is known for obscene caching. Seems to be little point here.


Inject some script:  There may be a number of ways to do this.  I did it the easy/hack way.  I installed the Simple Ads add on:
http://addons.elkarte.net/feature/Simple-Ads.html.  In the Position List, enable Overall Header. In Add Ad, name your block Service Worker.  Paste this code:

Code: [Select]
<html>
<head>
<link rel="manifest" href="', $boardurl, '/manifest.json">
<meta name="theme-color" content="#384556">
<meta http-equiv="Content-Type" content="application/x-web-app-manifest+json"/>
<script>
if('serviceWorker' in navigator) {
  navigator.serviceWorker
           .register('/sw.js')
           .then(function() { console.log("Service Worker Registered"); });
}

</script>
</head>
</html>

^^change the theme color to match your theme color again. Check "Active". Check "Overall Header". Check all membergroups you'd like to be able to use the app.  Check all actions. Check all boards. Edit Ad. 

The above could be done with Simple Portal, or a hook.  I'm not an eloquent enough programmer to pull that off.  Maybe some help from eman or spuds?  ;D

A template edit:  There is one core code edit. I've tried to figure out how to incorporate it into the code block above. Nada so far.  Anyway, it's a one liner.  In /themes/default/index.template.php:

Find:
Code: [Select]
// Show all the relative links, such as help, search, contents, and the like.
echo '

Add after:
Code: [Select]
<link rel="manifest" href="', $boardurl, '/manifest.json">

Restart php and apache/nginx. You should be good to go on the server!


So what now? Apparently there is at least some PWA support with Firefox, perhaps other browsers.  Most assuredly this works with Chrome.  SSSOOOO....using Chrome as the example....login, browse your site.  If you have everything correct, after some time you should get a banner inviting you to add the site to your homescreen.  Accept, enjoy!  8)


What doesn't work?  https://www.elkarte.net/community/index.php?topic=5114.0

Re: Elk Progressive Web App

Reply #1
Good job @badmonkey! I love to try this but I'll create an addon for it instead. So it'll be easier to add and remove.

Re: Elk Progressive Web App

Reply #2
Time to blow the dust off this one. Users love browsing the sites on the PWA!

The basics of setting this up remain the same. However, after much research, learning, and....errors....there's a better engine to drive this machine. The original sw.js version is bare bones - just enough to make it functional. Can we do better? How about caching forum pages so they may be accessed when a user is off the network? It turns out we can do just that. In fact, forum images can be part of the deal! The catch is a user's cache will store: 1. pages they've surfed, or 2. pages included in the sw.js file to be cached on install. This version also includes caching an offline.php file/URL to be displayed when a user is offline AND the requested page is not cached. That is, it's an included fallback. I created an offline.php to include a handful of SSI functions so users would have eye candy during their network vacation. Images not existing in cache have a "offline" notification as a fallback. This is a far more elegant user experience.

Without further ado, here is the current sw.js version. To be completely transparent, I did not write this code. I found it somewhere online, experimented with it, made a handful of tweaks, the rolled with it. My apologies to the original author, who deserves the credit for it. Unfortunately I cannot remember nor relocate the original source. If someone happens across it please post it here.

sw.js:
Code: [Select]
'use strict';
var SWversion = 'v1.0';

(function() {

    // A cache for core files like CSS and JavaScript
    var staticCacheName = 'static';
    // A cache for pages to store for offline
    var pagesCacheName = 'pages';
    // A cache for images to store for offline
    var imagesCacheName = 'images';
    // Update 'version' if you need to refresh the caches
    var version = 'v1::';

    // Store core files in a cache (including a page to display when offline)
    var updateStaticCache = function() {
        return caches.open(version + staticCacheName)
            .then(function (cache) {
                return cache.addAll([
                    'https://www.mysite.com/offline.php',
                ]);
            });
    };

    // Put an item in a specified cache
    var stashInCache = function (cacheName, request, response) {
        caches.open(cacheName)
            .then(function (cache) {
                cache.put(request, response);
            });
    };

    // Limit the number of items in a specified cache.
    var trimCache = function (cacheName, maxItems) {
        caches.open(cacheName)
            .then(function (cache) {
                cache.keys()
                    .then(function (keys) {
                        if (keys.length > maxItems) {
                            cache.delete(keys[0])
                                .then(trimCache(cacheName, maxItems));
                        }
                    });
            });
    };

    // Remove caches whose name is no longer valid
    var clearOldCaches = function() {
        return caches.keys()
            .then(function (keys) {
                return Promise.all(keys
                    .filter(function (key) {
                      return key.indexOf(version) !== 0;
                    })
                    .map(function (key) {
                      return caches.delete(key);
                    })
                );
            })
    };

    self.addEventListener('install', function (event) {
        event.waitUntil(updateStaticCache()
            .then(function () {
                return self.skipWaiting();
            })
        );
    });

    self.addEventListener('activate', function (event) {
        event.waitUntil(clearOldCaches()
            .then(function () {
                return self.clients.claim();
            })
        );
    });

    // See: https://brandonrozek.com/2015/11/limiting-cache-service-workers-revisited/
    self.addEventListener('message', function(event) {
        if (event.data.command == 'trimCaches') {
            trimCache(version + pagesCacheName, 35);
            trimCache(version + imagesCacheName, 40);
        }
    });

    self.addEventListener('fetch', function (event) {
        var request = event.request;
        // For non-GET requests, try the network first, fall back to the offline page
        if (event.request.method === "POST") {
            return;
        }
        if (request.method !== 'GET') {
            event.respondWith(
                fetch(request)
                    .catch(function () {
                        return caches.match('/offline.php');
                    })
            );
            return;
        }

        // For HTML requests, try the network first, fall back to the cache, finally the offline page
        if (request.headers.get('Accept').indexOf('text/html') !== -1) {
            // Fix for Chrome bug: https://code.google.com/p/chromium/issues/detail?id=573937
            if (request.mode != 'navigate') {
                request = new Request(request.url, {
                    method: 'GET',
                    headers: request.headers,
                    mode: request.mode,
                    credentials: request.credentials,
                    redirect: request.redirect
                });
            }
            event.respondWith(
                fetch(request)
                    .then(function (response) {
                        // NETWORK
                        // Stash a copy of this page in the pages cache
                        var copy = response.clone();
                        var cacheName = version + pagesCacheName;
                        stashInCache(cacheName, request, copy);
                        return response;
                    })
                    .catch(function () {
                        // CACHE or FALLBACK
                        return caches.match(request)
                            .then(function (response) {
                                return response || caches.match('/offline.php');
                            })
                    })
            );
            return;
        }

        // For non-HTML requests, look in the cache first, fall back to the network
        event.respondWith(
            caches.match(request)
                .then(function (response) {
                    // CACHE
                    return response || fetch(request)
                        .then(function (response) {
                            // NETWORK
                            // If the request is for an image, stash a copy of this image in the images cache
                            if (request.headers.get('Accept').indexOf('image') !== -1) {
                                var copy = response.clone();
                                var cacheName = version + imagesCacheName;
                                stashInCache(cacheName, request, copy);
                            }
                            return response;
                        })
                        .catch(function () {
                            // OFFLINE
                            // If the request is for an image, show an offline placeholder
                            if (request.headers.get('Accept').indexOf('image') !== -1) {
                                return new Response('<svg role="img" aria-labelledby="offline-title" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg"><title id="offline-title">Offline</title><g fill="none" fill-rule="evenodd"><path fill="#D8D8D8" d="M0 0h400v300H0z"/><text fill="#9B9B9B" font-family="Helvetica Neue,Arial,Helvetica,sans-serif" font-size="72" font-weight="bold"><tspan x="93" y="172">offline</tspan></text></g></svg>', { headers: { 'Content-Type': 'image/svg+xml' }});
                            }
                        });
                })
        );
    });

})();


Re: Elk Progressive Web App

Reply #3
Good work. I never have the time to try this though.

Re: Elk Progressive Web App

Reply #4
I just tried to get it work.
Chromium response when using the current sw.js:
Quote
(index):10 Service Worker Registered
sw.js:1 Uncaught (in promise) TypeError: Failed to fetch

Probably because offline.php is missing?

When I use the sw.js from your initial post, I get:
Quote
(index):10 Service Worker Registered
sw.js:1 Uncaught (in promise) TypeError: Request failed

FYI:
- Only one image has been used (144x144), since https://en.wikipedia.org/wiki/Progressive_web_application says that it's sufficient.
- PHP and Webserver restart (here apache) is - of course - not relevant for this, is n't it?

 

Re: Elk Progressive Web App

Reply #5
I just tried to get it work.
Chromium response when using the current sw.js:
Quote
(index):10 Service Worker Registered
sw.js:1 Uncaught (in promise) TypeError: Failed to fetch
  
Probably because offline.php is missing?


 
 

Yes, if offline.php is missing the Service Worker will fail to register. Obviously offline.php could be renamed to whatever you like. It could even be a simple static html page letting the user know they are offline (or whatever you'd like them to see).



- Only one image has been used (144x144), since https://en.wikipedia.org/wiki/Progressive_web_application says that it's sufficient.

 
 

From my understanding, only one image will be used in a given install as you say. It is supposed to be automatically chosen based on viewport size/configuration. The documentation suggests the others are included such that other viewports could utilize the optimal image size. Perhaps it's possible to use a single image size for all viewports? Not sure on that one, but that isn't my understanding of it.




- PHP and Webserver restart (here apache) is - of course - not relevant for this, is n't it?

 
 

Actually, it *should* be relevant. PHP and webserver restart will break their respective caches so the new files will be served properly. Depending on configuration, i.e. file caching aggressiveness, it may be necessary to restart them with each and all file changes.


Good luck rjm. You'll love it once it's working!