25 {shinyMobile} and PWA

Transforming a classic Shiny app into a PWA is a game changer for end users. The first step is to setup a valid web manifest with icons and favicon.

Some of the PWA features won’t work with iOS, like the install prompt.

As a reminder, the code examples shown throughout this chapter are gathered in the {OSUICode} package accessible here, specifically PWA apps are available here.

25.1 Introduction

Below, we review one by one the necessary steps to convert a shiny app to a PWA. To get a good idea of what our mission exactly is, we leverage the Application tab of the developer tools.

The overall expected result is shown Figure 25.1. Alternatively, one may use the Google Lighthouse utility to provide a general diagnosis to the app, as illustrated on Figure 25.2. There are many categories like performance, accessibility. In our case, let’s just select the PWA category, check the mobile device radio and click on generate a report.

Application tab of the developers tools

FIGURE 25.1: Application tab of the developers tools

Google Lightouse utility

FIGURE 25.2: Google Lightouse utility

According to the diagnostic result displayed on Figure 25.3, we don’t meet all requirements, most importantly there is:

  • No manifest.
  • No service worker.
  • No icons.
Lighthouse audit result

FIGURE 25.3: Lighthouse audit result

25.2 {charpente} and PWA tools

charpente has tools to help design a PWA, particularly the set_pwa function which does all the previously mentioned steps in only one line of code. There are however a few prerequisites:

  • The app must belong to a package.
  • The function must target the app directory.

Let’s create a inst/examples/pwa-app sub-folder and the app.R file:

library(shiny)
library(shinyMobile)

ui <- f7_page(
  "Test",
  navbar = f7_navbar("Title"),
  toolbar = f7_toolbar(),
  title = "shinyMobile",
  options = list(
    theme = "ios",
    version = "1.0.0",
    touch = list(
      tapHold = TRUE
    ),
    color = "#42f5a1",
    filled = TRUE,
    dark = TRUE
  )
)

server <- function(input, output, session) {}
shinyApp(ui, server)

Then we set the PWA configuration with set_pwa. Overall, this function generates a manifest.webmanifest file, downloads the Google PWA compatibility script, adds a custom dependency pointing to the manifest.webmanifest file and a 144x144 icon file, copies a boilerplate service-worker.js with its offline.html page and registers the service worker (whose code is borrowed from web.dev):

window.addEventListener('load', () => {
  if ('serviceWorker' in navigator) {
    var pathname = window.location.pathname;
    navigator.serviceWorker
      .register(pathname + 'service-worker.js', { scope: pathname})
      .then(function() { console.log('Service Worker Registered'); });
  };
});

In the shinyMobile case, as Framework7 already registers any provided service worker, we don’t need that initialization script. Therefore, to skip the creation of sw-register.js and importing it in main.js, we should actually call:

set_pwa("inst/examples/pwa-app", register_service_worker = FALSE)

Importantly, this function does not handle icon creation. There are tools such as appsco and app-manifest, to create those custom icons and splash screens, if you need to.

In the following, we provide more detail about the mentioned steps.

25.2.1 Create the manifest

We would like to create a JSON configuration file like this:

{
  "short_name": "My App",
  "name": "Super amazing app",
  "description": "This app is just mind blowing",
  "icons": [
    {
      "src": "icons/icon.png",
      "type": "image/png",
      "sizes": "192x192"
    }
    // ...
  ],
  "start_url": "<APP_URL>",
  "background_color": "#3367D6",
  "display": "standalone",
  "scope": "/",
  "theme_color": "#3367D6",
  "shortcuts": [
    {
      "name": "Open toast",
      "short_name": "Shortcut",
      "description": "Do something",
      "url": "<APP_URL>/...",
      "icons": [{ "src": "icons/shortcut.png", "sizes": "192x192" }]
    }
  ]
}

This file has to be accessible by the app, hence best practice is to put it in the /www folder, icon images being hosted in the /www/icons sub-directory. The charpente create_manifest function writes a JSON file at the provided location. Interestingly the shortcuts fields gives the ability to start the app in a specific state, so that end users save time. This feature is only supported by latest Android devices as well as up to date Windows 10 computers (no Apple support). In practice, the shortcut url can be processed by shiny::parseQueryString on the server side. For instance, if the url contains a query string like https://domain/path/?foo=1, we could do:

observeEvent(session$clientData$url_search, {
  query <- parseQueryString(session$clientData$url_search)
  req(length(query) > 0)
  # Ways of accessing the values
  if (as.numeric(query$foo) == 1) {
    f7Toast(text = "Plop")
  }
})

The web manifest and icons have to be included in the head before the Google PWA compatibility script:

<link rel="manifest" href="manifest.webmanifest" />
<!-- include icon also from manifest -->
<link rel="icon" type="image/png" 
  href="icons/icon-144.png" sizes="144x144" />

set_pwa() internally calls create_pwa_dependency() which creates an HTML dependency containing all necessary resources:

#' PWA dependencies utils
#'
#' @description This function attaches PWA manifest and icons to the given tag
#'
#' @param tag Element to attach the dependencies.
#'
#' @importFrom utils packageVersion
#' @importFrom htmltools tagList htmlDependency
#' @export
add_pwa_deps <- function(tag) {
 pwa_deps <- htmlDependency(
  name = "pwa-utils",
  version = packageVersion("shinyMobile"),
  src = c(file = "shinyMobile-0.0.0.9000"),
  head = "<link rel=\"manifest\" 
    href=\"manifest.webmanifest\"/>
    <link rel=\"icon\" type=\"image/png\" 
    href=\"icons/icon-144.png\" sizes=\"144x144\" />",
  package = "mypkg2",
 )
 tagList(tag, pwa_deps)
}

In practice, since the package already relies on other dependencies like Framework7, we will leverage the add_dependencies() function to add all dependencies at once.

All provided icons must follow the convention icon-<size_in_px>.png like icon-144.png, which is the default.

25.2.2 Google PWA compatibility

As we use the Google PWA compatibility script, we have to include at least one icon like <link rel="icon" type="image/png" href="res/icon-128.png" sizes="128x128" />. However, we found some discrepancies between the developer tools recommendations and the PWA compatibility script. Therefore, we recommend to follow the developer tools prescriptions, that is to include at least one icon of size 144x144. All other elements are generated by the script itself, which is convenient. Indeed, having to handle all possible screen sizes and different OS is particularly tricky, repetitive, and not interesting.

The HTML dependency is downloaded with create_dependency("pwacompat", options = charpente_options(bundle = FALSE)). Don’t forget to update the add_dependencies() call in f7_page() by including the two new dependencies:

f7_page <- function(..., navbar, toolbar, title = NULL, 
                    options = NULL) {

  config_tag <- tags$script(
    type = "application/json",
    `data-for` = "app",
    jsonlite::toJSON(
      x = options,
      auto_unbox = TRUE,
      json_verbatim = TRUE
    )
  )

  # create body_tag
  body_tag <- tags$body(
    tags$div(
      id = "app",
      tags$div(
        class = "view view-main",
        tags$div(
          class = "page",
          navbar,
          toolbar,
          tags$div(
            class = "page-content",
            ...
          )
        )
      )
    ),
    config_tag
  )

  tagList(
    tags$head(
      tags$meta(charset = "utf-8"),
      tags$meta(
        name = "viewport",
        content = "width=device-width, initial-scale=1, 
        maximum-scale=1, minimum-scale=1, user-scalable=no, 
        viewport-fit=cover"
      ),
      tags$meta(
        name = "apple-mobile-web-app-capable",
        content = "yes"
      ),
      tags$meta(
        name = "theme-color",
        content = "#2196f3"
      ),
      tags$title(title)
    ),
    add_dependencies(
      body_tag,
      deps = c("framework7", "shinyMobile", "pwa", "pwacompat")
    )
  )
}

Calling devtools::load_all() and running the app again, you should see the new dependencies in the head (Figure 25.4).

New PWA dependencies in the head tag.

FIGURE 25.4: New PWA dependencies in the head tag.

Yet, according to Figure 25.5, we still miss the service worker, as shown in the manifest diagnostic. This demonstrates how powerful are the developer tools as the end user is always guided step by step.

Missing service worker registration.

FIGURE 25.5: Missing service worker registration.

25.2.3 Service worker and offline page

The second mandatory step to make our app installable is the service worker. We borrowed and modified the code from web.dev. set_pwa() copies this code in the the provided app /www folder:

/*
Copyright 2015, 2019, 2020 Google LLC. All Rights Reserved.
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 http://www.apache.org/licenses/LICENSE-2.0
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
*/

// Incrementing OFFLINE_VERSION will kick off the install event and force
// previously cached resources to be updated from the network.
const OFFLINE_VERSION = 1;
const CACHE_NAME = "offline";
// Customize this with a different URL if needed.
const OFFLINE_URL = "offline.html";

self.addEventListener("install", (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(CACHE_NAME);
      // Setting {cache: 'reload'} in the new request will 
      // ensure that the response isn't fulfilled from the 
      // HTTP cache; i.e., it will be from the network.
      await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
      await cache.add( new Request("framework7-5.7.14/css/framework7.bundle.min.css", { cache: "reload" }) );
      await cache.add( new Request("framework7-5.7.14/js/framework7.bundle.min.js", { cache: "reload" }) );
      await cache.add( new Request("shared/jquery.min.js", { cache: "reload" }) );
    })()
  );
  // Force the waiting service worker to become the active service worker.
  self.skipWaiting();
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      // Enable navigation preload if it's supported.
      // See https://developers.google.com/web/updates/2017/02/navigation-preload
      if ("navigationPreload" in self.registration) {
        await self.registration.navigationPreload.enable();
      }
    })()
  );

  // Tell the active service worker to take control of 
  // the page immediately.
  self.clients.claim();
});

self.addEventListener("fetch", (event) => {

  // Fix service-worker bug
  if (event.request.cache === 'only-if-cached') return;

  // We only want to call event.respondWith() if this 
  // is a navigation request for an HTML page ...
  if (event.request.mode === "navigate") {
    event.respondWith(
      (async () => {
        try {
          // First, try to use the navigation preload response 
          // if it's supported.
          const preloadResponse = await event.preloadResponse;
          if (preloadResponse) {
            return preloadResponse;
          }

          // Always try the network first.
          const networkResponse = await fetch(event.request);
          return networkResponse;
        } catch (error) {
          // catch is only triggered if an exception is thrown, 
          // which is likely due to a network error.
          // If fetch() returns a valid HTTP response with a 
          // response code in the 4xx or 5xx range, the catch() 
          // will NOT be called.
          console.log("Returning offline page instead.", error);

          const cache = await caches.open(CACHE_NAME);
          const cachedResponse = await cache.match(OFFLINE_URL);
          return cachedResponse;
        }
      })()
    );
  } else {
    // ... but also serve other cached assets 
    // (not a navigation request).
    event.respondWith(
      (async () => {
        
        try {
          // Always try the network first.
          const networkResponse = await fetch(event.request);
          return networkResponse;
          
        } catch (error) {
          
          const cache = await caches.open(CACHE_NAME);
          const cachedResponse = await cache.match(event.request);
          if (cachedResponse) return cachedResponse;
          
        }
      })()
    );
  }

  // If our if() condition is false, then this fetch handler 
  // will still intercept the
  // request and tries to return an answer either from 
  // network or from the relevant cache location. If there 
  // are any other fetch handlers registered, they will get a
  // chance to call event.respondWith(). If no fetch handlers 
  // call event.respondWith(), the request will be handled by 
  // the browser as if there were no service worker involvement.
});

Overall, the above service worker is composed of three steps:

  • Installation: cache is initialized and assets like HTML page, CSS, JS, images are asynchronously cached. In addition to caching the offline.html page, we also cache necessary assets. Their respective path is taken from the server location, for instance, Framework7 assets are located in framework7-5.7.14/... and jQuery assets in shared/. Best practice is to look at the developer tools Source tab which provides the right location.
  • Activation.
  • Fetch: the service worker intercepts all network requests sent by the client and returns answers according to a predefined strategy. Here we set the “network first” strategy, meaning we always try to return an answer from the network and fall back to the cache if the request failed (for instance in case of missing internet connection). In the above code, there are two kind of requests: navigation, that is related to an HTML page and other requests corresponding to static assets like CSS or JS. Therefore, we have an if and else statement to consider those two cases. If you would like to know more about caching strategies please refer to the Google documentation: https://developers.google.com/web/tools/workbox/modules/workbox-strategies.

This service worker redirects the end user to the offline cached page (offline.html) whenever the app is offline, thereby offering a better user experience.

We strongly advise to keep the same file names.

The next step involves the service worker registration. Framework7 has a dedicated module in the app configuration. We modify the config in init.js before initializing the app and run build_js() to update the minified file:

config.serviceWorker = {
  path: window.location.pathname + 'service-worker.js',
  scope: window.location.pathname
}

If the process is successful, you get the result shown in Figure 25.6.

Registered service worker.

FIGURE 25.6: Registered service worker.

The new PWA standard imposes to return a valid response when the app is offline. The offline page is also copied from charpente:

<!DOCTYPE html>
<html>
  <head>
    <!-- Required meta tags-->
    <meta charset="utf-8">
    <meta name="viewport" 
    content="width=device-width, 
    initial-scale=1, 
    maximum-scale=1, 
    minimum-scale=1, 
    user-scalable=no, 
    viewport-fit=cover">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <!-- Color theme for statusbar (Android only) -->
    <meta name="theme-color" content="#2196f3">
    <!-- Your app title -->
    <title>Oups</title>
    <!-- Path to Framework7 Library Bundle CSS -->
    <link 
      rel="stylesheet" 
      href="framework7-5.7.14/css/framework7.bundle.min.css">
    <!-- Path to your custom app styles-->
  </head>
  <body>
    <!-- App root element -->
    <div id="app">

      <!-- Your main view, should have "view-main" class -->
      <div class="view view-main">
        <!-- Initial Page, "data-name" contains page name -->
        <div data-name="home" class="page">

          <!-- Top Navbar -->
          <div class="navbar">
            <div class="navbar-bg"></div>
            <div class="navbar-inner">
              <div class="title">Offline page</div>
            </div>
          </div>

          <!-- Bottom Toolbar -->
          <div class="toolbar toolbar-bottom">
            <div class="toolbar-inner">
              <!-- Toolbar links -->
              <a href="#" class="link">Link 1</a>
              <a href="#" class="link">Link 2</a>
            </div>
          </div>

          <!-- Scrollable page content -->
          <div class="page-content">
            <div class="block">
              <p>Welcome to the offline page</p>
              <button 
                onclick="location.reload();" 
                class="toast-button button color-red col">
                Reload
              </button>
            </div>
          </div>
        </div>
      </div>
    </div>
    <!-- jQuery -->
    <script 
      type="text/javascript" 
      src="shared/jquery.min.js">
    </script>
    <!-- Path to Framework7 Library Bundle JS -->
    <script 
      type="text/javascript"     
      src="framework7-5.7.14/js/framework7.bundle.min.js">
    </script>
    <!-- Path to your app js -->
    <script>
      var app = new Framework7({
        // App root element
        root: '#app',
        // App Name
        name: 'My App',
        // App id
        id: 'com.myapp.test',
        // Enable swipe panel
        panel: {
          swipe: true,
        },
        autoDarkTheme: true,
        // ... other parameters
      });

      var mainView = app.views.create('.view-main');
      
      if (Framework7.device.standalone) {
        $("html, body").css({ height: "100vh", width: "100vw" });
        if ($(".appbar").length > 0) {
          $(".toolbar").css("margin-bottom", "20px");
        }
      }
    </script>
  </body>
</html>

Notice that jQuery, required for easier DOM interactions, as well as Framework7 CSS and JS assets are cached in the above service worker script, thereby making them available to offline.html. This offline fallback relies on Framework7 for consistency reasons but could be replace by any other HTML page, keeping in mind the update the service worker.

Now, let’s audit our app again: congrats! It is installable and reliable, although further PWA optimization may be provided.

Installable shinyMobile app.

FIGURE 25.7: Installable shinyMobile app.

A common source of error is the browser cache. It is best practice to regularly empty it. Alternatively, one may runs in incognito mode, which does not cache files.

25.2.4 Disable PWA for the end user

With the above approach, shinyMobile will always look for a service worker to register. Particularly, this would raise an error in case no service worker is found on the server. What if the user doesn’t want to create a PWA, let’s say for less important applications? We may add a parameter to f7_page, for instance allowPWA, that is either TRUE or FALSE, store its value in the body data-pwa attribute and recover it on the JS side within init.js:

// check if the app is intended to be a PWA
let isPWA = $('body').attr('data-pwa') === "true";

if (isPWA) {
  config.serviceWorker = {
    path: window.location.pathname + "service-worker.js",
    scope: window.location.pathname
  };
}

It only creates config.serviceWorker if the user specified allowPWA = TRUE.

25.3 Handle the installation

It is a great opportunity to propose a custom installation experience.

To be able to install the app, make sure to replace start_url by the url where the app is deployed like https://dgranjon.shinyapps.io/installable-pwa-app/ for instance. Missing that step would cause an issue during the service worker registration.

Once the installation criteria are met, the web browser raises the beforeinstallprompt event, (except on the iOS platform which is not compatible yet. We edit the init.js script and write:

let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
  // Prevent the mini-infobar from appearing on mobile
  e.preventDefault();
  // Stash the event so it can be triggered later.
  deferredPrompt = e;
});

This code adds an event listener to the window, prevents it from showing at start with e.preventDefault and captures it in an external variable called deferredPrompt. The next step comprises the design of our custom piece of UI that will trigger the prompt install. We can benefit from the rich Framework7 interface and display a toast containing an install button. The initialization is fairly simple, following the pattern app.<COMPONENT>.create(parameters):

// Create custom install UI
let installToast = app.toast.create({
  position: 'center',
  text: `<button 
    id="install-button" 
    class="toast-button button color-green">
    Install
  </button>`
});

We give it an id so as to call it later and edit the beforeinstallprompt event listener to show the toast:

let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
  // Prevent the mini-infobar from appearing on mobile
  e.preventDefault();
  // Stash the event so it can be triggered later.
  deferredPrompt = e;
  // Show install trigger
  installToast.open();
});

With jQuery like $(window).on('beforeinstallprompt', ...), we would capture the event with e.originalEvent!

We register a second event listener, which fires on the toast button click. We first close the toast, call the prompt method on the deferred event and log the result:

app.utils.nextTick(function() {
  $('#install-button').on('click', function() {
    // close install toast
    installToast.close();
    if (!deferredPrompt) {
      // The deferred prompt isn't available.
      return;
    }
    // Show the install prompt.
    deferredPrompt.prompt();
    // Log the result
    deferredPrompt.userChoice.then((result) => {
      console.log('OK', 'userChoice', result);
      // Reset the deferred prompt variable, since
      // prompt() can only be called once.
      deferredPrompt = null;
    });
  });
}, 500);

We run build_js() and deploy the app to shinyapps.io (remember: we must serve the app under HTTPS!). Figure 25.8 illustrates the install prompt window that appears to install the app. Once installed, the beforeinstallprompt event does not fire anymore and the app may be launched as a standalone app, for instance on macOSX (Figure 25.9).

Install prompt window.

FIGURE 25.8: Install prompt window.

Installed PWA on macOSX.

FIGURE 25.9: Installed PWA on macOSX.

On Figure 25.9, the blue window color corresponds to the tags$meta(name = "theme-color", content = "#2196f3"), passed in the f7_page layout element. Whenever the connection is lost, the redirection occurs to the offline.html page, as shown on Figure 25.10.

Offline HTML template.

FIGURE 25.10: Offline HTML template.

The final product may be run with:

shinyAppDir(system.file("shinyMobile/pwa", package = "OSUICode"))

25.4 Workbox

Workbox is a more robust alternative to the approach described above with boilerplate code to enable:

  • pre-caching of dependencies like CSS and JS but also images and Google fonts to improve performances.
  • Improve offline experience.

This part does not work yet at the time of review submission. I’ll probably remove it …

25.5 Other resources

The process described above works perfectly for any Shiny template. The reader may also consider other packages like {shiny.pwa}, that creates a PWA compatible structure at run time, within the app /www folder.