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). + +

Code is Poetry.

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 } ) + ); + } ); +} );