We got our app deployed to a temporary website yesterday to analyze it with Lighthouse. The report led to a few changes in the applications manifest, and to configure the website itself to use Brotli compression. At the end of the day, the report passed with flying colors, but we then found additional warnings about the PWA when we reviewed the manifest in the Application tab of the web browsers developer tools.
- Richer PWA Install UI won’t be available on desktop. Please add at least one screenshot with the form_factor set to wide.
- Richer PWA Install UI won’t be available on mobile. Please add at least one screenshot for which form_factor is not set or set to a value other than wide.
- id is not specified in the manifest,
start_urlis used instead. To specify an App ID that matches the current identity, set theidfield to/ - Define protocol handlers in the manifest to register your app as a handler for custom protocols when your app is installed.
- Define display-override in the manifest to use the Window Controls Overlay API and customize your app’s title bar.

Let’s start with the installation screenshots. We need both wide and narrow screenshots for mobile and desktop browsers.
We start in Chrome development tools by toggling the device toolbar ( [Command] ⌘ + [Shift] ⇧ + [M]) to look like a mobile device instead of taking up all of the space available. We don’t have a specific size recommended to us for narrow or wide screens. The limit is to be within 320 to 3840 pixels high/wide, and an aspect ratio of no more than 1:2.3 (cinematic widescreen). Let’s change the dimensions to a familiar mobile phone for our narrow screenshot. Next we open the command window with ⌘⇧P and take a full size screenshot.


We could eventually end up with a large number of screenshots – eight for desktop, and five for mobile. For the benefit of the project, let’s rename the image to be SEO friendly and include enough information for developers to understand the form factor, size, and order of the screenshot.
[app name]-[type]-[form factor]-[width]x[height]-[order].png
periplux-screenshot-narrow-1290×2796-1.png
Once the screenshot is included in the manifest file, our desktop installation will not display these narrow screenshots. We also need wide screenshots for desktop installations. Let’s go ahead and grab that before we continue. The simplest option is to rotate our device and repeat the process to get another screenshot.

Now we get to add the screenshots to our manifest similar to icons, but we also specify a form_factor.
screenshots: [
{
src: '/images/pwa/periplux-screenshot-narrow-1290x2796-1.png',
sizes: '1290x2796',
type: 'image/png',
form_factor: 'narrow',
},
{
src: '/images/pwa/periplux-screenshot-wide-2796x1290-1.png',
sizes: '2796x1290',
type: 'image/png',
form_factor: 'wide',
},
],
Build and deploy. We may need to break the cache of our existing app. I’ve found that disabling cache and refreshing isn’t good enough. However, running a lighthouse report seems to break the cache for a PWA for us. Let’s do that.
On desktop, our install prompt has changed to an application opener after we had installed the app yesterday. We need to uninstall the app to review the Rich PWA Install Dialog. Go ahead and open the app. Once the app opens, click the pillar of dots (like a skewer with kebab meat & vegetables) in the upper right-hand corner to open the overflow menu and choose the option to Uninstall.


Manually Delete?
Let’s try to delete the app without opening it.
On macOS, you will not see your apps or a folder for Chrome Apps in finders Applications folder. To delete the PWA without opening it, go to your home folder Go > Home on mac, and the open Applications > Chrome Apps. You’ll see your PWA’s listed. From here, you can move it to the trash. For added measure, let’s empty our trash. I have two apps because one is from my localhost.

If you move the app to the trash via Finder, you’ll find that if you refresh the page or open a new tab and visit the site, Google Chrome will show a dialog to open the app rather than install it. It’s cached. If you exit out of Google Chrome and come back, it still thinks the app is installed. If you proceed, the application will be re-installed and you’ll have to delete it again.
Install a Richer PWA
Now that we’ve uninstalled the app, we get our install prompt again. Let’s confirm our screenshots show up when we install.
- macOS Chrome – Yes
- maxOS Safari – N/A (Add to dock)
- maxOS Firefox – N/A (Save as complete page)
- iOS Safari – N/A (Share to home screen)
- iOS Chrome – N/A (Share to home screen)
- Android Chrome – Yes


From my perspective, Richer PWA install UI is specifically targeting Chrome users at this time. I haven’t checked, but it may also be used with how apps are listed in PWA storefronts. Here are a couple PWA storefronts at a quick glance.
Setting an ID
id is not specified in the manifest, start_url is used instead. To specify an App ID that matches the current identity, set the id field to /
Usually I see ID’s assigned as a Guid / UUID rather than a URL. Let’s see if there is a requirement as to the format of an App ID.
I’m not finding anything regarding the id other than the computed id, which is based on the start_url. I don’t even have a start_url field. Let’s just set them both to /. I’m speculating that the worst that could happen is that a platform would later require us to change the App ID, and cause everyone to re-install the web app or end up with duplicate apps to the same web page.
Display Override
Define display-override in the manifest to use the Window Controls Overlay API and customize your app’s title bar.
The first thing that I see on the provided URL is that this is an experimental technology. It appears that much of the PWA manifest options are experimental, or have limited browser support.
Display override is an extension to the display property. We can specify additional displays in the order that we prefer. We have a few options.
- fullscreen
- standalone
- minimal-ui
- browser
- window-controls-overlay
{
display_override: ["A", "B"],
display: "C"
}
In the above manifest, we are telling the browser that we would like option “A”, to fallback to option “B”, and last – we would support option “C”.
I don’t need the end-user to navigate a website. Given that this is a game, I want to opt for fullscreen… but not if its on a desktop computer. In that case, I would want window-controls overlay. minimal-ui would be my second choice followed by standalone. However, does that make it near impossible to uninstall the app?
Let’s try this:
{
display_override: [
'window-controls-overlay',
'fullscreen',
'minimal-ui',
],
display: 'standalone',
}
Although it was detected, I’m not seeing much difference in the desktop app vs mobile. I get the feeling that these were the default settings to begin with.


Protocol Handlers
Define protocol handlers in the manifest to register your app as a handler for custom protocols when your app is installed.
I saved this one for last as I’m unfamiliar with it. The name gives the impression that I could prefix a link with a protocol other than the standard http / https / ftp / mailto on a web page and launch the PWA automatically.
Yes, that’s exactly what it is. This is how clicking on mailto can open Google Mail instead of opening an email client on the operating system. Besides the standard protocols, a custom protocol must be prefixed with web+ and can link to a page with a query string.
{
"protocol_handlers": [
{
"protocol": "web+periplux",
"url": "/?search=%s"
},
{
"protocol": "web+gps",
"url": "/?gps=%s"
},
]
}
If I create links with these protocols, the web browser will open the app.
<a href="web+periplux://hello">Search Hello</a><br> <a href="web+gps://38.9181,-78.1934">Go to Front Royal</a>
The first link opens the PWA with /?search=hello and the second opens the app with /?gps=38.9181,-78.1934. Let’s modify our landing page to display the contents of what was sent via the protocol.
const App = () => {
const params = new URLSearchParams(
window.location.search,
);
let text = 'Protocol not handled.';
if (params.has('gps')) {
text = `GPS: ${params.get('gps')}`;
} else if (params.has('search')) {
text = `Search: ${params.get('search')}`;
}
return (
<div>
<h1>Hello World!</h1>
<p>{text}</p>
</div>
);
};
export default App;
Build, upload, refresh…

Well… something didn’t work. Testing it opened up a blank page. I think I should have installed the app.



Well… that went well. We have one small problem. The full url of the protocol was displayed. I didn’t expect web+gps:// as part of the gps parameter. The full url that was handled was /?gps=web+gps://39.9274585,-78.2221281.

It looks like the periplux protocol worked the same way. The URLSearchParams does not unescape the value of search parameters. We are getting multiple instances of the application. We may need to do some fancy footwork so that the original or new app closes while the other is updated to show the handled protocol.
Let’s fix the encoding and url prefix first.
const App = () => {
const params = new URLSearchParams(
window.location.search,
);
let text = 'Protocol not handled.';
if (params.has('gps')) {
const gps = getValue('gps', params.get('gps'));
text = `GPS: ${gps}`;
} else if (params.has('search')) {
const search = getValue(
'periplux',
params.get('search'),
);
text = `Search: ${search}`;
}
return (
<div>
<h1>Hello World!</h1>
<p>{text}</p>
</div>
);
};
const getValue = (
protocol: string,
value: string | null,
): string => {
if (value === null) return '';
if (!value.startsWith(`web+${protocol}://`)) return '';
value = value.slice(protocol.length + 7);
return decodeURIComponent(value);
};
export default App;

Single Instance PWA
It appears that having multiple windows open for the same app may be by design. Just looking at the menu, we have an option to open a new window. Even if this is the case, it would be nice so that opening via protocol targets the current instance.

It looks like this is intentional in the W3C Working Draft of application manifests: §5.2 Launching a web application.
This algorithm is replaceable to allow an experimental launch_handler manifest field to configure the behavior of all web application launches. The replacement algorithm invokes create a new application context by default but under certain conditions behaves differently.
Here we go. Launch Handler API. We have a few options.
- focus-existing
- navigate-existing
- navigate-new
- auto (default)
Let’s go for focus-existing.
Well… it works, but the data in the protocols are no longer passed in. The API mentions something about populating a target launch URL in a launch queue. How do I read the queue and detect when a new value is available? It all comes down to consuming the launch queue.
const handleQueue = (launchParams) => alert(launchParams.targetURL); window.launchQueue.setConsumer(handleQueue);

We have a few things going on now. We have our original location.search, some experimental launch events to listen to, as well as a React application. For all of this, I created a little helper module.
export const protocolValues: {
gps: string | undefined;
search: string | undefined;
} = {
gps: undefined,
search: undefined,
};
const gpsListeners: FunctionStringCallback[] = [];
const searchListeners: FunctionStringCallback[] = [];
export const addGpsListener = (
eventHandler: FunctionStringCallback,
) => {
if (typeof eventHandler !== 'function') return;
if (!gpsListeners.includes(eventHandler))
gpsListeners.push(eventHandler);
};
export const removeGpsListener = (
eventHandler: FunctionStringCallback,
) => {
const index = gpsListeners.indexOf(eventHandler);
if (index !== -1) gpsListeners.splice(index, 1);
};
export const addSearchListener = (
eventHandler: FunctionStringCallback,
) => {
if (typeof eventHandler !== 'function') return;
if (!searchListeners.includes(eventHandler))
searchListeners.push(eventHandler);
};
export const removeSearchListener = (
eventHandler: FunctionStringCallback,
) => {
const index = searchListeners.indexOf(eventHandler);
if (index !== -1) {
searchListeners.splice(index, 1);
}
};
const processProtocol = (
params: URLSearchParams,
key: 'gps' | 'search',
protocol: string,
listeners: FunctionStringCallback[],
) => {
protocolValues[key] = undefined;
if (!params.has(key)) return;
let value = params.get(key);
if (value === null) return;
if (value.startsWith(protocol)) {
value = value.slice(protocol.length);
}
value = decodeURIComponent(value).trim();
if (value === '') return;
protocolValues[key] = value;
listeners.forEach((callbackFn) => callbackFn(value));
};
const consumer = (launchParams: { targetURL: string }) => {
if (!launchParams.targetURL) return;
const params = new URL(launchParams.targetURL)
.searchParams;
processProtocol(
params,
'gps',
'web+gps://',
gpsListeners,
);
processProtocol(
params,
'search',
'web+periplux://',
searchListeners,
);
};
if ('launchQueue' in window) {
(
window.launchQueue as { setConsumer: Function }
).setConsumer(consumer);
}
consumer({ targetURL: window.location.href });
We can worry about what looks pretty another day. We not only consume the launch queue, but we also pass the current window.location.href to the consumer. Our App has changed as well.
import { useCallback, useEffect, useState } from 'react';
import {
protocolValues,
addGpsListener,
addSearchListener,
removeGpsListener,
removeSearchListener,
} from './ProtocolHandler';
const App = () => {
const [text, setText] = useState('Protocol not handled');
const handleGpsChange = useCallback(
(gps: string) => {
setText(`GPS: ${gps}`);
},
[setText],
);
const handleSearchChange = useCallback(
(search: string) => {
setText(`Search: ${search}`);
},
[setText],
);
useEffect(() => {
addGpsListener(handleGpsChange);
addSearchListener(handleSearchChange);
return () => {
removeGpsListener(handleGpsChange);
removeSearchListener(handleSearchChange);
};
}, [handleGpsChange, handleSearchChange]);
useEffect(() => {
if (protocolValues.gps) {
setText(`GPS: ${protocolValues.gps}`);
} else if (protocolValues.search) {
setText(`Search: ${protocolValues.search}`);
}
}, []);
return (
<div>
<h1>Hello World!</h1>
<p>{text}</p>
</div>
);
};
export default App;
The end result is that we can click on multiple custom protocol links all day, and we will only have one instance of the app running. The end-user still has the ability to create new windows on the desktop via File -> New Window, but at least we aren’t cluttering their desktop if they have a bunch of links they want to open.
So what if they have multiple apps? Does it aways target the most recent one? No. On my own environment, the most recent app that you interacted with (focused) is the one that receives the new url. I don’t think this can be depended on, but it makes the most sense to me.
- Open App 1
- Click File -> New Window
- App 2 opens
- Click on a link
web+gps://hello - App 2 is the target that receives the link
- Click on App 1 title bar
- Go back to the browser and click on the same link
- App 1 is the target that receives the link
Wrap Up
So what did we do today?
- Configured our PWA to use the Richer PWA Install UI
- Assigned an application ID and start url
- Configured the app to display in a full screen.
- Setup custom protocol handlers for
web+periplux://andweb+gps://links from any browser to launch our PWA. - Setup our app to launch as a single instance.
