Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experiment: Admin PWA #33102

Merged
merged 13 commits into from
Jul 5, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
34 changes: 34 additions & 0 deletions lib/pwa.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php
/**
* Progressive Web App
*
* @package gutenberg
*/

add_filter(
'admin_head',
function() {
$l10n = array(
'logo' => file_get_contents( ABSPATH . 'wp-admin/images/wordpress-logo-white.svg' ),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this incorporate the site icon? Otherwise a user may have multiple admin PWAs and they'd all have the same icon.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've thought a bit about this, and I think we shouldn't use the site icon. See #33102 (comment).

I was using the site icon at first, but it only works with pngs in Chrome, and the front-end of the site could potentially be a PWA that uses the site icon. The site icon is meant to be used for the site, not the administration area. I've made it so that the admin icon is dynamically generated based on the admin color scheme, so you could have many different WordPress sites that you're managing with differently coloured icons. I think this is the nicest solution for the admin, for which we'd need a default icon anyway if so site icon is set.

Copy link
Member Author

@ellatrix ellatrix Jul 7, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Example:

macOS iOS
image IMG_D41148F1081F-1

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How often do users change the admin color scheme? I personally never change it. It's the same for all sites I manage.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The site icon is meant to be used for the site, not the administration area.

Also, I'm not sure about that. Is not the site icon also showing up as the favicon when in the admin?

'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',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PWA plugin makes use of admin-ajax.php for serving the service worker which also works.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, we could do that too :)

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;
}
);
14 changes: 14 additions & 0 deletions lib/service-worker.js
Original file line number Diff line number Diff line change
@@ -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 () {} );
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/admin-manifest/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package-lock=false
Empty file.
3 changes: 3 additions & 0 deletions packages/admin-manifest/README.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions packages/admin-manifest/package.json
Original file line number Diff line number Diff line change
@@ -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.",
Copy link
Member

@gziolo gziolo Jul 5, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you mark it as private so it doesn't get published to npm until it's ready for prime time?

It will also remove the entry from docs/manifest.json – the block editor handbook.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, sure, I wasn't aware of that.

"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"
}
}
157 changes: 157 additions & 0 deletions packages/admin-manifest/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
function addManifest( manifest ) {
const link = document.createElement( 'link' );
link.rel = 'manifest';
link.href = 'data:application/manifest+json,' + JSON.stringify( manifest );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is clever. The PWA plugin uses the REST API to serve the manifest, but using a data: URI simplifies things. Was there a specific reason for doing it inline?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is clever. The PWA plugin uses the REST API to serve the manifest, but using a data: URI simplifies things. Was there a specific reason for doing it inline?

Copy link
Member Author

@ellatrix ellatrix Jul 7, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, this simplifies things and allows us to dynamically generate the icon (for which I'm changing the background color based on the admin color scheme). See #33102 (comment).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it's still preferable to use the REST API for the frontend web app manifest, because there's a performance hit for running script with each page load.

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