Turn the Pi-hole Admin Dashboard into a Progressive Web App

Pi-hole describes itself as “A black hole for Internet advertisements”. I think the GitHub description is more apt:

The Pi-hole® is a DNS sinkhole that protects your devices from unwanted content …

Fewer ads are nice, but tracker and malware blocking at the LAN DNS level is nicer. Pi-hole is the first install of my self-hosted adventure.

The admin web interface is pretty:

Pi-hole Admin Dashboard © Pi-hole

But it lacks one thing I’m obsessed with; full Progressive Web App support. The ability to install the website as it were.

I added support myself.

The mobile phone screenshot below is a before and after:

Pi-hole shortcut and PWA home screen icons

On the left is the standard home screen bookmark. Note the ugly Chrome icon-within-an-icon. The bookmark opens a web browser. This is the default “Add to Home screen” experience for Pi-hole – and any website – out of the box.

On the right is the progressive web app icon installed with Chrome’s WebAPK. It looks like a first-class app and opens in chromeless Chrome.

A little superficial but I want it.

Three step Pi-hole PWA

Pi-hole requires three things for full PWA support. First you need the admin dashboard running over a secure HTTPS connection. I’m using Traefik with Let’s Encrypt configured to proxy the Pi-hole web server.

I pointed a spare domain name at my public IP and forwarded ports 80 and 443 from my router to my Raspberry Pi. After the TLS certificate was generated by Traefik I closed the firewall and added a custom DNS record in Pi-hole itself to resolve that domain to the RPi’s local IP directly. I don’t want anything accessible to the outside world. I’ll need to re-open the firewall and disable the DNS rule to renew the certificate in a few months. In the meantime, browsers seem happy with the domain resolving to a non-public IP. I didn’t think this would work but it does (for now).

Second thing — a change to the Web Manifest file. Pi-hole has one but it needs a small amendment. Depending on your install you may find the location at:

/var/www/html/admin/img/favicons/manifest.json

Ensure the start_url property is set:

"start_url": "/admin/"

The third and final thing – a Service Worker. This is a JavaScript file that can do a lot or a little depending on what’s desired. For Pi-hole I’ve erred on the side of caution and added a minimum viable service worker.

The location:

/var/www/html/admin/sw.js

The JavaScript contents:

const ver = `5.0.0`;
const cacheName = `pihole-${ver}`;

self.addEventListener('install', (ev) => {
  console.log(`install`);
  self.skipWaiting();
});

self.addEventListener('activate', (ev) => {
  console.log(`activate`);
  ev.waitUntil(self.clients.claim());
  ev.waitUntil(
    caches.keys().then((keyList) =>
      Promise.all(
        keyList.map((key) => {
          if (key !== cacheName) {
            return caches.delete(key);
          }
        })
      )
    )
  );
});

const fromCache = (request) =>
  caches.open(cacheName).then((cache) => cache.match(request));

const updateCache = (request, response) =>
  caches.open(cacheName).then((cache) => cache.put(request, response));

const fetchAndCache = (ev) =>
  fetch(ev.request)
    .then((response) => {
      if (!response || response.status !== 200 || response.type !== 'basic') {
        return response;
      }
      ev.waitUntil(updateCache(ev.request, response.clone()));
      return response;
    })
    .catch((err) => {
      console.log(err);
    });

const allowTypes = ['js', 'css', 'gif', 'jpg', 'png', 'svg', 'ttf', 'otf', 'woff2'];

self.addEventListener('fetch', (ev) => {
  if (ev.request.method !== 'GET') {
    return;
  }
  const url = new URL(ev.request.url);
  const ext = url.pathname.split('.').pop();
  if (allowTypes.includes(ext) === false) {
    return;
  }
  ev.respondWith(
    fromCache(ev.request).then((response) => {
      if (response) {
        ev.waitUntil(fetchAndCache(ev));
        console.log(`from cache: ${url.pathname}`);
        return response;
      }
      console.log(`from fetch: ${url.pathname}`);
      return fetchAndCache(ev);
    })
  );
});

The service worker only intercepts and caches binary assets (images, fonts, etc). Even those requests are still sent to the server in the background. This offers minor performance benefits whilst avoiding breaking any Pi-hole functionality. Worst case scenario is that I may have to reload the page for an asset to refresh.

More importantly, it’s enough to meet the PWA requirements.

Now that I’ve done this, I wonder if an empty JavaScript file would have been enough? Write in and tell me if you test this before I do!

Finally, to ensure the service worker is installed I’ve added a <script> to the bottom of footer.php before the closing tags:

/var/www/html/admin/scripts/pi-hole/php/footer.php
<script>
if ('serviceWorker' in window.navigator) {
  window.navigator.serviceWorker.register('/admin/sw.js');
}
</script>
</body>
</html>

With these three things in place the Pi-hole admin dashboard now meets the requirements for PWA installation.

🍾 yay!

It’s quite probable that future Pi-hole updates will overwrite my amends. Hence the documentation here. I’d like to open a couple of Github issues and pull requests on the Pi-hole repo to get this working officially. I’ll do this if I have time to brush up on their developer etiquette. I don’t want to drop a drive-by issue without time to follow up appropriately. The web manifest tweak seems like an easy win. The default service worker would need discussion.

Buy me a coffee! Support me on Ko-fi