diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index fc531a40cea7ef..ad961de106d75f 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -140,6 +140,11 @@
/lib/compat/wordpress-5.9/class-wp-theme-json-resolver-gutenberg.php @timothybjacobs @spacedmonkey @oandregal
/phpunit/class-wp-theme-json-test.php @oandregal
+# Web App
+/packages/admin-manifest @ellatrix
+/lib/pwa.php @ellatrix
+/lib/service-worker.js @ellatrix
+
# Native
/packages/components/src/mobile/global-styles-context @geriux
diff --git a/lib/load.php b/lib/load.php
index 6fc08546ad32ef..404c9b66a9f2ab 100644
--- a/lib/load.php
+++ b/lib/load.php
@@ -121,6 +121,7 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/navigation-page.php';
require __DIR__ . '/experiments-page.php';
require __DIR__ . '/global-styles.php';
+require __DIR__ . '/pwa.php';
// TODO: Move this to be loaded from the style engine package, via the build directory.
// Part of the build process should be to copy the PHP file to the correct location,
diff --git a/lib/pwa.php b/lib/pwa.php
new file mode 100644
index 00000000000000..4e3c86bee7ecc2
--- /dev/null
+++ b/lib/pwa.php
@@ -0,0 +1,34 @@
+ file_get_contents( ABSPATH . 'wp-admin/images/wordpress-logo-white.svg' ),
+ 'siteTitle' => get_bloginfo( 'name' ),
+ 'adminUrl' => admin_url(),
+ );
+ wp_enqueue_script( 'wp-admin-manifest' );
+ wp_localize_script( 'wp-admin-manifest', 'wpAdminManifestL10n', $l10n );
+ }
+);
+
+add_filter(
+ 'load-index.php',
+ function() {
+ if ( ! isset( $_GET['service-worker'] ) ) {
+ return;
+ }
+
+ header( 'Content-Type: text/javascript' );
+ // Must be at the admin root so the scope is correct. Move to the
+ // wp-admin folder when merging with core.
+ echo file_get_contents( __DIR__ . '/service-worker.js' );
+ exit;
+ }
+);
diff --git a/lib/service-worker.js b/lib/service-worker.js
new file mode 100644
index 00000000000000..15a2105a5fb47b
--- /dev/null
+++ b/lib/service-worker.js
@@ -0,0 +1,14 @@
+/* global self */
+
+// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/skipWaiting
+self.addEventListener( 'install', function ( event ) {
+ event.waitUntil( self.skipWaiting() );
+} );
+
+// https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim
+self.addEventListener( 'activate', function ( event ) {
+ event.waitUntil( self.clients.claim() );
+} );
+
+// Necessary for Chrome to show the install button.
+self.addEventListener( 'fetch', function () {} );
diff --git a/package-lock.json b/package-lock.json
index 43f476e7f796c3..812a67721c9120 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17007,6 +17007,13 @@
"@wordpress/i18n": "file:packages/i18n"
}
},
+ "@wordpress/admin-manifest": {
+ "version": "file:packages/admin-manifest",
+ "requires": {
+ "@babel/runtime": "^7.16.0",
+ "@wordpress/url": "file:packages/url"
+ }
+ },
"@wordpress/annotations": {
"version": "file:packages/annotations",
"requires": {
diff --git a/package.json b/package.json
index 566a3a1dad8bd2..e5fd9f97a6cfc7 100755
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
},
"dependencies": {
"@wordpress/a11y": "file:packages/a11y",
+ "@wordpress/admin-manifest": "file:packages/admin-manifest",
"@wordpress/annotations": "file:packages/annotations",
"@wordpress/api-fetch": "file:packages/api-fetch",
"@wordpress/autop": "file:packages/autop",
diff --git a/packages/admin-manifest/.npmrc b/packages/admin-manifest/.npmrc
new file mode 100644
index 00000000000000..43c97e719a5a82
--- /dev/null
+++ b/packages/admin-manifest/.npmrc
@@ -0,0 +1 @@
+package-lock=false
diff --git a/packages/admin-manifest/CHANGELOG.md b/packages/admin-manifest/CHANGELOG.md
new file mode 100644
index 00000000000000..e69de29bb2d1d6
diff --git a/packages/admin-manifest/README.md b/packages/admin-manifest/README.md
new file mode 100644
index 00000000000000..8878e9d77aef1b
--- /dev/null
+++ b/packages/admin-manifest/README.md
@@ -0,0 +1,11 @@
+# Admin Manifest
+
+Dynamically creates a Web App [manifest](https://w3c.github.io/manifest/) and registers the service worker for the admin.
+
+## Contributing to this package
+
+This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects.
+
+To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md).
+
+
diff --git a/packages/admin-manifest/package.json b/packages/admin-manifest/package.json
new file mode 100644
index 00000000000000..ea18cb5c0e9ac2
--- /dev/null
+++ b/packages/admin-manifest/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@wordpress/admin-manifest",
+ "version": "1.0.1",
+ "description": "Dynamically creates a Web App manifest and registers the service worker for the admin.",
+ "private": true,
+ "author": "The WordPress Contributors",
+ "license": "GPL-2.0-or-later",
+ "keywords": [
+ "wordpress",
+ "gutenberg",
+ "manifest"
+ ],
+ "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/admin-manifest/README.md",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/WordPress/gutenberg.git",
+ "directory": "packages/admin-manifest"
+ },
+ "bugs": {
+ "url": "https://github.com/WordPress/gutenberg/issues"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "main": "build/index.js",
+ "module": "build-module/index.js",
+ "react-native": "src/index",
+ "dependencies": {
+ "@babel/runtime": "^7.16.0",
+ "@wordpress/url": "file:../url"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/admin-manifest/src/index.js b/packages/admin-manifest/src/index.js
new file mode 100644
index 00000000000000..95b3f4a8e1abb4
--- /dev/null
+++ b/packages/admin-manifest/src/index.js
@@ -0,0 +1,176 @@
+/**
+ * WordPress dependencies
+ */
+import { addQueryArgs } from '@wordpress/url';
+
+function addManifest( manifest ) {
+ const link = document.createElement( 'link' );
+ link.rel = 'manifest';
+ link.href = `data:application/manifest+json,${ encodeURIComponent(
+ JSON.stringify( manifest )
+ ) }`;
+ document.head.appendChild( link );
+}
+
+function addAppleTouchIcon( size, base64data ) {
+ const iconLink = document.createElement( 'link' );
+ iconLink.rel = 'apple-touch-icon';
+ iconLink.href = base64data;
+ iconLink.sizes = '180x180';
+ document.head.insertBefore( iconLink, document.head.firstElementChild );
+}
+
+function createSvgElement( html ) {
+ const doc = document.implementation.createHTMLDocument( '' );
+ doc.body.innerHTML = html;
+ const { firstElementChild: svgElement } = doc.body;
+ svgElement.setAttribute( 'viewBox', '0 0 80 80' );
+ return svgElement;
+}
+
+function createIcon( { svgElement, size, color, backgroundColor, circle } ) {
+ return new Promise( ( resolve ) => {
+ const canvas = document.createElement( 'canvas' );
+ const context = canvas.getContext( '2d' );
+
+ // Leave 1/8th padding around the logo.
+ const padding = size / 8;
+ // Which leaves 3/4ths of space for the icon.
+ const logoSize = padding * 6;
+
+ // Resize the SVG logo.
+ svgElement.setAttribute( 'width', logoSize );
+ svgElement.setAttribute( 'height', logoSize );
+
+ // Color in the background.
+ svgElement.querySelectorAll( 'path' ).forEach( ( path ) => {
+ path.setAttribute( 'fill', backgroundColor );
+ } );
+
+ // Resize the canvas.
+ canvas.width = size;
+ canvas.height = size;
+
+ // If we're not drawing a circle, set the background color.
+ if ( ! circle ) {
+ context.fillStyle = backgroundColor;
+ context.fillRect( 0, 0, canvas.width, canvas.height );
+ }
+
+ // Fill in the letter (W) and circle around it.
+ context.fillStyle = color;
+ context.beginPath();
+ context.arc( size / 2, size / 2, logoSize / 2 - 1, 0, 2 * Math.PI );
+ context.closePath();
+ context.fill();
+
+ // Create a URL for the SVG to load in an image element.
+ const svgBlob = new window.Blob( [ svgElement.outerHTML ], {
+ type: 'image/svg+xml',
+ } );
+ const url = URL.createObjectURL( svgBlob );
+ const image = document.createElement( 'img' );
+
+ image.src = url;
+ image.width = logoSize;
+ image.height = logoSize;
+ image.onload = () => {
+ // Once the image is loaded, draw it onto the canvas.
+ context.drawImage( image, padding, padding );
+ // Export it to a blob.
+ canvas.toBlob( ( imageBlob ) => {
+ // We no longer need the SVG blob url.
+ URL.revokeObjectURL( url );
+ // Unfortunately blob URLs don't seem to work, so we have to use
+ // base64 encoded data URLs.
+ const reader = new window.FileReader();
+ reader.readAsDataURL( imageBlob );
+ reader.onloadend = () => {
+ resolve( reader.result );
+ };
+ } );
+ };
+ } );
+}
+
+function getAdminBarColors() {
+ const adminBarDummy = document.createElement( 'div' );
+ adminBarDummy.id = 'wpadminbar';
+ document.body.appendChild( adminBarDummy );
+ const { color, backgroundColor } = window.getComputedStyle( adminBarDummy );
+ document.body.removeChild( adminBarDummy );
+ // Fall back to black and white if no admin/color stylesheet was loaded.
+ return {
+ color: color || 'white',
+ backgroundColor: backgroundColor || 'black',
+ };
+}
+
+window.addEventListener( 'load', () => {
+ if ( ! ( 'serviceWorker' in window.navigator ) ) {
+ return;
+ }
+
+ const { logo, siteTitle, adminUrl } = window.wpAdminManifestL10n;
+ const manifest = {
+ name: siteTitle,
+ display: 'standalone',
+ orientation: 'portrait',
+ start_url: adminUrl,
+ // Open front-end, login page, and any external URLs in a browser
+ // modal.
+ scope: adminUrl,
+ icons: [],
+ };
+
+ const { color, backgroundColor } = getAdminBarColors();
+ const svgElement = createSvgElement( logo );
+
+ Promise.all( [
+ // The maskable icon should have its background filled. This is used
+ // for iOS. To do: check which sizes are really needed.
+ ...[ 180, 192, 512 ].map( ( size ) =>
+ createIcon( {
+ svgElement,
+ size,
+ color,
+ backgroundColor,
+ } ).then( ( base64data ) => {
+ manifest.icons.push( {
+ src: base64data,
+ sizes: size + 'x' + size,
+ type: 'image/png',
+ purpose: 'maskable',
+ } );
+
+ // iOS doesn't seem to look at the manifest.
+ if ( size === 180 ) {
+ addAppleTouchIcon( size, base64data );
+ }
+ } )
+ ),
+ // The "normal" icon should be round. This is used for Chrome
+ // Desktop PWAs. To do: check which sizes are really needed.
+ ...[ 180, 192, 512 ].map( ( size ) =>
+ createIcon( {
+ svgElement,
+ size,
+ color,
+ backgroundColor,
+ circle: true,
+ } ).then( ( base64data ) => {
+ manifest.icons.push( {
+ src: base64data,
+ sizes: size + 'x' + size,
+ type: 'image/png',
+ purpose: 'any',
+ } );
+ } )
+ ),
+ ] ).then( () => {
+ addManifest( manifest );
+ window.navigator.serviceWorker.register(
+ addQueryArgs( adminUrl, { 'service-worker': true } )
+ );
+ } );
+} );