Advanced and Integration Manifest Members¶
Integration members are the Web App Manifest keys that wire an installed PWA into the operating system: they register it as a handler for URL schemes and file types, add it to the share sheet, control which window a launch lands in, stretch its scope across several origins, and describe its relationship to native apps. Unlike name, icons or display, almost all of them are specified outside the core W3C manifest spec, implemented only in Chromium-based browsers, and applied at install or update time rather than on every page load. This page documents every one of them at the level of processing rules, validation edge cases, OS behavior and current support, and links to the capability pages that cover each API end to end.
Key takeaways
- Every member on this page is Chromium-only (Chrome, Edge, Opera, Samsung Internet to varying degrees). Safari and Firefox ignore all of them, so each one must be a progressive enhancement over a working web app.
- Most members are processed at install time and re-applied when the browser picks up a manifest update. Invalid entries are dropped one by one, silently, with only a DevTools manifest warning to tell you.
protocol_handlers(Chrome 96),file_handlers(Chrome 102) andlaunch_handler(Chrome 110) are the stable desktop trio. Files and captured launches arrive throughwindow.launchQueue; do not rely on a reload re-delivering the last launch: Chrome 146 removed that undocumented behavior on desktop.scope_extensionsshipped in Chrome 139 on desktop (Chrome's release notes; MDN's data says 138) with a new syntax ({"type": "origin", "origin": …}plus aweb-app-origin-associationfile keyed by manifestid). The origin-trial syntax with*.wildcards is ignored by current Chrome.handle_links,url_handlersandcapture_linksnever shipped. Link capturing into installed PWAs is on by default on Windows, macOS and Linux (Chrome 138 for apps that declarelaunch_handler.client_mode, Chrome 140 for all apps; available but off by default per app on ChromeOS), controlled by the user and bylaunch_handler.tab_stripand thetabbeddisplay mode are stable only on ChromeOS;note_takingis ChromeOS-only;edge_side_panelandwidgetsare Microsoft Edge-only, and Microsoft marked the sidebar feature as deprecated in July 2026.related_applicationsplusprefer_related_applicationschange install promotion on Android and, when achrome_web_storeentry (orplayon ChromeOS with Android apps) is listed, on desktop Chromium, andnavigator.getInstalledRelatedApps()can detect a related Android app, Windows app or (since Chrome 140 on desktop) the PWA itself.iarc_rating_idis not read by any browser.
Why integration members behave differently from core members¶
Core members such as name, start_url and display are defined by the W3C Web Application Manifest spec and influence how the browser presents the app on every launch. Integration members add a second responsibility: the browser has to write something into the operating system (a registry key on Windows, a CFBundleDocumentTypes entry in the app shim's Info.plist on macOS, a .desktop file and a MIME database entry on Linux, a WebAPK manifest on Android) and keep it in sync with your manifest over the lifetime of the installation. That has consequences you need to plan for:
- Nothing happens until the app is installed. A
protocol_handlersentry on a site that the user has only bookmarked does nothing. The one exception isrelated_applications, which influences the install promotion that runs before installation. - Changes propagate through the manifest update process. Chromium re-checks an installed app's manifest when the app's pages load (with throttling) and, when an integration member changes, re-runs the matching OS integration step (for example unregistering removed protocol handlers). The rules that decide when an update is picked up are covered in App Identity & Updates.
- Failures are per entry and silent. Chromium's manifest parser drops a single invalid
file_handlersitem, protocol handler or scope extension and keeps the rest. The only feedback is an error string in the Application > Manifest pane of DevTools and in the manifest parser output. Get into the habit of checking that pane after every manifest change (see Browser DevTools). - The user has the last word. Protocol and file handling show a consent prompt on first use; link capturing and file handling can be toggled per app on
chrome://app-settings/<app-id>; the OS may already have a default handler for a file type or scheme and will not silently hand it to your app.
Where each member is specified¶
| Member | Specification | Status of the document |
|---|---|---|
protocol_handlers, file_handlers, note_taking, tab_strip, scope_extensions, related_applications, prefer_related_applications, display_override | Manifest Incubations (WICG) | Community Group draft |
launch_handler, window.launchQueue | Web App Launch Handler API (WICG) | Community Group draft |
share_target | Web Share Target | Editor's draft |
navigator.getInstalledRelatedApps() | Get Installed Related Apps API (WICG) | Community Group draft |
iarc_rating_id | Manifest App Information registry | Supplementary, advisory only |
edge_side_panel, widgets | Microsoft Edge documentation and explainers | Proprietary to Edge |
handle_links, url_handlers, capture_links | Explainers only | Never shipped; removed or abandoned |
The core members, their defaults and their processing order are covered in the Members Reference; this page assumes you know them.
Shared processing rules: URL resolution and "within scope"¶
Almost every integration member contains a URL (action, url, new_note_url, new_tab_button.url). The processing rules are the same for all of them, and most bugs come from these three rules:
- Relative URLs resolve against the manifest URL, not the document URL. A manifest at
/static/app.webmanifestwith"action": "open"produces/static/open. Always write root-relative paths ("/open"). - The resolved URL must be within the app's scope or the entry is discarded. Chromium's check is "same origin, and the URL's path starts with the scope's path" as a plain string prefix. A scope of
/apptherefore also covers/appleand/app-old; end scopes with a slash (/app/) if you mean a directory. - Only same-origin URLs pass, because being within scope implies same origin. Cross-origin handler pages are impossible, even on your own subdomains.
scope_extensionsdoes not change this for handler URLs; it only changes how navigations are treated.
The id member matters too: Chromium derives the installed app's identity, and therefore its OS registrations and per-app settings, from the manifest id. Changing id creates a different app with fresh registrations, so settle on an id before you add integration members.
Support at a glance¶
| Member | Chrome / Edge desktop | Chrome Android | ChromeOS | Safari (macOS, iOS) | Firefox | Deeper coverage |
|---|---|---|---|---|---|---|
protocol_handlers | ✅ 96 | ❌ | ⚠️ | ❌ | ❌ | Protocol Handlers & Launch Handling |
file_handlers | ✅ 102 | ❌ | ✅ | ❌ | ❌ | File Handling |
share_target | ⚠️ | ✅ 76 (GET since 71) | ✅ 89 | ❌ | ❌ | Web Share Target |
launch_handler | ✅ 110 | ⚠️ | ✅ | ❌ | ❌ | Protocol Handlers & Launch Handling |
handle_links | ❌ never shipped | ❌ | ❌ | ❌ | ❌ | This page |
scope_extensions | ✅ 139 | ❌ | ✅ 139 | ❌ | ❌ | This page |
note_taking | ❌ (parsed only) | ❌ | ✅ | ❌ | ❌ | This page |
tab_strip / tabbed | 🧪 flag | ❌ | ✅ | ❌ | ❌ | Display Modes |
edge_side_panel | ⚠️ Edge only, deprecated | ❌ | ❌ | ❌ | ❌ | This page |
widgets | ⚠️ Edge on Windows 11 only | ❌ | ❌ | ❌ | ❌ | This page |
related_applications | ⚠️ | ✅ | ✅ | ❌ | ❌ | Detecting Installed Apps |
prefer_related_applications | ⚠️ | ✅ | ✅ | ❌ | ❌ | This page |
iarc_rating_id | ❌ | ❌ | ❌ | ❌ | ❌ | This page |
Support data as of September 2026. Version numbers are Chrome versions from MDN's browser compatibility data and chromestatus.com; Edge and Opera follow the same Chromium milestone. Check caniuse for live data. Partial support (⚠️) is explained in each member's section.
protocol_handlers: registering URL schemes¶
protocol_handlers is the declarative, install-time counterpart of navigator.registerProtocolHandler(). It lets an installed PWA become an OS-level handler for schemes such as mailto:, magnet: or your own web+ scheme, so a click on web+music:track/42 in any app (a native mail client, a chat app, a PDF) launches your PWA.
Syntax and processing rules¶
{
"id": "/",
"start_url": "/",
"scope": "/",
"protocol_handlers": [
{ "protocol": "web+music", "url": "/open?uri=%s" },
{ "protocol": "magnet", "url": "/downloads/new?link=%s" },
{ "protocol": "mailto", "url": "/compose?to=%s" }
]
}
Each entry is an object with two required string members. Chromium processes them as follows:
| Rule | What happens when violated |
|---|---|
protocol_handlers must be an array | Whole member ignored: "property 'protocol_handlers' ignored, type array expected." |
| Each entry must be an object | Entry ignored |
protocol must be present, a string, and either a safelisted scheme or web+ followed by one or more ASCII letters | Entry ignored: "required property 'protocol' is invalid." |
url must be present and resolve (against the manifest URL) to a URL within scope | Entry ignored: "should be within scope of the manifest." |
url must contain the %s token | Entry ignored (the same SyntaxError condition that registerProtocolHandler() throws) |
url must be http: or https: and potentially trustworthy | Entry ignored |
The scheme comparison is ASCII case-insensitive and the scheme is lowercased, so "Web+Music" registers web+music. web+ must be followed by letters only: web+my-app and web+app2 are invalid.
Allowed schemes¶
The HTML Standard defines the safelisted schemes that sites may claim. Current Chromium accepts that whole list (ftp, ftps and sftp only since Chrome 117), plus a set of Chromium-only schemes, most of them decentralized-web schemes added in Chrome 86. payto (RFC 8905) exists in the code behind a feature that is disabled by default:
| Group | Schemes |
|---|---|
| HTML safelist | bitcoin, ftp, ftps, geo, im, irc, ircs, magnet, mailto, matrix, mms, news, nntp, openpgp4fpr, sftp, sip, sms, smsto, ssh, tel, urn, webcal, wtai, xmpp |
| Chromium-only additions | cabal, dat, did, doi, dweb, ethereum, hyper, ipfs, ipns, ssb |
| Custom schemes | web+ followed by one or more ASCII letters, for example web+music, web+todo |
| Rejected | http, https, file, chrome, javascript, data, any unprefixed custom scheme such as myapp |
Isolated Web Apps run with a relaxed security level: they can claim arbitrary schemes made of ASCII letters and dashes (minimum two characters) except a blocklist (http, https, file, ms-settings, chrome, chrome-extension, isolated-app), and their url must contain exactly one %s, placed in the query string. Normal PWAs cannot do this.
How %s is replaced¶
When the OS hands a URL to the browser, the browser applies the HTML Standard's handler algorithm: it strips any username and password from the invoked URL, serializes it, percent-encodes it with the component percent-encode set, and substitutes the result for the first %s in your handler URL. The component set encodes :, /, ?, #, +, &, =, @ and more, so the whole URL survives as a single query value:
| Invoked URL | Handler url | Resulting launch URL |
|---|---|---|
web+music:track/42?t=13 | /open?uri=%s | /open?uri=web%2Bmusic%3Atrack%2F42%3Ft%3D13 |
mailto:[email protected]?subject=Hi | /compose?to=%s | /compose?to=mailto%3Aada%40example.com%3Fsubject%3DHi |
magnet:?xt=urn:btih:abc | /downloads/new?link=%s | /downloads/new?link=magnet%3A%3Fxt%3Durn%3Abtih%3Aabc |
Because the whole invoked URL arrives, including the scheme, your handler must parse it again. URLSearchParams decodes the value for you; then parse it with new URL(), which accepts any syntactically valid scheme:
// Handles launches produced by manifest "protocol_handlers".
// The handler page is /open?uri=%s, so the invoked URL arrives
// percent-encoded in the "uri" query parameter.
const ALLOWED_SCHEMES = new Set(["web+music:", "magnet:", "mailto:"]);
export function parseProtocolLaunch(locationHref = window.location.href) {
const page = new URL(locationHref);
const raw = page.searchParams.get("uri"); // already percent-decoded
if (!raw) return null;
let invoked;
try {
invoked = new URL(raw);
} catch {
// Malformed input from another app: never trust it.
console.warn("Ignoring malformed protocol launch", raw);
return null;
}
if (!ALLOWED_SCHEMES.has(invoked.protocol)) return null;
switch (invoked.protocol) {
case "web+music:": {
// "web+music:track/42?t=13" has an opaque path "track/42".
const [kind, id] = invoked.pathname.split("/");
if (kind !== "track" || !/^\d+$/.test(id)) return null;
const t = Number(invoked.searchParams.get("t") ?? 0);
return { view: "track", id, startAt: Number.isFinite(t) ? t : 0 };
}
case "magnet:":
return { view: "download", magnet: invoked.href };
case "mailto:":
// mailto addresses live in the path; headers in the query.
return {
view: "compose",
to: decodeURIComponent(invoked.pathname),
subject: invoked.searchParams.get("subject") ?? "",
};
default:
return null;
}
}
Protocol launches carry untrusted input
Any application on the device, and any web page the user clicks through, can construct a web+music: or mailto: URL with arbitrary content and hand it to your app. Validate identifiers against strict patterns, never inject the value into HTML or build URLs from it without encoding, and never perform a state-changing action (sending, deleting, paying, granting access) straight from a launch: show the user what is about to happen and require a click inside your UI.
OS registration and the first-launch consent prompt¶
Chromium registers the schemes with the OS when the app is installed and whenever a manifest update adds or removes handlers. On Windows it writes per-app entries into the registry through Chrome's shell-integration layer; on Linux, scheme associations are x-scheme-handler/<scheme> pseudo-MIME types in the app's .desktop entry. If several apps (native or web) claim the same scheme, the OS picker decides, not the browser. ChromeOS is the exception, and the reason for the ⚠️ in the support table: ChromeOS routes schemes through its own app-intent system rather than a desktop-style registry, Chromium's integration there has focused on Isolated Web Apps, and ordinary PWAs should not count on other ChromeOS or Android (ARC) apps launching them through a custom scheme. Test the exact flow on a ChromeOS device before promising it.
The first time the PWA is launched through a scheme, Chromium shows a permission dialog naming the app and its origin and asking whether it may open links of that type, with a Remember my choice checkbox. If the user chooses Disallow with Remember my choice, Chromium unregisters the handler from the OS. The only other way to unregister is to uninstall the app or ship a manifest update without the entry. Registered handlers are never exposed to web content, so they cannot be used for fingerprinting.
Protocol launches only fire for top-level, user-initiated navigations: a web+music: URL used as an iframe src or navigated programmatically without a user gesture will not open the app.
Manifest registration compared with registerProtocolHandler()¶
| Aspect | protocol_handlers (manifest) | navigator.registerProtocolHandler() |
|---|---|---|
| When it takes effect | At install and on manifest update | When called, after a browser prompt |
| Scope of the handler | OS-wide: other native apps can launch your PWA | Browser-only: links clicked inside the browser |
| Opens in | The installed app window (subject to launch_handler) | A browser tab |
| Handler URL | Must be within the manifest scope | Must be same origin as the calling page |
| Lifetime | Tied to the installation | Until the user removes it in browser settings |
| Engines | Chromium desktop (Chrome 96+) | Chromium desktop and Firefox (desktop and Android); not Safari, not Chrome Android |
You can use both: registerProtocolHandler() for users who never install, and the manifest for installed users. The details of both APIs, including the browser settings UI and testing tricks, are on Protocol Handlers & Launch Handling.
file_handlers: becoming an "Open with" target¶
file_handlers registers an installed PWA with the OS as an application that can open specific file types. The user sees it in the file manager's Open with menu, can make it the default app for a type, and double-clicking such a file launches the PWA with a FileSystemFileHandle delivered through window.launchQueue.
Member-by-member reference¶
{
"file_handlers": [
{
"action": "/open-csv",
"accept": {
"text/csv": [".csv"],
"text/tab-separated-values": [".tsv"]
}
},
{
"action": "/open-graph",
"name": "Grafr graph",
"accept": {
"application/vnd.grafr-graph+json": [".grafr", ".graf"]
},
"icons": [
{ "src": "/icons/grafr-file-256.png", "sizes": "256x256", "type": "image/png" }
],
"launch_type": "multiple-clients"
}
]
}
| Member | Type | Required | Default | Notes |
|---|---|---|---|---|
action | URL string | Yes | none | Resolved against the manifest URL; must be within scope, or the whole handler is dropped. This is the page the launch navigates to (or the targetURL of the LaunchParams). |
accept | Object mapping MIME type to extensions | Yes | none | Must be a non-empty object with at least one valid entry after filtering, or the handler is dropped. |
name | String | No | Browser/OS default | Human-readable file type name for OS surfaces, typically only shown when the app is the default handler. User agents may ignore it for privacy reasons. |
icons | Array of image resources | No | App icon | File-type icons for the OS. Documented by Chrome, but Chromium has never applied them by default on any OS; expect the app icon or a generic icon. |
launch_type | "single-client" or "multiple-clients" | No | "single-client" | Whether a multi-file open produces one launch with all files, or one launch per file. |
Validation of accept: spec rules and Chromium's limits¶
The spec processes each accept entry independently and skips (rather than rejects) invalid ones:
- The key must parse as a MIME type whose top-level type is registered with IANA (
text,image,audio,video,application,font,model,multipart,message, and so on).image/*style wildcards are allowed as the subtype. (Chromium additionally refuses FreeDesktopx-scheme-handler/*pseudo-types here, because on Linux they denote protocol handlers.) - The value must be a non-empty list of strings, each starting with
.. The spec also caps extensions at 16 characters. - Both MIME types and extensions are mandatory because operating systems disagree on which one they use: Windows only uses extensions and ignores MIME types, while Linux registers both with
xdg-mime.
Chromium's parser is slightly different from the spec text, and the differences matter when you test:
| Behavior | Spec | Chromium |
|---|---|---|
Extension value given as a single string ("text/csv": ".csv") | Invalid (list required) | Accepted |
Extension "." alone | Invalid | Rejected: must contain at least one character after the dot |
| Extension longer than 16 characters | Invalid | Not checked |
| Extensions containing control or format characters | Not mentioned | Rejected |
| Total extensions across all handlers | "May truncate" | Hard limit of 300; extensions past the limit are dropped with "too many total file extensions" |
launch_type given as an array | Not mentioned | Accepted; first recognized value wins |
launch_type and platform behavior¶
With "single-client", opening five selected .csv files in a file manager produces one launch whose LaunchParams.files contains five handles. With "multiple-clients", it produces five launches with one handle each, which, with the default launch_handler on desktop, means five windows. The spec forbids mixing files from different handlers in one launch, so selecting a .csv and a .grafr file always produces at least two launches.
The spec calls out a platform limitation: Windows never launches an application with several files at once. It starts the handler once per file, so on Windows every launch is effectively "multiple-clients" regardless of the manifest. If your app must merge multi-file opens into one window on every OS, set launch_handler.client_mode to focus-existing or navigate-existing and merge incoming files in your launchQueue consumer.
Consuming files with window.launchQueue¶
Registering file types only launches the app; your page must read the files. They arrive on the main thread, not in the service worker, as FileSystemFileHandle objects. Chromium grants read access only: calling createWritable() on a launched handle triggers the File System Access write-permission prompt the first time.
// Receives files opened through manifest "file_handlers".
// Requires a secure context and an installed app in Chromium 102+.
const DB_NAME = "launched-files";
const STORE = "handles";
function openDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
// FileSystemHandle objects are structured-cloneable, so they can be
// stored in IndexedDB and reopened after a reload or restart.
async function rememberHandle(key, handle) {
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).put(handle, key);
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
db.close();
}
export async function ensureWritable(handle) {
const opts = { mode: "readwrite" };
if ((await handle.queryPermission(opts)) === "granted") return true;
// requestPermission() needs transient user activation, so call this
// from a click handler (for example the "Save" button), not on launch.
return (await handle.requestPermission(opts)) === "granted";
}
// Processes the files of one launch. Exported separately so that an app
// with several launch types can call it from its single consumer.
export async function handleFileLaunch(launchParams, openDocument) {
for (const handle of launchParams.files) {
if (handle.kind !== "file") continue; // Directory handles are possible in theory.
try {
const file = await handle.getFile(); // Read access is pre-granted.
await rememberHandle(`recent:${file.name}:${file.lastModified}`, handle);
await openDocument({ handle, file });
} catch (err) {
// NotFoundError: file moved or deleted between launch and read.
// NotAllowedError: file handling revoked in app settings.
console.error("Could not open launched file", handle.name, err);
}
}
}
// For apps whose only launch type is file handling.
export function initFileLaunches(openDocument) {
if (!("launchQueue" in window) || !("files" in LaunchParams.prototype)) {
return false; // No File Handling: fall back to <input type="file">.
}
window.launchQueue.setConsumer((launchParams) => {
if (launchParams.files?.length) {
return handleFileLaunch(launchParams, openDocument);
}
});
return true;
}
The consumer can be called several times during a page's lifetime (for example when a second file is opened into an existing window with focus-existing), so openDocument must cope with a document already being open. If the app also handles URL launches, call setConsumer() once and dispatch inside it, as shown in the launch consumer below.
Reloads no longer replay the last launch
Chromium has long re-delivered the last LaunchParams, including its file handles, to a newly registered consumer after the user reloads an app window. That behavior was never specified: it was a stop-gap from before file handles could be stored in IndexedDB. The chromestatus entry Stop re-queueing LaunchParams on reload removed it in Chrome 146 on desktop; a reload is now an ordinary navigation. If your app relies on reload to reopen the current file, persist the handle in IndexedDB as shown above and restore it yourself; that works on every Chromium version.
OS registration, permissions and user control¶
Chromium registers file handlers at install time and updates them when the file_handlers member changes:
- Windows: file associations under per-app ProgIds in the current user's registry hive, with a small app-specific launcher executable; each handler gets its own ProgId so its
namecan appear separately in Open with. - macOS: document types in the
Info.plistof the app shim bundle that Chromium generates for each installed web app. - Linux: an XML MIME-info file installed with
xdg-mime install, the MIME types listed in the app's.desktopfile, thenupdate-desktop-databaseso file managers such as Nautilus pick up the association. - ChromeOS: the app appears in the Files app's Open with menu.
How installation itself differs across these systems is covered on Desktop Platforms.
The OS may already have a default app for .csv or .png; installing your PWA does not change the default. Users choose it once through Open with and may make it the default. When the PWA is launched with a file for the first time, Chromium asks the user to allow file handling for that app; the decision persists, the prompt resets when the file_handlers member changes, and users can revoke it with the Include this app as an option when opening files toggle on chrome://app-settings/<app-id>. Do not register common types (.json, .txt, .png) unless your app is genuinely a general-purpose editor for them; prefer your own extension for your own format.
The full API, including drag-and-drop and saving back to the same file, is on File Handling and File System Access.
share_target: receiving shares from other apps¶
share_target registers the installed PWA in the operating system's share sheet. When the user shares text, a link or files to it, the browser launches the app by navigating (GET) or submitting a form (POST) to the action URL.
Syntax¶
{
"share_target": {
"action": "/share-target/",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "name",
"text": "description",
"url": "link",
"files": [
{
"name": "media",
"accept": ["image/*", "video/mp4", ".heic"]
},
{
"name": "docs",
"accept": ["application/pdf", ".pdf"]
}
]
}
}
}
| Member | Type | Default | Rules (Chromium parser) |
|---|---|---|---|
action | URL string | none | Required; within scope, or the whole share_target is ignored. |
method | "GET" or "POST" | "GET" (with a console warning if missing) | Case-insensitive. Any other string invalidates the whole member. |
enctype | "application/x-www-form-urlencoded" or "multipart/form-data" | "application/x-www-form-urlencoded" | Case-insensitive. multipart/form-data with GET invalidates the member. |
params | Object | none | Required; missing or non-object invalidates the member. |
params.title, params.text, params.url | String | none | The names of the form fields (or query parameters) that carry the shared title, text and URL. |
params.files | Object or array of {name, accept} | none | Only allowed with POST + multipart/form-data. A file entry without a non-empty name or with an empty accept is dropped. |
files[].accept | String or array of strings | none | MIME types (image/*, */*) or extensions (.heic). One invalid MIME string invalidates the entire share_target. |
Receiving the share¶
With GET, the shared values become query parameters on the action URL (for example /share-target/?name=…&description=…&link=…), which you read like any other query string. With POST, the browser sends a real multipart/form-data request, which your service worker should intercept so shares work offline and do not hit your server. Answer with a 303 redirect so a reload does not resubmit the form:
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (event.request.method !== "POST" || url.pathname !== "/share-target/") return;
event.respondWith((async () => {
try {
const form = await event.request.formData();
const shared = {
title: form.get("name") ?? "",
// Android puts shared URLs in the text field, not in "url".
text: form.get("description") ?? "",
url: form.get("link") ?? "",
files: [...form.getAll("media"), ...form.getAll("docs")],
};
const id = await saveShareToIndexedDB(shared); // your persistence layer
return Response.redirect(`/share-target/received?id=${encodeURIComponent(id)}`, 303);
} catch (err) {
console.error("Share target failed", err);
return Response.redirect("/share-target/received?error=1", 303);
}
})());
});
Two platform behaviors catch everyone. On Android the system share intent has no URL field, so shared links usually arrive in text (occasionally in title), and your code must extract URLs from text. And the app must be installed: on Android that means a WebAPK minted by Chrome (Chrome 71 added GET targets, Chrome 76 POST and files); on ChromeOS the app appears in the sharesheet from Chrome 89. Chrome on Windows, macOS and Linux does not register share targets with the OS; Microsoft documents share-target registration for PWAs installed with Edge on Windows. Service worker parsing, IndexedDB hand-off and the full edge-case list are on Web Share Target, and the sending side is on Web Share API.
launch_handler and window.launchQueue¶
launch_handler controls what happens when something launches an already-installed app: whether the browser opens a new window, reuses an existing one and navigates it, or just focuses an existing one and hands it the launch URL to handle in script. Launches include clicking the app icon, app shortcuts, file handling, protocol handling and, on desktop, links that the browser captures into the app (on by default on Windows, macOS and Linux since Chrome 140).
client_mode values¶
{
"launch_handler": {
"client_mode": ["focus-existing", "navigate-existing", "auto"]
}
}
client_mode | An app window is already open | No app window is open | LaunchParams enqueued |
|---|---|---|---|
auto (default) | User agent decides: navigate-new on desktop, navigate-existing on Android | New window | Yes |
navigate-new | Opens a new app window (a new tab in the tabbed display mode) at the target URL | New window | Yes, in the new document |
navigate-existing | Focuses the most recently used app window and navigates it to the target URL | New window | Yes, in the newly loaded document |
focus-existing | Focuses the most recently used app window without navigating; the target URL is delivered only through launchQueue | New window at the target URL | Yes, in the existing document |
focus-existing is the mode that makes launches feel native: a music player keeps playing when you open a shortcut, a chat app switches conversation without reloading, an editor opens a second file in a new tab of its own UI. The cost is that you must implement a launchQueue consumer, because the browser does not navigate. Without one, the launch just focuses the window and the user sees nothing change.
How client_mode is parsed: strings, arrays and fallbacks¶
client_mode accepts a string or an array of strings. Chromium and the spec process it the same way:
- If
launch_handleris missing or not an object, it is ignored (default behavior applies). - If
client_modeis a string, it is used if the browser recognizes it; otherwise the result isauto, with the warning "client_mode value '…' ignored, unknown value." - If
client_modeis an array, each entry is tried in order; non-strings and unknown values are skipped with a warning, and the first recognized value wins. - If nothing is recognized, the result is
auto.
Arrays exist for forward compatibility: when a future value such as a hypothetical "focus-existing-tab" is added, you can list it first and older browsers will fall through to a value they understand. Chromium's parser reads only client_mode. Names from the origin-trial era, such as the route_to field (renamed to client_mode during the trial) and values like existing-client-retain, are ignored, so manifests copied from 2021-2022 articles silently fall back to auto.
The LaunchQueue and LaunchParams interfaces¶
[Exposed=Window] interface LaunchParams {
readonly attribute DOMString? targetURL;
readonly attribute FrozenArray<FileSystemHandle> files;
};
callback LaunchConsumer = any (LaunchParams params);
partial interface Window {
readonly attribute LaunchQueue launchQueue;
};
[Exposed=Window] interface LaunchQueue {
undefined setConsumer(LaunchConsumer consumer);
};
The queue exists to solve a race: the browser decides on a launch before your script runs, so it buffers LaunchParams in the document's queue until a consumer is set. setConsumer() then synchronously drains every buffered entry into the new consumer, and every later launch into that document calls the consumer directly. Calling setConsumer() again replaces the consumer; buffered entries that were already consumed are not replayed.
sequenceDiagram
participant OS as OS or browser UI
participant B as Browser process
participant W as App window
participant Q as window.launchQueue
participant App as Your consumer
OS->>B: Launch app (shortcut, file, protocol, captured link)
B->>B: Resolve client_mode (focus-existing)
alt An in-scope app window exists
B->>W: Focus existing window, no navigation
B->>Q: Enqueue LaunchParams(targetURL, files)
Q->>App: consumer(params), immediately if set
else No app window
B->>W: Create new window and navigate to targetURL
B->>Q: Enqueue LaunchParams after navigation commits
Note over Q,App: Buffered until setConsumer() runs
App->>Q: setConsumer(fn)
Q->>App: fn(params) for each buffered entry
end Chromium-specific details that affect real apps:
window.launchQueueexists in every window in Chromium, including ordinary browser tabs, so'launchQueue' in windowtells you the API exists, not that you are in an installed app.- Since a change that landed at the end of 2022, Chromium enqueues
LaunchParamsfor every launch into an app window, whether or not the manifest declareslaunch_handler. A consumer is therefore also a reliable way to see the URL of the launch that created the window. - The spec forbids enqueueing
LaunchParamsinto a document whose current URL is out of scope, in both directions: launch URLs must be in scope, and afocus-existinglaunch must not hand its URL to a window that has wandered to another origin. In that case the launch falls back to a navigation (Chromium's Android implementation, for example, navigates the existing tab to the target URL). - For file launches routed into an existing window,
targetURLwasnullbefore Chrome 146; since Chrome 146 it is populated. Older versions are still in use, so do not depend ontargetURLfor file launches: branch onfiles.lengthfirst. - Since Chrome 146 on desktop, a reload is an ordinary navigation that does not re-deliver the previous
LaunchParams. Write code that works on older versions too: ignore duplicate deliveries of the same file and restore state from IndexedDB, not from a replayed launch.
A production launch consumer¶
This module routes URL launches (shortcuts, protocol handlers, captured links) and file launches from one consumer, and survives being invoked many times in a long-lived window.
// One consumer for every launch type. Designed for client_mode
// "focus-existing": the browser does not navigate, so we route in-app.
import { parseProtocolLaunch } from "./protocol-router.js";
import { handleFileLaunch } from "./file-launch.js";
const SCOPE = new URL("/", window.location.origin);
function inScope(url) {
return url.origin === SCOPE.origin && url.pathname.startsWith(SCOPE.pathname);
}
async function routeUrlLaunch(targetURL, router) {
const url = new URL(targetURL);
if (!inScope(url)) return; // Defensive: the browser already checks scope.
// Protocol handler launches land on /open?uri=...
if (url.pathname === "/open") {
const route = parseProtocolLaunch(url.href);
if (route) return router.show(route);
return router.resolve(new URL("/", SCOPE)); // Invalid input: go home.
}
// Shortcuts and captured links: hand the path to the SPA router.
// pushState instead of location.assign() keeps in-memory state
// (audio playback, unsaved drafts, open sockets) alive.
if (url.href !== window.location.href) {
history.pushState({ launched: true }, "", url);
}
return router.resolve(url);
}
export function installLaunchHandling({ router, openDocument }) {
if (!("launchQueue" in window)) return false; // Safari, Firefox.
// Call setConsumer() exactly once: a second call REPLACES this consumer.
window.launchQueue.setConsumer(async (params) => {
try {
if (params.files && params.files.length > 0) {
await handleFileLaunch(params, openDocument); // (1)!
return;
}
if (params.targetURL) {
await routeUrlLaunch(params.targetURL, router);
}
} catch (err) {
console.error("Launch handling failed", err);
router.showError?.("This link could not be opened.");
}
});
return true;
}
- File launches are checked first because their
targetURLmay benull(existing-window file launches) or equal to the handler'sactionURL, which is not a route you want to navigate to on its own.
Call installLaunchHandling() as early as possible in your boot sequence, but only after the router can render: buffered LaunchParams are delivered synchronously from inside setConsumer().
Android, ChromeOS and the tabbed display mode¶
- Desktop (Windows, macOS, Linux, ChromeOS): full support since Chrome 110. With navigation capturing, links to an installed app's scope that would open a new tab or window (links clicked in other apps,
target="_blank"links in the browser) open in the app by default on Windows, macOS and Linux: since Chrome 138 for apps that declarelaunch_handler.client_mode, since Chrome 140 for all apps.client_modedecides which window receives them. On ChromeOS, capturing is available but off by default per app. - Tabbed display mode: with
"display_override": ["tabbed"],navigate-newopens a new tab in the existing app window instead of a new window. - Android: every installed web app is a single Android task, so
autoresolves tonavigate-existing. MDN's compatibility data listslaunch_handlerfor Chrome on Android from version 110; recent Chromium versions also route client modes andLaunchParams(including file handles passed by Android intents) for Trusted Web Activities, andandroidx.browser1.9.0 added theLaunchHandlerClientModeconstants for TWA wrappers. Test on real devices before relying onfocus-existingthere; the Android installation model (WebAPKs versus TWAs) is explained on Android.
The deeper walkthrough of launch handling, including navigation capturing flows and testing, lives on Protocol Handlers & Launch Handling.
handle_links: status and the link-capturing history¶
Experimental proposal, never shipped
handle_links exists only in an archived explainer. No browser implements it, and none is expected to.
handle_links was a proposed top-level member with the values "auto" (default), "preferred" and "not-preferred", letting an app hint whether in-scope links clicked elsewhere should open in the installed app. It is not implemented: its chromestatus entry ("Web app handle links") has been "On hold" for years, and the WICG pwa-url-handler repository that hosted its explainer was archived in March 2026. Browsers ignore the member, so shipping it is harmless but pointless.
Link capturing ended up as browser and user policy plus launch_handler, after three manifest designs were tried and dropped:
| Proposal | Manifest shape | Outcome |
|---|---|---|
| Declarative Link Capturing | "capture_links": "none" \| "new-client" \| "existing-client-navigate" | Origin trial up to Chrome 97 (expired March 2022); redesigned as launch_handler plus user opt-in |
| PWAs as URL Handlers | "url_handlers": [{ "origin": "https://*.example.com" }] | Experimental on desktop; removed from Chromium; split into scope_extensions and handle_links |
| Link handling preference | "handle_links": "preferred" | Never shipped; removed; explainer archived in 2026 |
| Navigation capturing (current) | No member: default browser behavior, user toggle, and launch_handler.client_mode | Default on Windows, macOS and Linux (Chrome 138 with launch_handler.client_mode, Chrome 140 for all apps; rolled out in stages from Chrome 134); ChromeOS: available, off by default per app |
Under navigation capturing, Chrome's rule is that a navigation is capturable when it "creates a new frame and does not open in an auxiliary browsing context". In practice, a user action that opens a URL inside an installed app's scope in a new top-level browsing context without an opener (a link clicked in another application, or a target="_blank" link in a browser tab, which gets noopener by default) launches the app. Ordinary same-tab navigations and window.open() popups that keep an opener (auxiliary browsing contexts) are not captured. Users opt out per app under Opening supported links on chrome://app-settings/<app-id> by choosing Open in Chrome browser. The app's client_mode then picks the window. Microsoft Edge documents an equivalent automatic behavior for PWAs installed from the Microsoft Store, or installed with Edge when Edge is the default browser, with an opt-out on edge://apps. See Protocol Handlers & Launch Handling for the full navigation-capturing decision tree.
scope_extensions: one app across several origins¶
A manifest's scope can only cover one origin. Products that span example.com, example.co.uk and help.example.com therefore show an "out of scope" bar with the foreign URL whenever the user crosses an origin inside the app window, and links to the other origins are not captured into the app. scope_extensions lets the app claim additional origins, provided each of those origins confirms the association with a well-known file.
Syntax (Chrome 139 and later)¶
{
"id": "https://example.com/app",
"name": "Example",
"start_url": "/app/",
"scope": "/app/",
"display": "standalone",
"scope_extensions": [
{ "type": "origin", "origin": "https://example.co.uk" },
{ "type": "origin", "origin": "https://help.example.com" }
]
}
{
"https://example.com/app": {
"scope": "/app/"
}
}
With these three files, the app's extended navigation scope is https://example.com/app/, https://example.co.uk/app/ and all of https://help.example.com/.
Processing and validation rules¶
Manifest side (spec plus Chromium's limits):
| Rule | Detail |
|---|---|
| Entry shape | Object with string type and string origin; entries missing either key are ignored. |
type | Only "origin" is defined. Chromium has an experimental "site"-style form behind a disabled flag; do not use it. |
origin | Must parse as an https: origin. Paths are ignored (only the origin is kept). The host must end in a known public suffix and be longer than it, so bare suffixes (com, co.uk) and localhost fail; IP addresses such as 127.0.0.1 pass, which matters for local testing. |
| Wildcards | "https://*.example.com" (origin-trial syntax) is not supported by shipping Chrome; the subdomain wildcard lives behind the same disabled flag. List each origin. |
| Maximum entries | Chromium parses the first 10 entries and ignores the rest with a warning. |
| Maximum length | origin strings longer than 2,000 characters are ignored. |
Association file side:
- The browser fetches
https://<origin>/.well-known/web-app-origin-associationfor each listed origin. A failed fetch or invalid JSON means that origin is not added. - The file is a JSON object whose keys are manifest ids (the fully resolved
idof the app, such ashttps://example.com/app, not the manifest URL and not the origin alone). - The value for your id must be an object. Its optional
"scope"is resolved against the associated origin and defaults to"/", meaning the whole origin. A scope that resolves to another origin is ignored. - The same file can list several apps, and the same key can carry
"allow_migration": true, which themigrate_fromhandshake uses when an app moves between same-site origins (migrate_fromandmigrate_toreached desktop Chromium in Chrome 150 according to the Chrome release notes and chromestatus, while MDN's data lists 149; see App Identity & Updates).
Migrate from the origin-trial syntax
Early documentation (including Chrome's 2023 article) shows { "origin": "https://*.example.com" } in the manifest and { "web_apps": [{ "web_app_identity": "https://example.com" }] } in the association file. Neither shape is accepted by current Chrome: manifest entries without type are dropped, and association files must be keyed by the manifest id. Update both files together.
What the extension changes, and where¶
Scope extensions shipped in Chrome 139 on Windows, macOS, Linux and ChromeOS: the Intent to Ship targeted Chrome 138, the feature then appeared in the Chrome 139 release notes, and chromestatus lists 139 as the desktop shipping milestone (MDN's compatibility data still says 138). The Intent to Ship explicitly excluded mobile platforms, "where app identity is implemented differently"; Chrome on Android parses the member but does not apply it. With validated extensions:
- Navigations inside the app window to an extended origin no longer show the out-of-scope bar, so the product feels like one app. The spec asks browsers to keep some indication that the origin changed, distinct from the out-of-scope UI.
- Links to extended origins become eligible for capturing into the app, because Chromium's app-scope matching includes validated extensions; the same navigation-capturing rules and user settings apply as for in-scope links.
- Nothing else changes: storage, service workers, cookies and permissions remain per origin.
help.example.comstill needs its own service worker for offline support, and handler URLs inprotocol_handlersorfile_handlersmust still be on the manifest's origin.
note_taking: ChromeOS note-taking apps¶
note_taking marks the app as a note-taking app and gives the OS a URL for "new note" actions.
note_taking must be an object (otherwise it is ignored); new_note_url is resolved against the manifest URL and dropped if it is not within scope. The spec calls it advisory: the OS may ignore it or offer it as one choice among several.
Only ChromeOS acts on it. An installed app with note_taking appears in the list of note-taking apps under Settings > Device > Stylus, and once the user selects it there, the stylus palette's Create note action launches the app at new_note_url (the spec defines this as an ordinary app launch whose target URL is new_note_url). The member is parsed on all Chromium platforms (MDN lists Chrome 95; the Intent to Ship targeted Chrome 93) but has no effect outside ChromeOS. Make the new_note_url page open instantly into an empty, focused editor, and make it work offline, because a stylus user expects to write immediately. The related lock_screen member (a separate start_url for lock-screen note taking) is parsed by Chromium only behind a disabled flag.
tab_strip and the tabbed display mode¶
Tabbed application mode gives a standalone app window its own tab strip, so a document-centric PWA can keep several documents open in one window with real browser-grade tab isolation. You opt in through display_override, and optionally tune the tab strip with tab_strip:
{
"start_url": "/",
"display": "standalone",
"display_override": ["tabbed"],
"tab_strip": {
"home_tab": {
"scope_patterns": [
{ "pathname": "/" },
{ "pathname": "/index.html" },
{ "pathname": "/dashboard/*" }
]
},
"new_tab_button": {
"url": "/documents/new"
}
}
}
| Member | Meaning | Default and processing |
|---|---|---|
tab_strip.home_tab | Enables a pinned home tab that acts as the app's top-level menu. The home tab never navigates away: links from it to URLs outside the home tab scope open in a new app tab, and navigations in other tabs to URLs inside the home tab scope are redirected to the home tab, which gets focus. | No home tab when absent. Chromium also parses an icons array (or "auto") for the home tab's icon. |
home_tab.scope_patterns | List of URL Pattern inputs, resolved against the manifest URL, defining which URLs belong to the home tab. The start_url (ignoring fragments) is always in the home tab scope. | Empty list: only start_url is in the home tab scope. |
tab_strip.new_tab_button.url | URL loaded by the "new tab" button. Must be within the manifest scope. | Defaults to start_url. The button is only shown if this URL is outside the home tab scope, so an app with a home tab and no explicit new_tab_button.url has no new tab button. |
Detect the mode with @media (display-mode: tabbed) or matchMedia("(display-mode: tabbed)"). With launch_handler.client_mode set to navigate-new, launches open a new tab in the existing window rather than a new window.
Tabbed mode shipped only on ChromeOS. On Windows, macOS and Linux, Chromium treats "tabbed" as an unknown display mode unless the user enables chrome://flags/#enable-desktop-pwas-tab-strip (and #enable-desktop-pwas-tab-strip-customizations for tab_strip), so display_override falls through to your next entry and then to display. Always follow "tabbed" with a mode you can live with:
{
"display_override": ["tabbed", "window-controls-overlay", "standalone"],
"display": "standalone"
}
The window-controls-overlay mode in that fallback chain is covered on Window Controls Overlay, and the full display_override algorithm on Display Modes.
edge_side_panel: Microsoft Edge sidebar apps¶
Deprecated
Microsoft's documentation carries the notice "Update July 2026: This feature is being deprecated; it will soon no longer be supported." Do not start new work on sidebar integration, and treat existing integrations as temporary.
edge_side_panel is an Edge-only member that signals that the app may be pinned to the Edge sidebar, a panel docked next to the user's tabs:
{
"name": "PWAmp music player",
"short_name": "PWAmp",
"description": "A skinnable music player",
"display": "standalone",
"edge_side_panel": {
"preferred_width": 480
}
}
- An empty object (
"edge_side_panel": {}) is enough to opt in. The sidebar's default minimum width is 376 CSS pixels, and users can resize it. preferred_width(a number of CSS pixels) makes Edge open the sidebar at that width. Users can still resize it down to the 376-pixel minimum, so your layout must work at 376 pixels regardless.- Setting
displaytobrowser(or omitting it) produces a sidebar-only app that can be pinned to the sidebar but not installed as a standalone app. - Inside the sidebar, Edge adds an
Edge Side Panelbrand to User-Agent Client Hints and a matching token to theUser-Agentstring, and sends the desktop hintSec-CH-UA-Mobile: ?0:
export function isEdgeSidebar() {
const brands = navigator.userAgentData?.brands ?? [];
if (brands.some((b) => b.brand === "Edge Side Panel")) return true;
// Fallback for contexts without UA-CH (not recommended as primary signal).
return navigator.userAgent.includes("Edge Side Panel");
}
The member is ignored by every other browser.
widgets: Windows 11 Widgets Board (Edge)¶
Experimental
PWA widgets are a Microsoft Edge feature for the Windows 11 Widgets Board, defined in Edge documentation and explainers rather than a cross-browser spec, and Microsoft's page for it has not been substantively revised since 2023. No other browser or OS implements widgets or the service worker widgets API. Verify the current state in Edge before investing.
The widgets member is an array of widget definitions. Widgets are not HTML: Windows 11 renders them from Adaptive Cards templates, filled with JSON data that your service worker supplies.
{
"widgets": [
{
"name": "PWAmp mini player",
"description": "Control the PWAmp music player",
"tag": "pwamp",
"template": "pwamp-template",
"ms_ac_template": "/widgets/mini-player-template.json",
"data": "/widgets/mini-player-data.json",
"type": "application/json",
"screenshots": [
{ "src": "/widgets/screenshot.png", "sizes": "600x400", "label": "The mini player widget" }
],
"icons": [{ "src": "/icons/icon-16.png", "sizes": "16x16" }],
"auth": false,
"update": 86400
}
]
}
| Field | Required | Meaning |
|---|---|---|
name | Yes | Widget title shown to users. |
short_name | No | Shorter alternative name. |
description | Yes | What the widget does, shown in the widget picker. |
tag | Yes | Identifier used by the service worker API (getByTag, updateByTag). |
template | No (documented as informational) | Name of a generic template; not used by Windows today, but keep it. |
ms_ac_template | Yes | URL of the Adaptive Cards template JSON. |
data | No | URL returning the JSON bound into the template (${song} placeholders). |
type | No | MIME type of data. |
screenshots | Yes | Picker previews; images larger than 1024x1024 are ignored; platform may be Windows or any. |
icons | No | Widget icons (falls back to the app's icons); images larger than 1024x1024 are ignored. |
auth | No | Whether the widget requires authentication. |
update | No | Desired refresh interval in seconds. Nothing refreshes automatically: your service worker must update the widget. |
multiple | No | Allow several instances of the widget; defaults to true. |
Installing the PWA only adds its widgets to the picker; a widget instance is created when the user adds it, which fires widgetinstall in your service worker. You render and refresh instances through self.widgets:
// Microsoft Edge on Windows 11 only. Guard every call.
async function fetchText(url) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) throw new Error(`${url} answered ${response.status}`);
return response.text();
}
async function renderWidget(widget) {
const { msAcTemplate, data, tag } = widget.definition;
try {
const [template, payload] = await Promise.all([
fetchText(msAcTemplate),
// "data" is optional in the manifest: bind an empty object without it.
data ? fetchText(data) : Promise.resolve("{}"),
]);
await self.widgets.updateByTag(tag, { template, data: payload });
} catch (err) {
// Offline or server error: keep the last rendered card instead of
// replacing it with an empty one.
console.warn("Widget refresh failed", tag, err);
}
}
self.addEventListener("widgetinstall", (event) => {
event.waitUntil((async () => {
await renderWidget(event.widget);
const tag = event.widget.definition.tag;
const interval = event.widget.definition.update;
// Periodic Background Sync keeps the widget fresh while the app is closed.
if (interval && self.registration.periodicSync) {
const tags = await self.registration.periodicSync.getTags();
if (!tags.includes(tag)) {
await self.registration.periodicSync.register(tag, { minInterval: interval * 1000 });
}
}
})());
});
self.addEventListener("widgetuninstall", (event) => {
event.waitUntil((async () => {
// Unregister the sync only when the last instance goes away.
if (event.widget.instances.length === 1 && self.registration.periodicSync) {
await self.registration.periodicSync.unregister(event.widget.definition.tag);
}
})());
});
self.addEventListener("periodicsync", (event) => {
// Fired for the tag registered in widgetinstall.
event.waitUntil((async () => {
if (!self.widgets) return;
const widget = await self.widgets.getByTag(event.tag);
if (widget) await renderWidget(widget);
})());
});
self.addEventListener("widgetresume", (event) => {
// The host suspended rendering to save resources; repaint now.
event.waitUntil(renderWidget(event.widget));
});
self.addEventListener("widgetclick", (event) => {
// event.action is the "verb" of the Action.Execute in the template.
if (event.action === "next-song" || event.action === "previous-song") {
event.waitUntil(forwardToClients({ type: event.action }));
}
});
self.addEventListener("activate", (event) => {
// Widgets may exist before this worker activates: repaint them.
event.waitUntil((async () => {
if (!self.widgets) return;
const widget = await self.widgets.getByTag("pwamp");
if (widget) await renderWidget(widget);
})());
});
async function forwardToClients(message) {
const windows = await self.clients.matchAll({ type: "window" });
for (const client of windows) client.postMessage(message);
}
Note that Periodic Background Sync's minInterval is in milliseconds while the manifest's update is in seconds; Microsoft's own sample passes update unconverted, which requests a far shorter interval than intended (the browser still enforces its own minimum). The self.widgets object also offers getByInstanceId(), getByHostId(), matchAll(options) and updateByInstanceId(). For local development Microsoft requires Windows 11 with Developer Mode enabled and the Windows App SDK 1.2 runtime; for distribution, it recommends packaging the PWA for the Microsoft Store with PWABuilder. The underlying periodic update mechanism is covered on Periodic Background Sync.
related_applications, prefer_related_applications and getInstalledRelatedApps()¶
These three pieces describe and detect the relationship between your web app and other apps you publish: an Android app on Google Play, a Windows app, a Chrome extension, or the PWA itself on another scope.
The related_applications entry format¶
{
"related_applications": [
{
"platform": "play",
"url": "https://play.google.com/store/apps/details?id=com.example.app",
"id": "com.example.app"
},
{ "platform": "windows", "id": "ExampleCorp.ExampleApp_9jmtgj1pbbz6e!App" },
{ "platform": "webapp", "url": "/manifest.webmanifest", "id": "https://example.com/" }
],
"prefer_related_applications": false
}
| Field | Rule |
|---|---|
platform | Required, non-empty string; entries without it are ignored ("'platform' is a required field"). |
url | Optional; resolved against the manifest URL with no scope or origin restriction. |
id | Optional platform-specific identifier. At least one of url or id is required. |
min_version, fingerprints | Defined in the spec (with fingerprints entries of {type, value}), but not implemented in any browser. A lower installed version or a mismatched signing certificate does not stop detection. |
The relationship declared here is one-directional. The spec forbids browsers from assuming an endorsement unless the other app claims the same relationship, which is why detection always requires a verification file on the other side.
Platform strings that Chromium acts on:
platform | id format | Used by |
|---|---|---|
play | Android package name (com.example.app) | Android: native app install banner, install suppression, getInstalledRelatedApps(). ChromeOS: install suppression when Android apps (ARC) are enabled. |
windows | Package Family Name plus !App (for example MyApp_9jmtgj1pbbz6e!App) | getInstalledRelatedApps() on Windows. |
webapp | Manifest id (required on desktop) | getInstalledRelatedApps() for PWAs. |
chrome_web_store | Extension id | Desktop Chromium: install-promotion suppression when the extension is installed or preferred. |
Other strings (itunes, f-droid, amazon, …) appear in older examples; no browser acts on them.
prefer_related_applications: what it really does¶
prefer_related_applications is a boolean (default false) that tells the browser to promote a related app instead of the web app. Chromium's install pipeline implements it as follows:
Android (Chrome). If prefer_related_applications is true and a play entry has an id, Chrome queries Google Play for that package and offers the native app install banner in place of the PWA install flow: beforeinstallprompt still fires, but with event.platforms set to ["play"], and prompt() shows the Play install UI. If the play entry's url contains an id= query parameter, it must equal id, or the entry is rejected ("IDs do not match"); a referrer= parameter in that URL is forwarded to Play. This Play lookup only works in Chrome Beta and Stable, so testing in Canary or Dev shows the status "prefer_related_applications is only supported on Chrome Beta and Stable channels on Android".
Desktop Chromium. If prefer_related_applications is true and any entry has a platform the desktop can install (chrome_web_store, or play on ChromeOS with Android apps enabled), Chromium stops install promotion with the status "Manifest specifies prefer_related_applications: true". The app remains installable from the browser menu, but it is not promoted and beforeinstallprompt does not fire.
Independent of the flag. Chromium also suppresses PWA install promotion when a related non-web app is already installed: the play package on Android (and on ChromeOS through ARC), or an enabled chrome_web_store extension on desktop.
Leave prefer_related_applications at false unless you really want Android users sent to the Play Store; the PWA-first alternatives are covered in Install Prompts & Custom UI and Publishing to App Stores.
navigator.getInstalledRelatedApps()¶
dictionary RelatedApplication {
required USVString platform;
USVString url;
DOMString id;
USVString version;
};
[Exposed=Window]
partial interface Navigator {
[SecureContext] Promise<sequence<RelatedApplication>> getInstalledRelatedApps();
};
The method reads the current document's manifest, takes its related_applications, and resolves with the entries that are installed and verified. It is only available in secure contexts; called from a document that is not in a top-level browsing context (an iframe), it rejects with an InvalidStateError. Chrome only checks the first three entries of related_applications, to prevent sites from probing for a long list of apps, and browsers must return nothing in private or incognito-style modes.
| What you check | Verification on the other side | Where it works |
|---|---|---|
| Android app | asset_statements in the app's AndroidManifest.xml with delegate_permission/common.handle_all_urls for your site (Bubblewrap and PWABuilder add this) | Chrome on Android 80+ |
| Windows app (UWP / packaged) | windows.appUriHandler extension in the app manifest, plus /.well-known/windows-app-web-link listing the Package Family Name | Chrome and Edge on Windows 85+ |
| The PWA itself, same origin and within its scope | None: platform: "webapp", url of the manifest, id of the app | Chrome on Android 84+; Chrome and Edge 140+ on Windows, macOS, Linux and ChromeOS |
| A PWA on another scope or origin | /.well-known/assetlinks.json on the PWA's origin with delegate_permission/common.query_webapk naming the calling page's manifest URL | Chrome on Android 84+ only |
Returned objects copy platform, url and id from your manifest and add version when the platform reports one (Android apps do):
// Returns details of related apps that are installed and verified.
// Resolves to [] where the API is missing or detection is impossible.
export async function getRelatedApps() {
if (!("getInstalledRelatedApps" in navigator)) return [];
if (window.top !== window) return []; // Rejects with InvalidStateError in iframes.
try {
return await navigator.getInstalledRelatedApps();
} catch (err) {
console.warn("getInstalledRelatedApps failed", err);
return [];
}
}
export async function shouldShowInstallPromo() {
const apps = await getRelatedApps();
// Hide the PWA install promo if the Android app or the PWA is present.
return !apps.some((app) => app.platform === "play" || app.platform === "webapp");
}
Use the result to hide your install UI or deep-link users into the app they already have; never make functionality depend on it, because every non-Chromium browser returns nothing. The complete set of installation-detection techniques (display-mode queries, appinstalled, getInstalledRelatedApps(), server-side hints) is on Detecting Installed Apps, and the Android side of verified relationships on Trusted Web Activity.
iarc_rating_id: age rating metadata¶
iarc_rating_id is a string holding an International Age Rating Coalition certification code for the app, defined in the W3C Manifest App Information registry alongside categories, description and screenshots:
The registry classifies it as supplementary: purely advisory, never applied at runtime. IARC certificates are issued through participating storefronts, and one code may be shared across storefronts as long as the distributed product is the same. No browser reads the member, MDN has removed its reference page, and Chromium's manifest parser does not parse it. Tooling such as PWABuilder's manifest validator recognizes it as an optional field. Include it only if a storefront or internal tooling you use consumes it; age ratings for store listings are otherwise handled in each store's own submission process (see Publishing to App Stores).
A complete integration manifest¶
The following manifest combines the members above in the way a desktop-focused productivity PWA would ship them. Every entry degrades cleanly: a browser that does not understand a member ignores it.
{
"id": "/",
"name": "Grafr",
"short_name": "Grafr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": ["tabbed", "window-controls-overlay", "standalone"],
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"launch_handler": {
"client_mode": ["focus-existing", "auto"]
},
"protocol_handlers": [
{ "protocol": "web+grafr", "url": "/open?uri=%s" }
],
"file_handlers": [
{
"action": "/open-file",
"name": "Grafr graph",
"accept": {
"application/vnd.grafr-graph+json": [".grafr"],
"text/csv": [".csv"]
},
"launch_type": "single-client"
}
],
"share_target": {
"action": "/share-target/",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [{ "name": "data", "accept": ["text/csv", ".csv"] }]
}
},
"tab_strip": {
"home_tab": { "scope_patterns": [{ "pathname": "/" }] },
"new_tab_button": { "url": "/graphs/new" }
},
"note_taking": { "new_note_url": "/notes/new" },
"scope_extensions": [
{ "type": "origin", "origin": "https://docs.grafr.example" }
],
"related_applications": [
{ "platform": "webapp", "url": "/manifest.webmanifest", "id": "https://grafr.example/" },
{ "platform": "play", "id": "example.grafr.twa" }
],
"prefer_related_applications": false
}
A one-page summary of every member, including the core ones, is on the Manifest Cheat Sheet.
Browser support in detail¶
| Feature | Chrome desktop | Edge desktop | Chrome Android | Samsung Internet | Safari / iOS | Firefox |
|---|---|---|---|---|---|---|
protocol_handlers | ✅ 96 | ✅ 96 | ❌ | ❌ | ❌ | ❌ |
file_handlers | ✅ 102 | ✅ 102 | ❌ | ❌ | ❌ | ❌ |
file_handlers[].launch_type | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
file_handlers[].icons | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
share_target | ⚠️ 89 (ChromeOS only) | ⚠️ Windows1 | ✅ 76 | ✅ 12.0 | ❌ | ❌ |
launch_handler | ✅ 110 | ✅ 110 | ⚠️ 1102 | ⚠️ 21.02 | ❌ | ❌ |
window.launchQueue / LaunchParams.files | ✅ 102 | ✅ 102 | ⚠️ | ❌ | ❌ | ❌ |
LaunchParams.targetURL | ✅ 110 | ✅ 110 | ⚠️ | ❌ | ❌ | ❌ |
handle_links | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
scope_extensions | ✅ 1393 | ✅ 139 | ❌4 | ❌ | ❌ | ❌ |
note_taking | ⚠️ ChromeOS only | ❌ | ❌ | ❌ | ❌ | ❌ |
tabbed + tab_strip | ⚠️ ChromeOS; 🧪 flag elsewhere | ❌ | ❌ | ❌ | ❌ | ❌ |
edge_side_panel | ❌ | ⚠️ deprecated | ❌ | ❌ | ❌ | ❌ |
widgets | ❌ | ⚠️ Windows 11 | ❌ | ❌ | ❌ | ❌ |
related_applications / prefer_related_applications | ⚠️ install suppression only | ⚠️ | ✅ 44 | ✅ 4.0 | ❌ | ❌ |
getInstalledRelatedApps() (webapp, same scope) | ✅ 140 | ✅ 140 | ✅ 84 | ✅ 14.0 | ❌ | ❌ |
getInstalledRelatedApps() (Windows app) | ✅ 85 (Windows) | ✅ 85 (Windows) | n/a | n/a | ❌ | ❌ |
iarc_rating_id | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
Support data as of September 2026. For live data, check MDN's Web App Manifest reference (each member page has a compatibility table), MDN's Launch Handler API page and caniuse.
Common pitfalls¶
- Expecting integration without installation. None of
protocol_handlers,file_handlers,share_target,launch_handler,note_takingortab_stripdoes anything in a browser tab. Test with the app installed, and remember that an Android share target needs a WebAPK, which Chrome mints only for apps installed through its own install flow. - Handler URLs outside scope.
"action": "/open"with"scope": "/app/"silently drops the file handler. So does a manifest served from a subdirectory with relativeactionvalues. - Scope without a trailing slash.
"scope": "/app"makes/apple-pay-helppart of your app, including for link capturing and scope extensions. - One bad MIME type killing
share_target. A typo such as"image/"or"images/*"in anyfiles[].acceptremoves the entire share target, not just that entry. - Registering generic file types. Claiming
.json,.txtorimage/*competes with the user's editors and viewers, and Chrome automatically blocks file handling for an app after the user ignores its first-launch prompt three times. Register your own extension first. - Relying on multi-file launches on Windows. Windows starts one launch per file; handle merging in your
launchQueueconsumer. - Two
setConsumer()calls. The second silently replaces the first. Route everything through one consumer. - Assuming reload replays the launch. The replay was an unspecified stop-gap that Chrome 146 removed on desktop; persist file handles in IndexedDB.
focus-existingwithout a consumer. The launch focuses the window and nothing else happens; users think the link is broken.- Shipping origin-trial syntax.
url_handlers,capture_links,handle_links,route_to, wildcardscope_extensionsorigins andweb_appsassociation files are all ignored by current browsers. - Forgetting the association file. A
scope_extensionsentry without a matching, manifest-id-keyed/.well-known/web-app-origin-associationon the target origin is dropped, with only a DevTools warning. - Setting
prefer_related_applications: trueby accident. Copy-pasted manifests with this flag send Android users to Play and, when achrome_web_storeentry is listed, lose PWA install promotion on desktop.
Debugging integration members¶
- DevTools > Application > Manifest. Shows parse errors and warnings (every "ignored" message quoted on this page), the installability status including
prefer_related_applications, and a Protocol Handlers section where you can enter aweb+URL and test the handler without an OS round trip. See Browser DevTools. chrome://web-app-internals. Dumps every installed app as Chromium stores it, including the parsed file handlers, protocol handlers,launch_handlerclient mode, validated scope extensions and the recorded OS integration state. Use it to confirm what the browser actually registered, which is not always what your current manifest says.chrome://app-settings/<app-id>. The per-app Opening supported links choice (link capturing), the file handling toggle, the app's site permissions and a link to its full site settings. Change these to reproduce first-run and opted-out states.- The OS itself. On Windows, check Default apps and Open with; on Linux,
xdg-mime query default <mime/type>and the generated.desktopfile; on macOS, Get Info > Open with for a file of the registered type. - Test launches from outside the browser. Launch protocol URLs from a terminal (
start web+grafr:demoon Windows,open "web+grafr:demo"on macOS,xdg-open "web+grafr:demo"on Linux) and open files from the file manager, so you exercise the real OS registration rather than the DevTools shortcut. - Reinstall during development. Installed apps only pick up integration changes through the manifest update process (see App Identity & Updates), so uninstall and reinstall after editing these members, then recheck
chrome://web-app-internals. Automated Testing covers scripting these checks.
Further reading¶
On this site
- Members Reference: the core manifest members and their processing
- Display Modes:
display_override,tabbed,window-controls-overlayand fallbacks - App Identity & Updates:
id, manifest updates andmigrate_from - File Handling: the File Handling API end to end
- Web Share Target: receiving shares, service worker patterns and edge cases
- Protocol Handlers & Launch Handling: schemes, navigation capturing and
launchQueue - Window Controls Overlay: the other
display_overridedesktop mode - Detecting Installed Apps: every way to know whether your app is installed
External references
- Manifest Incubations (WICG):
protocol_handlers,file_handlers,scope_extensions,tab_strip,note_taking,related_applications - Web App Launch Handler API (WICG) and MDN: Launch Handler API
- Web Share Target and Get Installed Related Apps API
- HTML Standard: custom scheme handlers and safelisted schemes
- Chrome for Developers: URL protocol handler registration for PWAs
- Chrome for Developers: File Handling API and Launch Handler API
- Chrome for Developers: Navigation management into installed PWAs
- Chrome for Developers: Get Installed Related Apps API and Tabbed application mode
- Chrome Platform Status: Web app scope extensions and Stop re-queueing LaunchParams on reload
- Microsoft Learn: Build a PWA for the sidebar in Microsoft Edge and Display a PWA widget in the Windows Widgets Board
- Microsoft Learn: Handle links to a PWA
- W3C Manifest App Information (
iarc_rating_id)
-
MDN lists the
launch_handlermember for Chrome Android 110 but markswindow.launchQueue,LaunchParams.filesandLaunchParams.targetURLas unsupported on Chrome Android, so do not assume a consumer is called there. Android apps are single-task,automeansnavigate-existing, and Chromium's recent Android work on client modes and launch parameters targets Trusted Web Activities; verify behavior on devices. ↩↩ -
Chrome's release notes and chromestatus give Chrome 139 as the shipping milestone; MDN's compatibility data lists 138, the milestone the Intent to Ship originally targeted. ↩
-
MDN's data lists Chrome Android 138 and Samsung Internet 30 because the member is parsed there, but the Intent to Ship limited the feature to desktop platforms, where it has an effect. ↩