Skip to content

CodeCoupler Application Basics PWA Handling 🔒

What is this?

If the browser spots that a PWA has been loaded here, it gives the user a dialog box to install it locally. Once it's installed, you'll have some extra options for customising the window title bar of your local application.

This library lets you intercept the installation dialog and only open it when the user wants to.

Once the app's installed, the automatically set classes help you design the window title bar.

Handling the installation prompt works only in chromium based browsers!

Setup

Install Bundle:

npm i @codecoupler-pro/ui-bundle

Define Aliases, Externals and Assets:

{
  module: "@codecoupler-pro/app-pwa",
  global: ["codecoupler", "app", "pwa"]
},
{
  module: "@codecoupler-pro/ui-bundle",
  global: ["codecoupler", "ui", "bundle"],
  entries: [
    "dist/codecoupler-pro-ui-bundle.js"
  ]
}

Basic Usage

1
2
3
import { PWA } from "@codecoupler-pro/app-pwa";
let pwa = new PWA();
pwa.pwaInstallPrompt.then(callbackInstallPromptFromPromise);

Or

import { PWA } from "@codecoupler-pro/app-pwa";
new PWA({ callbackInstallPrompt: callbackInstallPromptFromConstructor });

The callback function will be executed whenever the browser wants to show the install button.

Read the chapter Prerequisites for further requirements for developing a PWA in a development environment!

Handling Installation Prompt

Define a callback to get the prompt event handle. Whether this event is triggered depends on many factors. The browser will check that the PWA is configured correctly and is not running in an iframe, and that the PWA is already installed. Additionally, the PWA class will check whether the application is currently running in browser mode. This does not include full-screen mode.

Although there are several ways to define a handler, you should ensure that only one handler processes the installation prompt. This handler determines whether and when the user is shown the browser installation dialog.

There are two options:

Using the Promise

Use the result of the promise after initialisation to retrieve the prompt event handle. The promise will only ever be resolved, never rejected:

pwa = new PWA();
pwa.pwaInstallPrompt.then(callbackInstallPromptFromPromise);

The promise resolves to the instance itself. The promise will only be called once, and you will have to use the BeforeInstallPromptEvent object from the instance property promptEvent. Bear in mind that the user can cancel the browser dialogue and click your install button again. In this case, you will need to use the BeforeInstallPromptEvent object from this property again, as it will have changed.

function callbackInstallPromptFromPromise(pwa) {
  // Create here or enable any existing custom install button.
  // This function will be called only one time.
  // The following event listener will be created only one time.
  installButton.addEventListener("click", () => {
    // Avoid multiple clicks.
    installButton.disabled = true;
    // Here the handler will get always the newest prompt event.
    pwa.promptEvent.prompt();
    pwa.promptEvent.userChoice.then((choice) => {
      if (choice.outcome === "accepted") {
        installButton.remove();
      } else {
        // Do not remove the button if the browser dialog was not accepted.
        // The user can click again on this button to show the browser dialog again.
        // Normally we would have to wait for the next notification to enable this button.
        // But as we use here the promise we will not receive any more.
        // So we enable this button and we assume that the browser dialog can be reopened.
        installButton.disabled = false;
      }
    });
  });
}

Using the constructor

Define a callback during initialisation to handle the installation prompt:

pwa = new PWA({ callbackInstallPrompt: callbackInstallPromptFromConstructor });

The callback receives the BeforeInstallPromptEvent object as the first argument. Bear in mind that the user can cancel the browser dialogue and click your install button again. In this case, you must use the BeforeInstallPromptEvent object again, as the arguments have changed. Also bear in mind that this function will then be called multiple times. You must handle both of these situations somehow.

function callbackInstallPromptFromConstructor(promptEvent) {
  // Create here or enable any existing custom install button.
  // Everytime this function is called we will save the argument in a buttons property.
  installButton.promptEvent = promptEvent;
  // Avoid to install the event listener multiple times.
  if (!installButton.installed) {
    installButton.installed = true;
    installButton.addEventListener("click", () => {
      // Avoid multiple clicks.
      installButton.disabled = true;
      // Get the prompt event from the buttons property.
      // You cannot access here the argument "promptEvent" of the outer function.
      installButton.promptEvent.prompt();
      installButton.promptEvent.userChoice.then((choice) => {
        if (choice.outcome === "accepted") {
          installButton.remove();
        }
        // Do not remove the button if the browser dialog was not accepted.
        // The user can click again on this button to show the browser dialog again.
        // You can keep here button disabled because the outer function will be called again and
        // enable the button.
      });
    });
  }
}

CSS Classes & Handling Window Controls Overlay

If the application runs in windows control overlay mode, the body will receive the following classes:

  • wco-visible: If the user hides the standard chrome title and the custom title should be shown.
  • wco-invisible: The standard chrome title of the application is used.
  • wco-controls-right: Window controls are on the right (like on Windows).
  • wco-controls-left: Window controls are on the left (like on macOS).

If the windows control overlay mode is not supported the class wco-invisible will be added.

Example for a CodeCoupler UI root component using an wco header

wco-header.module.css (You have to define --color-primary somewhere to use this example):

:global(body) {
  /* Styling with visible window control overlay */
  &.wco-visible :local(.wco-header) {
    height: env(titlebar-area-height) !important;
    width: env(titlebar-area-width) !important;
    background: var(--color-primary);
    color: white;
    font-size: 14px;
    -webkit-app-region: drag;
    app-region: drag;
    margin-left: env(titlebar-area-x);
    margin-top: env(titlebar-area-y);

    /* If you want to place elements in this header that should be handled as drag targets, you
    should add them the class "no-drag-children" */
    & .no-drag-children > * {
      -webkit-app-region: no-drag;
      app-region: no-drag;
    }

    /*
    Examples for using the left/right orientation of the controls. The classes
    exists only if window controls overlay is visible.

    &.wco-controls-right :local(.header) {
      background: linear-gradient(
        90deg,
        var(--color-primary),
        white 2%,
        var(--color-primary) 6%
      ) !important;
    }

    &.wco-controls-left :local(.header) {
      background: linear-gradient(
        90deg,
        var(--color-primary) 94%,
        white,
        var(--color-primary) 98%
      ) !important;
    }
    */
  }

  /* Styling with invisible window control overlay */
  &.wco-invisible :local(.wco-header) {
    display: none !important;
  }
}

root.js:

import stylesWcoHeader from "./styles/wco-header.module.css";

export default class extends Flexbox {
  static defaults = {
    "@codecoupler": {
      flexbox: {
        root: {
          type: "column",
          content: [
            {
              type: "stage",
              class: stylesWcoHeader["wco-header"],
              id: "wco-header"
            },
            {
              // Further Stages
            },
          ]
        }
      }
    }
  };
}

Reference

Constructor Options

The constructor expects an object with the following properties:

  • callbackInstallPrompt: Callback after installation prompt available. The callback receives the BeforeInstallPromptEvent object as the first argument.

Instance Member

pwaInstallPrompt(pwa)

Resolves after installation prompt available.

Prerequisites

Service Worker

The application must register a service worker with a fetch event handler. You can use the Service Worker Loader library to do this:

new ServiceWorkerLoader({
  callbackUpdateFound: (registration) => {
    if (registration.waiting)
      registration.waiting.postMessage({ codecoupler: "cc-skip-waiting" });
    setTimeout(async () => {
      let allRegistrations = await navigator.serviceWorker.getRegistrations();
      for (let r of allRegistrations) {
        await r.unregister();
      }
      window.location.reload();
    }, 2000);
  }
});

Serve over HTTPS

In order to install the PWA, your application must be served over HTTPS. It is recommended that you use the mkcert tool from GitHub. This will install your own CA which will be trusted by your system and your browsers and create a valid certificate for your devServer.

Install mkcert on Linux (el9 platform)
1
2
3
4
5
6
7
8
sudo yum install nss-tools

curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64"
chmod +x mkcert-v*-linux-amd64
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
rm mkcert-v*-linux-amd64

mkcert -install

First add the domain to your hosts file:

127.0.0.1 your.domain.local

Now change to the root of your project directory and run:

mkcert your.domain.local

If you are using Webpack you can run your devServer with these settings in your webpack.config.js:

const fs = require("fs");
module.exports = {
  devServer: {
    server: {
      type: "https",
      options: {
        key: fs.readFileSync("./your.domain.local-key.pem"),
        cert: fs.readFileSync("./your.domain.local.pem")
      }
    },
    port: 9441,
    allowedHosts: ["your.domain.local"],
    open: ["https://your.domain.local:9441"]
  }
};

Why not serve in another way over HTTPS?

In order to install the PWA, you must serve it over HTTPS. The simplest way to do this is to set the value of devServer.server in your webpack.config.js to https. Then, even if you have set another allowedHosts, you can open Chrome and type https://localhost:XXXX. This will allow you to install the app.

However, whenever you start the app, you will be warned about an invalid certificate. This will prompt you to open the Chrome browser, accept the certificate, and then return to the app.

However, you may still experience issues with the service worker not loading. The service worker will only be loaded from a secure origin.

You can start the Chrome browser or the app with the following options: --ignore-certificate-errors and --unsafely-treat-insecure-origin-as-secure=https://localhost:XXXX.

The problem here is that the flag unsafely-treat-insecure-origin-as-secure will not be enabled. You have to visit the page chrome://flags#unsafely-treat-insecure-origin-as-secure and enable it manually. Other resources say that you have to combine this with the option --user-data-dir=/tmp/test-only-chrome-profile-dir, but this does not enable the flag either.

However, if you want to experiment with these options you could set the property open to this:

module.exports = {
  devServer: {
    open: {
      app: {
        //The browser application name is platform-dependent. Don't hard code it in reusable
        //modules. For example, 'Chrome' is 'Google Chrome' on macOS, 'google-chrome' on Linux, and
        //'chrome' on Windows.
        name: "google-chrome",
        arguments: [
          "--ignore-certificate-errors",
          "--unsafely-treat-insecure-origin-as-secure=https://localhost:XXXX/"
        ]
      }
    }
  }
};

As you can see, there are many issues with using a self-signed certificate. It is therefore recommended that you use the mkcert tool.

Web Manifest and Icons

Your application must provide a minimum web manifest, a favicon in different sizes, and two screenshots:

Minimum manifest.webmanifest file
{
  "id": "any_id",
  "short_name": "Any Short Name",
  "name": "Any Longer Name",
  "description": "Any Description",
  "theme_color": "#303030",
  "dir": "auto",
  "lang": "en-US",
  "display": "standalone",
  "orientation": "any",
  "start_url": "/?homescreen=1",
  "background_color": "#fff",
  "display_override": ["window-controls-overlay"],
  "protocol_handlers": [
    {
      "protocol": "web+anytoken",
      "url": "/#protocol-handler/%s"
    }
  ],
  "screenshots": [
    {
      "src": "screenshot-wide.png",
      "sizes": "1920x1080",
      "type": "image/png",
      "form_factor": "wide",
      "label": "Any Label"
    },
    {
      "src": "screenshot-narrow.png",
      "sizes": "360x800",
      "type": "image/png",
      "form_factor": "narrow",
      "label": "Any Label"
    }
  ],
  "icons": [
    {
      "src": "icon-36x36.png",
      "sizes": "36x36",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icon-48x48.png",
      "sizes": "48x48",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icon-256x256.png",
      "sizes": "256x256",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any"
    }
  ]
}

Web Manifest and Icons using CodeCoupler Webpack

If you are using the CodeCoupler Webpack Configuration Factory you must provide a file named favicon.png in the static directory and create a much smaller manifest.webmanifest file. Both files must be in the static directory.

manifest.webmanifest file when using CodeCoupler Webpack
{
  "id": "any_id",
  "short_name": "Any Short Name",
  "name": "Any Longer Name",
  "description": "Any Description",
  "theme_color": "#303030",
  "protocol_handlers": [
    {
      "protocol": "web+anytoken",
      "url": "/#protocol-handler/%s"
    }
  ],
  "screenshots": [
    {
      "src": "screenshot-wide.png",
      "sizes": "1920x1080",
      "type": "image/png",
      "form_factor": "wide",
      "label": "Any Label"
    },
    {
      "src": "screenshot-narrow.png",
      "sizes": "360x800",
      "type": "image/png",
      "form_factor": "narrow",
      "label": "Any Label"
    }
  ],
  "display_override": ["window-controls-overlay"]
}

To enable testing in development mode, add this to the CodeCoupler Webpack Configuration:

1
2
3
4
5
plugins: {
  FaviconsWebpackPlugin: {
    devMode: "webapp";
  }
}

Further Styling

We recommend defining the chrome colour of the standalone application in the HTML page as well as in the web manifest, even if it is already defined there. The web manifest will not be refreshed every time it is changed. Changes to the colour in the HTML page will be visible after every refresh.

1
2
3
4
5
<html>
  <head>
    <meta name="theme-color" content="#303030" />
  </head>
</html>