Explore new and upcoming browser capabilities for your PWA: From Fugu With Love

1. Before you begin

Progressive Web Applications (PWAs) are a type of application software delivered through the web, built using common web technologies including HTML, CSS, and JavaScript. They are intended to work on any platform that uses a standards-compliant browser.

In this codelab, you'll start with a baseline PWA, and then explore new browser capabilities that eventually will give your PWA superpowers 🦸.

A lot of these new browser capabilities are in-flight and still being standardized, so at times you'll need to set browser flags in order to use them.

Prerequisites

For this codelab, you should be familiar with modern JavaScript, specifically promises and async/await. Since not all steps of the codelab are supported on all platforms, it helps for testing if you have additional devices at hand, for example, an Android phone or a laptop using a different operating system than the device you edit the code in. As an alternative to real devices, you can try to use simulators like the Android simulator or online services like BrowserStack that let you test from your present device. Else, you can also just skip any step, they don't depend on each other.

What you'll build

You'll build a greeting card web app, and learn how new and upcoming browser capabilities can enhance your app so that it delivers an advanced experience on certain browsers (but remains useful on all modern browsers).

You'll learn how to add support capabilities, like file system access, system clipboard access, contacts retrieval, periodic background sync, screen wake lock, sharing features, and more.

After working through the codelab, you will have a solid understanding of how to progressively enhance your web apps with new browser features, all while not putting a download burden on the subset of your users that happen to be on incompatible browsers, and, most importantly, while not excluding them from your app in the first place.

What you'll need

Fully supported browsers at this time are:

Using the particular Dev channel is recommended.

2. Project Fugu

Progressive Web Apps (PWA) are built and enhanced with modern APIs to deliver enhanced capabilities, reliability, and installability while reaching anyone on the web, anywhere in the world, using any type of device.

Some of these APIs are very powerful, and, if handled incorrectly, things can go wrong. Just like fugu fish 🐡: When you cut it right, it's a delicacy, but when you cut it wrong, it can be lethal (but don't worry, nothing can actually break in this codelab).

This is why the internal code name of the Web Capabilities project (that the involved companies are developing these new APIs in) is Project Fugu.

Web capabilities—already today—allow big and small enterprises to build on pure browser-based solutions, oftentimes allowing for faster deployment with lower development costs compared to going the platform-specific route.

3. Get started

Download either browser, and then set the following run-time flag 🚩 by navigating to about://flags, which works in both Chrome and Edge:

  • #enable-experimental-web-platform-features

After you have enabled it, restart your browser.

You will use the platform Glitch, as it allows you to host your PWA and because it has a decent editor. Glitch also supports the import and export to GitHub, so there is no vendor lock-in. Navigate to fugu-paint.glitch.me to try out the application. It's a basic drawing app 🎨 that you will improve during the codelab.

Fugu Greetings baseline PWA with a large canvas with the word “Google” painted on it.

After playing with the application, remix the app to create your own copy that you can edit. The URL of your remix will look something like glitch.com/edit/#!/bouncy-candytuft ("bouncy-candytuft" will be something else for you). This remix is directly accessible worldwide. Sign in to your existing account or create a new account on Glitch to save your work. You can see your app by clicking the "🕶 Show" button, and the URL of the hosted app will be something like bouncy-candytuft.glitch.me (note the .me instead of the .com as the top-level domain).

Now you're ready to edit your app and improve it. Whenever you make changes, the app will reload and your changes will be directly visible.

Glitch IDE showing the editing of an HTML document.

The following tasks should ideally be completed in order, but as noted above, you can always skip a step if you don't have access to a compatible device. Remember, each task is marked with either 🐟, a harmless freshwater fish, or 🐡, a "handle with care" fugu fish, alerting you to how experimental or not a feature is.

Check the Console in DevTools to see if an API is supported on the present device. We also use Glitch so you can check the same app on different devices easily, for example on your mobile phone and your desktop computer.

API compatibility logged to the Console in DevTools.

4. 🐟 Add Web Share API Support

Creating the most amazing drawings is boring if there's no one to appreciate them. Add a feature that allows your users to share their drawings with the world, in the form of greeting cards.

The Web Share API supports sharing files, and as you may remember, a File is just a specific kind of Blob. Therefore, in the file called share.mjs, import the share button and a convenience function toBlob() that converts the contents of a canvas to a blob and add the share functionality as per the code below.

If you have implemented this but don't see the button, it's because your browser doesn't implement the Web Share API.

import { shareButton, toBlob } from './script.mjs';

const share = async (title, text, blob) => {
  const data = {
    files: [
      new File([blob], 'fugu-greeting.png', {
        type: blob.type,
      }),
    ],
    title: title,
    text: text,
  };
  try {
    if (!navigator.canShare(data)) {
      throw new Error("Can't share data.", data);
    }
    await navigator.share(data);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

shareButton.style.display = 'block';
shareButton.addEventListener('click', async () => {
  return share('Fugu Greetings', 'From Fugu With Love', await toBlob());
});

5. 🐟 Add Web Share Target API Support

Now your users can share greeting cards made using the app, but you can also allow users to share images to your app and turn them into greeting cards. For this, you can use the Web Share Target API.

In the Web Application Manifest, you need to tell the app what kind of files you can accept and what URL the browser should call when one or several files are shared. The excerpt below of the file manifest.webmanifest shows this.

{
  "share_target": {
    "action": "./share-target/",
    "method": "POST",
    "enctype": "multipart/form-data",
    "params": {
      "files": [
        {
          "name": "image",
          "accept": ["image/jpeg", "image/png", "image/webp", "image/gif"]
        }
      ]
    }
  }
}

The service worker then deals with the received files. The URL ./share-target/ doesn't actually exist, the app just acts on it in the fetch handler and redirects the request to the root URL by adding a query parameter ?share-target:

self.addEventListener('fetch', (fetchEvent) => {
  /* 🐡 Start Web Share Target */
  if (
    fetchEvent.request.url.endsWith('/share-target/') &&
    fetchEvent.request.method === 'POST'
  ) {
    return fetchEvent.respondWith(
      (async () => {
        const formData = await fetchEvent.request.formData();
        const image = formData.get('image');
        const keys = await caches.keys();
        const mediaCache = await caches.open(
          keys.filter((key) => key.startsWith('media'))[0],
        );
        await mediaCache.put('shared-image', new Response(image));
        return Response.redirect('./?share-target', 303);
      })(),
    );
  }
  /* 🐡 End Web Share Target */

  /* ... */
});

When the app loads, it then checks if this query parameter is set, and if so, draws the shared image onto the canvas and deletes it from the cache. All this happens in script.mjs:

const restoreImageFromShare = async () => {
  const mediaCache = await getMediaCache();
  const image = await mediaCache.match('shared-image');
  if (image) {
    const blob = await image.blob();
    await drawBlob(blob);
    await mediaCache.delete('shared-image');
  }
};

This function is then used when the app initializes.

if (location.search.includes('share-target')) {
  restoreImageFromShare();
} else {
  drawDefaultImage();
}

6. 🐟 Add Import Image Support

Drawing everything from scratch is hard. Add a feature allowing your users to upload a local image from their device into the app.

First, read up on the canvas' drawImage() function. Next, make yourself familiar with the <​input
type=file>
element.

Armed with this knowledge, you can then edit the file called import_image_legacy.mjs and add the following snippet. At the top of the file you import the import button and a convenience function drawBlob() that lets you draw a blob onto the canvas.

import { importButton, drawBlob } from './script.mjs';

const importImage = async () => {
  return new Promise((resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = 'image/png, image/jpeg, image/*';
    input.addEventListener('change', () => {
      const file = input.files[0];
      input.remove();
      return resolve(file);
    });
    input.click();
  });
};

importButton.style.display = 'block';
importButton.addEventListener('click', async () => {
  const file = await importImage();
  if (file) {
    await drawBlob(file);
  }
});

7. 🐟 Add Export Image Support

How will your user save a file created in the app to their device? Traditionally, this has been achieved with an <​a
download>
element.

In the file export_image_legacy.mjs add the contents as follows below. Import the export button and a toBlob() convenience function that converts the canvas contents to a blob.

import { exportButton, toBlob } from './script.mjs';

export const exportImage = async (blob) => {
  const a = document.createElement('a');
  a.download = 'fugu-greeting.png';
  a.href = URL.createObjectURL(blob);
  a.addEventListener('click', (e) => {
    a.remove();
    setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
  });
  setTimeout(() => a.click(), 0);
};

exportButton.style.display = 'block';
exportButton.addEventListener('click', async () => {
  exportImage(await toBlob());
});

8. 🐟 Add File System Access API Support

Sharing is caring, but your users will probably want to save their best work to their own devices. Add a feature that allows your users to save (and re-open) their drawings.

Before, you used a <​input type=file> legacy approach for importing files and a <​a download> legacy approach for exporting files. Now, you'll use the File System Access API to improve the experience.

This API allows for the opening and saving of files from the operating system's file system. Edit the two files, import_image.mjs and export_image.mjs respectively, by adding the contents below. For these files to load, remove the 🐡 emojis from script.mjs.

Replace this line:

// Remove all the emojis for this feature test to succeed.
if ('show🐡Open🐡File🐡Picker' in window) {
  /* ... */
}

...with this line:

if ('showOpenFilePicker' in window) {
  /* ... */
}

In import_image.mjs:

import { importButton, drawBlob } from './script.mjs';

const importImage = async () => {
  try {
    const [handle] = await window.showOpenFilePicker({
      types: [
        {
          description: 'Image files',
          accept: {
            'image/*': ['.png', '.jpg', '.jpeg', '.avif', '.webp', '.svg'],
          },
        },
      ],
    });
    return await handle.getFile();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

importButton.style.display = 'block';
importButton.addEventListener('click', async () => {
  const file = await importImage();
  if (file) {
    await drawBlob(file);
  }
});

In export_image.mjs:

import { exportButton, toBlob } from './script.mjs';

const exportImage = async () => {
  try {
    const handle = await window.showSaveFilePicker({
      suggestedName: 'fugu-greetings.png',
      types: [
        {
          description: 'Image file',
          accept: {
            'image/png': ['.png'],
          },
        },
      ],
    });
    const blob = await toBlob();
    const writable = await handle.createWritable();
    await writable.write(blob);
    await writable.close();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

exportButton.style.display = 'block';
exportButton.addEventListener('click', async () => {
  await exportImage();
});

9. 🐟 Add Contacts Picker API Support

Your users may want to add a message to their greeting card, and address someone personally. Add a feature that allows your users to pick one (or multiple) of their local contacts and add their names to the share message.

On an Android or iOS device, the Contact Picker API allows you to pick contacts from the device's contacts manager app and return them to the application. Edit the file contacts.mjs and add the code below.

import { contactsButton, ctx, canvas } from './script.mjs';

const getContacts = async () => {
  const properties = ['name'];
  const options = { multiple: true };
  try {
    return await navigator.contacts.select(properties, options);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

contactsButton.style.display = 'block';
contactsButton.addEventListener('click', async () => {
  const contacts = await getContacts();
  if (contacts) {
    ctx.font = '1em Comic Sans MS';
    contacts.forEach((contact, index) => {
      ctx.fillText(contact.name.join(), 20, 16 * ++index, canvas.width);
    });
  }
});

10. 🐟 Add Async Clipboard API Support

Your users may want to paste a picture from another app into your app, or copy a drawing from your app into another app. Add a feature that allows your users to copy and paste images into and out of your app. The Async Clipboard API supports PNG images, so now you can read and write image data to the clipboard.

Find the file clipboard.mjs and add the following:

import { copyButton, pasteButton, toBlob, drawImage } from './script.mjs';

const copy = async (blob) => {
  try {
    await navigator.clipboard.write([
      /* global ClipboardItem */
      new ClipboardItem({
        [blob.type]: blob,
      }),
    ]);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const paste = async () => {
  try {
    const clipboardItems = await navigator.clipboard.read();
    for (const clipboardItem of clipboardItems) {
      try {
        for (const type of clipboardItem.types) {
          const blob = await clipboardItem.getType(type);
          return blob;
        }
      } catch (err) {
        console.error(err.name, err.message);
      }
    }
  } catch (err) {
    console.error(err.name, err.message);
  }
};

copyButton.style.display = 'block';
copyButton.addEventListener('click', async () => {
  await copy(await toBlob());
});

pasteButton.style.display = 'block';
pasteButton.addEventListener('click', async () => {
  const image = new Image();
  image.addEventListener('load', () => {
    drawImage(image);
  });
  image.src = URL.createObjectURL(await paste());
});

11. 🐟 Add Badging API Support

When your users install your app, an icon will appear on their home screen. You can use this icon to convey fun information, like the number of brushstrokes a given drawing has taken.

Add a feature that counts up the badge whenever your user makes a new brushstroke. The Badging API allows for a numeric badge to be set on the app icon. You can update the badge whenever a pointerdown event happens (that is, when a brushstroke occurs), and reset the badge when the canvas is cleared.

Put the code below in the file badge.mjs:

import { canvas, clearButton } from './script.mjs';

let strokes = 0;

canvas.addEventListener('pointerdown', () => {
  navigator.setAppBadge(++strokes);
});

clearButton.addEventListener('click', () => {
  strokes = 0;
  navigator.setAppBadge(strokes);
});

12. 🐟 Add Screen Wake Lock API Support

At times, your users may need a few moments to just stare at a drawing, long enough for the inspiration to come. Add a feature that keeps the screen awake and stops the screensaver from kicking in. The Screen Wake Lock API prevents the user's screen from falling asleep. The wake lock is automatically released when a visibility change event as defined by Page Visibility occurs. Therefore the wake lock must be reacquired when the page comes back into view.

Find the file wake_lock.mjs and add the contents below. To test if this works, configure your screensaver to show after one minute.

import { wakeLockInput, wakeLockLabel } from './script.mjs';

let wakeLock = null;

const requestWakeLock = async () => {
  try {
    wakeLock = await navigator.wakeLock.request('screen');
    wakeLock.addEventListener('release', () => {
      console.log('Wake Lock was released');
    });
    console.log('Wake Lock is active');
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const handleVisibilityChange = () => {
  if (wakeLock !== null && document.visibilityState === 'visible') {
    requestWakeLock();
  }
};

document.addEventListener('visibilitychange', handleVisibilityChange);

wakeLockInput.style.display = 'block';
wakeLockLabel.style.display = 'block';
wakeLockInput.addEventListener('change', async () => {
  if (wakeLockInput.checked) {
    await requestWakeLock();
  } else {
    wakeLock.release();
  }
});

13. 🐟 Add Periodic Background Sync API Support

Starting with a blank canvas can be boring. You can use the Periodic Background Sync API to initialize your users' canvas with a new image each day, for example, Unsplash's daily fugu photo.

This requires two files, a file periodic_background_sync.mjs that registers the Periodic Background Sync and another file image_of_the_day.mjs that deals with downloading the image of the day.

In periodic_background_sync.mjs:

import { periodicBackgroundSyncButton, drawBlob } from './script.mjs';

const getPermission = async () => {
  const status = await navigator.permissions.query({
    name: 'periodic-background-sync',
  });
  return status.state === 'granted';
};

const registerPeriodicBackgroundSync = async () => {
  const registration = await navigator.serviceWorker.ready;
  try {
    registration.periodicSync.register('image-of-the-day-sync', {
      // An interval of one day.
      minInterval: 24 * 60 * 60 * 1000,
    });
  } catch (err) {
    console.error(err.name, err.message);
  }
};

navigator.serviceWorker.addEventListener('message', async (event) => {
  const fakeURL = event.data.image;
  const mediaCache = await getMediaCache();
  const response = await mediaCache.match(fakeURL);
  drawBlob(await response.blob());
});

const getMediaCache = async () => {
  const keys = await caches.keys();
  return await caches.open(keys.filter((key) => key.startsWith('media'))[0]);
};

periodicBackgroundSyncButton.style.display = 'block';
periodicBackgroundSyncButton.addEventListener('click', async () => {
  if (await getPermission()) {
    await registerPeriodicBackgroundSync();
  }
  const mediaCache = await getMediaCache();
  let blob = await mediaCache.match('./assets/background.jpg');
  if (!blob) {
    blob = await mediaCache.match('./assets/fugu_greeting_card.jpg');
  }
  drawBlob(await blob.blob());
});

In image_of_the_day.mjs:

const getImageOfTheDay = async () => {
  try {
    const fishes = ['blowfish', 'pufferfish', 'fugu'];
    const fish = fishes[Math.floor(fishes.length * Math.random())];
    const response = await fetch(`https://source.unsplash.com/daily?${fish}`);
    if (!response.ok) {
      throw new Error('Response was', response.status, response.statusText);
    }
    return await response.blob();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

const getMediaCache = async () => {
  const keys = await caches.keys();
  return await caches.open(keys.filter((key) => key.startsWith('media'))[0]);
};

self.addEventListener('periodicsync', (syncEvent) => {
  if (syncEvent.tag === 'image-of-the-day-sync') {
    syncEvent.waitUntil(
      (async () => {
        try {
          const blob = await getImageOfTheDay();
          const mediaCache = await getMediaCache();
          const fakeURL = './assets/background.jpg';
          await mediaCache.put(fakeURL, new Response(blob));
          const clients = await self.clients.matchAll();
          clients.forEach((client) => {
            client.postMessage({
              image: fakeURL,
            });
          });
        } catch (err) {
          console.error(err.name, err.message);
        }
      })(),
    );
  }
});

14. 🐟 Add Shape Detection API Support

Sometimes your users' drawings or the used background images may contain useful information like, for example, barcodes. The Shape Detection API, and specifically, the Barcode Detection API, lets you extract this information. Add a feature that attempts to detect barcodes from your users' drawings. Locate the file barcode.mjs and add the contents below. To test this feature, just load or paste an image with a barcode onto the canvas. You can copy an example barcode from an image search for QR codes.

/* global BarcodeDetector */
import {
  scanButton,
  clearButton,
  canvas,
  ctx,
  CANVAS_BACKGROUND,
  CANVAS_COLOR,
  floor,
} from './script.mjs';

const barcodeDetector = new BarcodeDetector();

const detectBarcodes = async (canvas) => {
  return await barcodeDetector.detect(canvas);
};

scanButton.style.display = 'block';
let seenBarcodes = [];
clearButton.addEventListener('click', () => {
  seenBarcodes = [];
});
scanButton.addEventListener('click', async () => {
  const barcodes = await detectBarcodes(canvas);
  if (barcodes.length) {
    barcodes.forEach((barcode) => {
      const rawValue = barcode.rawValue;
      if (seenBarcodes.includes(rawValue)) {
        return;
      }
      seenBarcodes.push(rawValue);
      ctx.font = '1em Comic Sans MS';
      ctx.textAlign = 'center';
      ctx.fillStyle = CANVAS_BACKGROUND;
      const boundingBox = barcode.boundingBox;
      const left = boundingBox.left;
      const top = boundingBox.top;
      const height = boundingBox.height;
      const oneThirdHeight = floor(height / 3);
      const width = boundingBox.width;
      ctx.fillRect(left, top + oneThirdHeight, width, oneThirdHeight);
      ctx.fillStyle = CANVAS_COLOR;
      ctx.fillText(
        rawValue,
        left + floor(width / 2),
        top + floor(height / 2),
        width,
      );
    });
  }
});

15. 🐡 Add Idle Detection API Support

If you imagine your app was running in a kiosk-like setup, a useful feature would be to reset the canvas after a certain amount of inactivity. The Idle Detection API lets you detect when a user no longer interacts with their device.

Find the file idle_detection.mjs and paste in the contents below.

import { ephemeralInput, ephemeralLabel, clearCanvas } from './script.mjs';

let controller;

ephemeralInput.style.display = 'block';
ephemeralLabel.style.display = 'block';

ephemeralInput.addEventListener('change', async () => {
  if (ephemeralInput.checked) {
    const state = await IdleDetector.requestPermission();
    if (state !== 'granted') {
      ephemeralInput.checked = false;
      return alert('Idle detection permission must be granted!');
    }
    try {
      controller = new AbortController();
      const idleDetector = new IdleDetector();
      idleDetector.addEventListener('change', (e) => {
        const { userState, screenState } = e.target;
        console.log(`idle change: ${userState}, ${screenState}`);
        if (userState === 'idle') {
          clearCanvas();
        }
      });
      idleDetector.start({
        threshold: 60000,
        signal: controller.signal,
      });
    } catch (err) {
      console.error(err.name, err.message);
    }
  } else {
    console.log('Idle detection stopped.');
    controller.abort();
  }
});

16. 🐡 Add File Handling API Support

What if your users could just doubleclick an image file and your app would pop up? The File Handling API allows you to do just that.

You'll need to register the PWA as a file handler for images. This happens in the Web Application Manifest, the excerpt below of the file manifest.webmanifest shows this. (This is already part of the manifest, no need to add it yourself.)

{
  "file_handlers": [
    {
      "action": "./",
      "accept": {
        "image/*": [".jpg", ".jpeg", ".png", ".webp", ".svg"]
      }
    }
  ]
}

To actually handle opened files, add the code below to the file file-handling.mjs:

import { drawBlob } from './script.mjs';

const handleLaunchFiles = () => {
  window.launchQueue.setConsumer((launchParams) => {
    if (!launchParams.files.length) {
      return;
    }
    launchParams.files.forEach(async (handle) => {
      const file = await handle.getFile();
      drawBlob(file);
    });
  });
};

handleLaunchFiles();

17. Congratulations

🎉 Woohoo, you made it!

There are so many exciting browser APIs being developed in the context of Project Fugu 🐡 that this codelab could barely scratch the surface.

For a deeper dive, or just to learn more, follow our publications on our site web.dev.

Landing page of the “Capabilities” section of the site web.dev.

But it doesn't end there. For updates not yet publicized, you can access our Fugu API tracker with links to all the proposals that have shipped, are in origin trial or dev trial, all proposals on which the work has started, and everything being considered, but not yet started.

Fugu API tracker website

This codelab was written by Thomas Steiner (@tomayac), I'm happy to answer your questions and am looking forward to reading your feedback! Special thanks to Hemanth H.M (@GNUmanth), Christian Liebel (@christianliebel), Sven May (@Svenmay), Lars Knudsen (@larsgk), and Jackie Han (@hanguokai) who have helped shape this codelab!