diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7d74d423b4253..cd63087b8124c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -127,6 +127,11 @@ /lib/class-wp-theme-json-resolver-gutenberg.php @timothybjabocs @spacedmonkey @nosolosw /phpunit/class-wp-theme-json-test.php @nosolosw +# Web App +/packages/admin-manifest @ellatrix +/lib/pwa.php @ellatrix +/lib/service-worker.js @ellatrix + # Native (Unowned) *.native.js @ghost *.android.js @ghost diff --git a/docs/manifest.json b/docs/manifest.json index ef37c643699f2..ede0e009f199f 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1301,6 +1301,12 @@ "markdown_source": "../packages/a11y/README.md", "parent": "packages" }, + { + "title": "@wordpress/admin-manifest", + "slug": "packages-admin-manifest", + "markdown_source": "../packages/admin-manifest/README.md", + "parent": "packages" + }, { "title": "@wordpress/annotations", "slug": "packages-annotations", diff --git a/lib/load.php b/lib/load.php index f81e7f3e8621c..d6cf4449b2168 100644 --- a/lib/load.php +++ b/lib/load.php @@ -118,6 +118,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'; require __DIR__ . '/block-supports/generated-classname.php'; require __DIR__ . '/block-supports/elements.php'; diff --git a/lib/pwa.php b/lib/pwa.php new file mode 100644 index 0000000000000..4e3c86bee7ecc --- /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 0000000000000..15a2105a5fb47 --- /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 ca2d654ff162a..8a685b0f36d41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13703,6 +13703,12 @@ "@wordpress/i18n": "file:packages/i18n" } }, + "@wordpress/admin-manifest": { + "version": "file:packages/admin-manifest", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, "@wordpress/annotations": { "version": "file:packages/annotations", "requires": { diff --git a/package.json b/package.json index 4546293f5555a..e94da91a3047a 100644 --- 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 0000000000000..43c97e719a5a8 --- /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 0000000000000..e69de29bb2d1d diff --git a/packages/admin-manifest/README.md b/packages/admin-manifest/README.md new file mode 100644 index 0000000000000..6b6ac3021fe30 --- /dev/null +++ b/packages/admin-manifest/README.md @@ -0,0 +1,3 @@ +# Admin Manifest + +Dynamically creates a Web App [manifest](https://w3c.github.io/manifest/) and registers the service worker for the admin. diff --git a/packages/admin-manifest/package.json b/packages/admin-manifest/package.json new file mode 100644 index 0000000000000..1282dc528c032 --- /dev/null +++ b/packages/admin-manifest/package.json @@ -0,0 +1,33 @@ +{ + "name": "@wordpress/admin-manifest", + "version": "1.0.0", + "description": "Dynamically creates a Web App manifest and registers the service worker for the admin.", + "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.13.10" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/admin-manifest/src/index.js b/packages/admin-manifest/src/index.js new file mode 100644 index 0000000000000..5b2deb55f9ea1 --- /dev/null +++ b/packages/admin-manifest/src/index.js @@ -0,0 +1,157 @@ +function addManifest( manifest ) { + const link = document.createElement( 'link' ); + link.rel = 'manifest'; + link.href = 'data:application/manifest+json,' + 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 ); + }; + } ); + }; + } ); +} + +// eslint-disable-next-line @wordpress/no-global-event-listener +window.addEventListener( 'load', () => { + if ( ! ( 'serviceWorker' in window.navigator ) ) { + return; + } + + const { logo, siteTitle, adminUrl } = window.wpAdminManifestL10n; + const manifest = { + // Replace spaces with non breaking spaces. Chrome collapses them. + name: siteTitle.replace( / /g, ' ' ), + display: 'standalone', + orientation: 'portrait', + start_url: adminUrl, + // Open front-end, login page, and any external URLs in a browser + // modal. + scope: adminUrl, + icons: [], + }; + + const adminBar = document.getElementById( 'wpadminbar' ); + const { color, backgroundColor } = window.getComputedStyle( adminBar ); + 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( adminUrl + '?service-worker' ); + } ); +} );