Skip to content
This repository has been archived by the owner on Jan 18, 2024. It is now read-only.

Parallelizing PWA icon generation #516

Merged
merged 9 commits into from
Apr 17, 2019
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
27 changes: 19 additions & 8 deletions packages/config/src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const DEFAULT_DISPLAY = 'fullscreen';
const DEFAULT_STATUS_BAR = 'default';
const DEFAULT_LANG_DIR = 'auto';
const DEFAULT_ORIENTATION = 'any';
const ICON_SIZES = [96, 128, 192, 256, 384, 512];
const ICON_SIZES = [192, 512];
const MAX_SHORT_NAME_LENGTH = 12;
const DEFAULT_PREFER_RELATED_APPLICATIONS = true;

Expand Down Expand Up @@ -303,10 +303,14 @@ function applyWebDefaults(appJSON: Object) {
webManifest.backgroundColor || splash.backgroundColor || DEFAULT_BACKGROUND_COLOR; // No default background color

/**
*
* https://developer.mozilla.org/en-US/docs/Web/Manifest#prefer_related_applications
* Specifies a boolean value that hints for the user agent to indicate
* to the user that the specified native applications (see below) are recommended over the website.
* This should only be used if the related native apps really do offer something that the website can't... like Expo ;)
*
* >> The banner won't show up if the app is already installed:
* https://github.com/GoogleChrome/samples/issues/384#issuecomment-326387680
*/

const preferRelatedApplications =
Expand Down Expand Up @@ -423,35 +427,42 @@ function inferWebHomescreenIcons(config: Object = {}, getAbsolutePath: Function,
// Use template icon
icon = options.templateIcon;
}
icons.push({ src: icon, size: ICON_SIZES });
const destination = `assets/icons`;
icons.push({ src: icon, size: ICON_SIZES, destination });
const iOSIcon = config.icon || ios.icon;
if (iOSIcon) {
const iOSIconPath = getAbsolutePath(iOSIcon);
icons.push({
ios: true,
size: 1024,
sizes: 180,
src: iOSIconPath,
destination,
});
}
return icons;
}

function inferWebStartupImages(config: Object = {}, getAbsolutePath: Function, options: Object) {
const { web = {}, ios = {}, splash = {} } = config;
const { icon, web = {}, splash = {}, primaryColor } = config;
if (Array.isArray(web.startupImages)) {
return web.startupImages;
}

const { splash: iOSSplash = {} } = ios;
const { splash: webSplash = {} } = web;
let startupImages = [];

let splashImageSource;
if (webSplash.image || iOSSplash.image || splash.image) {
splashImageSource = getAbsolutePath(webSplash.image || iOSSplash.image || splash.image);
const possibleIconSrc = webSplash.image || splash.image || icon;
if (possibleIconSrc) {
const resizeMode = webSplash.resizeMode || splash.resizeMode || 'contain';
const backgroundColor =
webSplash.backgroundColor || splash.backgroundColor || primaryColor || '#ffffff';
splashImageSource = getAbsolutePath(possibleIconSrc);
startupImages.push({
resizeMode,
color: backgroundColor,
src: splashImageSource,
supportsTablet: ios.supportsTablet,
supportsTablet: webSplash.supportsTablet === undefined ? true : webSplash.supportsTablet,
orientation: web.orientation,
destination: `assets/splash`,
});
Expand Down
2 changes: 0 additions & 2 deletions packages/webpack-config/web-default/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@
-->
<link rel="manifest" href="%PUBLIC_URL%manifest.json" />
<!-- iOS icons -->
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%apple-touch-icon.png" />
<link rel="apple-touch-startup-image" href="%PUBLIC_URL%apple-touch-icon.png" />
<link rel="mask-icon" href="" color="" />

<!-- TODO: Bacon: build a reliable system for testing these style changes -->
Expand Down
195 changes: 113 additions & 82 deletions packages/webpack-pwa-manifest-plugin/src/icons.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,36 @@
import fs from 'fs';
import jimp from 'jimp';
import fs from 'fs-extra';
import Jimp from 'jimp';
import mime from 'mime';
import { joinURI } from './helpers/uri';
import generateFingerprint from './helpers/fingerprint';
import IconError from './errors/IconError';
import { fromStartupImage } from './validators/Apple';

const supportedMimeTypes = [jimp.MIME_PNG, jimp.MIME_JPEG, jimp.MIME_BMP];
const supportedMimeTypes = [Jimp.MIME_PNG, Jimp.MIME_JPEG, Jimp.MIME_BMP];

const ASPECT_FILL = 'cover';
const ASPECT_FIT = 'contain';

export async function createBaseImageAsync(width, height, color) {
return new Promise(
(resolve, reject) =>
new Jimp(width, height, color, (err, image) => {
if (err) {
reject(err);
return;
}
resolve(image);
})
);
}

async function compositeImagesAsync(image, ...images) {
for (const imageProps of images) {
const childImage = await Jimp.read(imageProps);
image.composite(childImage, 0, 0);
}
return image;
}

function parseArray(i) {
if (i == null) return [];
Expand All @@ -23,6 +47,7 @@ function sanitizeIcon(iconSnippet) {
}
return {
src: iconSnippet.src,
resizeMode: iconSnippet.resizeMode,
sizes,
media: iconSnippet.media,
destination: iconSnippet.destination,
Expand Down Expand Up @@ -52,25 +77,13 @@ function processIcon(width, height, icon, buffer, mimeType, publicPath, shouldFi
ios: icon.ios
? { valid: icon.ios, media: icon.media, size: dimensions, href: iconPublicUrl }
: false,
resizeMode: icon.resizeMode,
color: icon.color,
},
};
}

async function processImg(sizes, icon, cachedIconsCopy, icons, assets, fingerprint, publicPath) {
const processNext = function() {
if (sizes.length > 0) {
return processImg(sizes, icon, cachedIconsCopy, icons, assets, fingerprint, publicPath); // next size
} else if (cachedIconsCopy.length > 0) {
const next = cachedIconsCopy.pop();
return processImg(next.sizes, next, cachedIconsCopy, icons, assets, fingerprint, publicPath); // next icon
} else {
return { icons, assets }; // there are no more icons left
}
};

const size = sizes.pop();

function parseSize(size) {
let width;
let height;
if (Array.isArray(size) && size.length) {
Expand All @@ -87,87 +100,105 @@ async function processImg(sizes, icon, cachedIconsCopy, icons, assets, fingerpri
width = dimensions[0];
height = dimensions[1];
}
return { width, height };
}

if (width > 0 && height > 0) {
const mimeType = mime.getType(icon.src);
if (!supportedMimeTypes.includes(mimeType)) {
let buffer;
try {
buffer = fs.readFileSync(icon.src);
} catch (err) {
throw new IconError(`It was not possible to read '${icon.src}'.`);
}
const processedIcon = processIcon(
width,
height,
icon,
buffer,
mimeType,
publicPath,
fingerprint
);
icons.push(processedIcon.manifestIcon);
assets.push(processedIcon.webpackAsset);
return processNext();
async function getBufferWithMimeAsync({ src, resizeMode, color }, mimeType, { width, height }) {
if (!supportedMimeTypes.includes(mimeType)) {
try {
return fs.readFileSync(src);
} catch (err) {
throw new IconError(`It was not possible to read '${src}'.`);
}
} else {
return await resize(src, mimeType, width, height, resizeMode, color);
}
}

const buffer = await resize(icon.src, mimeType, width, height);

const processedIcon = processIcon(
width,
height,
icon,
buffer,
mimeType,
publicPath,
fingerprint
);
icons.push(processedIcon.manifestIcon);
assets.push(processedIcon.webpackAsset);
return processNext();
async function processImage(size, icon, fingerprint, publicPath) {
const { width, height } = parseSize(size);
if (width <= 0 || height <= 0) {
return;
}
const mimeType = mime.getType(icon.src);
const _buffer = await getBufferWithMimeAsync(icon, mimeType, { width, height });
return processIcon(width, height, icon, _buffer, mimeType, publicPath, fingerprint);
}

function resize(img, mimeType, width, height) {
return new Promise((resolve, reject) => {
jimp.read(img, (err, img) => {
if (err) throw new IconError(`It was not possible to read '${img}'.`);
img.cover(width, height).getBuffer(mimeType, (err, buffer) => {
if (err) throw new IconError(`It was not possible to retrieve buffer of '${img}'.`);
resolve(buffer);
});
});
});
async function resize(img, mimeType, width, height, resizeMode = 'contain', color) {
try {
const initialImage = await Jimp.read(img);
const center = Jimp.VERTICAL_ALIGN_MIDDLE | Jimp.HORIZONTAL_ALIGN_CENTER;
if (resizeMode === ASPECT_FILL) {
return await initialImage
.cover(width, height, center)
.quality(100)
.getBufferAsync(mimeType);
} else if (resizeMode === ASPECT_FIT) {
const resizedImage = await initialImage.contain(width, height, center).quality(100);
if (!color) {
return resizedImage.getBufferAsync(mimeType);
}

const splashScreen = await createBaseImageAsync(width, height, color);
const combinedImage = await compositeImagesAsync(splashScreen, resizedImage);
return combinedImage.getBufferAsync(mimeType);
} else {
throw new IconError(
`Unsupported resize mode: ${resizeMode}. Please choose either 'cover', or 'contain'`
);
}
} catch ({ message }) {
throw new IconError(`It was not possible to generate splash screen '${img}'. ${message}`);
}
}

export function retrieveIcons(manifest) {
const startupImages = parseArray(manifest.startupImages);
// Remove these items so they aren't written to disk.
const { startupImages, apple, icon, icons, ...config } = manifest;
const parsedStartupImages = parseArray(startupImages);

let icons = parseArray(manifest.icons);
let parsedIcons = parseArray(icons);

if (startupImages.length) {
if (parsedStartupImages.length) {
// TODO: Bacon: use all of the startup images
const startupImage = startupImages[0];
icons = icons.concat(fromStartupImage(startupImage));
}
const response = [];

for (let icon of icons) {
response.push(sanitizeIcon(icon));
const startupImage = parsedStartupImages[0];
parsedIcons = [...parsedIcons, ...fromStartupImage(startupImage)];
}

delete manifest.startupImages;
delete manifest.icon;
delete manifest.icons;
return response;
const response = parsedIcons.map(icon => sanitizeIcon(icon));
return [response, config];
}

export async function parseIcons(fingerprint, publicPath, icons) {
if (icons.length === 0) {
export async function parseIcons(inputIcons, fingerprint, publicPath) {
if (!inputIcons.length) {
return {};
} else {
const first = icons.pop();
const data = await processImg(first.sizes, first, icons, [], [], fingerprint, publicPath);
return data;
}

let icons = [];
let assets = [];

let promises = [];
for (const icon of inputIcons) {
const { sizes } = icon;
promises = [
...promises,
...sizes.map(async size => {
const { manifestIcon, webpackAsset } = await processImage(
size,
icon,
fingerprint,
publicPath
);
icons.push(manifestIcon);
assets.push(webpackAsset);
}),
];
}
await Promise.all(promises);

return {
icons,
assets,
};
}
22 changes: 14 additions & 8 deletions packages/webpack-pwa-manifest-plugin/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ class WebpackPwaManifest {
}

const publicPath = this.options.publicPath || compilation.options.output.publicPath;
await buildResources(this, publicPath);

// The manifest (this.manifest) should be ready by this point.
// It will be written to disk here.
const manifestFile = await buildResources(this, publicPath);

if (!this.options.inject) {
callback(null, htmlPluginData);
Expand All @@ -112,14 +115,17 @@ class WebpackPwaManifest {
});
}

const manifestLink = {
rel: 'manifest',
href: this.manifest.url,
};
if (this.manifest.crossorigin) {
manifestLink.crossorigin = this.manifest.crossorigin;
if (manifestFile) {
const manifestLink = {
rel: 'manifest',
href: manifestFile.url,
};
if (this.manifest.crossorigin) {
manifestLink.crossorigin = this.manifest.crossorigin;
}
tags = applyTag(tags, 'link', manifestLink);
}
tags = applyTag(tags, 'link', manifestLink);

tags = generateMaskIconLink(tags, this.assets);

const tagsHTML = generateHtmlTags(tags);
Expand Down
11 changes: 4 additions & 7 deletions packages/webpack-pwa-manifest-plugin/src/injector.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,20 +75,17 @@ function writeManifestToFile(manifest, options, publicPath, icons) {

export async function buildResources(self, publicPath = '') {
if (!self.assets || !self.options.inject) {
publicPath = publicPath || '';
let parsedIconsResult = {};
if (!self.options.noResources) {
parsedIconsResult = await parseIcons(
self.options.fingerprints,
publicPath,
retrieveIcons(self.manifest)
);
const [results, config] = retrieveIcons(self.manifest);
self.manifest = config;
parsedIconsResult = await parseIcons(results, self.options.fingerprints, publicPath);
}

const { icons = {}, assets = [] } = parsedIconsResult;
const results = writeManifestToFile(self.manifest, self.options, publicPath, icons);
self.manifest = results;
self.assets = [results, ...assets];
return results;
}
}

Expand Down