blob: cf04bc70724566d17124e9b33a37076396b385a4 [file] [log] [blame]
// Copyright (c) 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @implements {SDK.TargetManager.Observer}
* @unrestricted
*/
Resources.AppManifestView = class extends UI.VBox {
constructor() {
super(true);
this.registerRequiredCSS('resources/appManifestView.css');
Common.moduleSetting('colorFormat').addChangeListener(this._updateManifest.bind(this, true));
this._emptyView = new UI.EmptyWidget(Common.UIString('No manifest detected'));
this._emptyView.appendLink(
'https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/?utm_source=devtools');
this._emptyView.show(this.contentElement);
this._emptyView.hideWidget();
this._reportView = new UI.ReportView(Common.UIString('App Manifest'));
this._reportView.show(this.contentElement);
this._reportView.hideWidget();
this._errorsSection = this._reportView.appendSection(Common.UIString('Errors and warnings'));
this._installabilitySection = this._reportView.appendSection(Common.UIString('Installability'));
this._identitySection = this._reportView.appendSection(Common.UIString('Identity'));
this._presentationSection = this._reportView.appendSection(Common.UIString('Presentation'));
this._iconsSection = this._reportView.appendSection(Common.UIString('Icons'), 'report-section-icons');
this._nameField = this._identitySection.appendField(Common.UIString('Name'));
this._shortNameField = this._identitySection.appendField(Common.UIString('Short name'));
this._startURLField = this._presentationSection.appendField(Common.UIString('Start URL'));
const themeColorField = this._presentationSection.appendField(Common.UIString('Theme color'));
this._themeColorSwatch = InlineEditor.ColorSwatch.create();
themeColorField.appendChild(this._themeColorSwatch);
const backgroundColorField = this._presentationSection.appendField(Common.UIString('Background color'));
this._backgroundColorSwatch = InlineEditor.ColorSwatch.create();
backgroundColorField.appendChild(this._backgroundColorSwatch);
this._orientationField = this._presentationSection.appendField(Common.UIString('Orientation'));
this._displayField = this._presentationSection.appendField(Common.UIString('Display'));
this._throttler = new Common.Throttler(1000);
SDK.targetManager.observeTargets(this);
}
/**
* @override
* @param {!SDK.Target} target
*/
targetAdded(target) {
if (this._target) {
return;
}
this._target = target;
this._resourceTreeModel = target.model(SDK.ResourceTreeModel);
this._serviceWorkerManager = target.model(SDK.ServiceWorkerManager);
if (!this._resourceTreeModel || !this._serviceWorkerManager) {
return;
}
this._updateManifest(true);
this._registeredListeners = [
this._resourceTreeModel.addEventListener(
SDK.ResourceTreeModel.Events.DOMContentLoaded, this._updateManifest.bind(this, true)),
this._serviceWorkerManager.addEventListener(
SDK.ServiceWorkerManager.Events.RegistrationUpdated, this._updateManifest.bind(this, false))
];
}
/**
* @override
* @param {!SDK.Target} target
*/
targetRemoved(target) {
if (this._target !== target) {
return;
}
if (!this._resourceTreeModel || !this._serviceWorkerManager) {
return;
}
delete this._resourceTreeModel;
delete this._serviceWorkerManager;
Common.EventTarget.removeEventListeners(this._registeredListeners);
}
/**
* @param {boolean} immediately
*/
async _updateManifest(immediately) {
const {url, data, errors} = await this._resourceTreeModel.fetchAppManifest();
const installabilityErrors = await this._resourceTreeModel.getInstallabilityErrors();
this._throttler.schedule(() => this._renderManifest(url, data, errors, installabilityErrors), immediately);
}
/**
* @param {string} url
* @param {?string} data
* @param {!Array<!Protocol.Page.AppManifestError>} errors
* @param {!Array<string>} installabilityErrors
*/
async _renderManifest(url, data, errors, installabilityErrors) {
if (!data && !errors.length) {
this._emptyView.showWidget();
this._reportView.hideWidget();
return;
}
this._emptyView.hideWidget();
this._reportView.showWidget();
const link = Components.Linkifier.linkifyURL(url);
link.tabIndex = 0;
this._reportView.setURL(link);
this._errorsSection.clearContent();
this._errorsSection.element.classList.toggle('hidden', !errors.length);
for (const error of errors) {
this._errorsSection.appendRow().appendChild(
UI.createIconLabel(error.message, error.critical ? 'smallicon-error' : 'smallicon-warning'));
}
if (!data) {
return;
}
if (data.charCodeAt(0) === 0xFEFF) {
data = data.slice(1);
} // Trim the BOM as per https://tools.ietf.org/html/rfc7159#section-8.1.
const parsedManifest = JSON.parse(data);
this._nameField.textContent = stringProperty('name');
this._shortNameField.textContent = stringProperty('short_name');
this._startURLField.removeChildren();
const startURL = stringProperty('start_url');
if (startURL) {
const completeURL = /** @type {string} */ (Common.ParsedURL.completeURL(url, startURL));
const link = Components.Linkifier.linkifyURL(completeURL, {text: startURL});
link.tabIndex = 0;
this._startURLField.appendChild(link);
}
this._themeColorSwatch.classList.toggle('hidden', !stringProperty('theme_color'));
const themeColor = Common.Color.parse(stringProperty('theme_color') || 'white') || Common.Color.parse('white');
this._themeColorSwatch.setColor(/** @type {!Common.Color} */ (themeColor));
this._themeColorSwatch.setFormat(Common.Color.detectColorFormat(this._themeColorSwatch.color()));
this._backgroundColorSwatch.classList.toggle('hidden', !stringProperty('background_color'));
const backgroundColor =
Common.Color.parse(stringProperty('background_color') || 'white') || Common.Color.parse('white');
this._backgroundColorSwatch.setColor(/** @type {!Common.Color} */ (backgroundColor));
this._backgroundColorSwatch.setFormat(Common.Color.detectColorFormat(this._backgroundColorSwatch.color()));
this._orientationField.textContent = stringProperty('orientation');
const displayType = stringProperty('display');
this._displayField.textContent = displayType;
const icons = parsedManifest['icons'] || [];
this._iconsSection.clearContent();
const imageErrors = [];
const setIconMaskedCheckbox =
UI.CheckboxLabel.create(Common.UIString('Show only the minimum safe area for maskable icons'));
setIconMaskedCheckbox.classList.add('mask-checkbox');
setIconMaskedCheckbox.addEventListener('click', () => {
this._iconsSection.setIconMasked(setIconMaskedCheckbox.checkboxElement.checked);
});
this._iconsSection.appendRow().appendChild(setIconMaskedCheckbox);
// TODO(mathias): Uncomment this once we have official docs.
// const documentationLink = UI.XLink.create(
// 'https://web.dev/#TODO', // TODO(mathias): Update once we have official docs.
// ls`documentation on maskable icons`);
// this._iconsSection.appendRow().appendChild(UI.formatLocalized('Need help? Read our %s.', [documentationLink]));
for (const icon of icons) {
const iconUrl = Common.ParsedURL.completeURL(url, icon['src']);
const result = await this._loadImage(iconUrl);
if (!result) {
imageErrors.push(ls`Icon ${iconUrl} failed to load`);
continue;
}
const {wrapper, image} = result;
const sizes = icon['sizes'] ? icon['sizes'].replace('x', '\xD7') + 'px' : '';
const title = sizes + '\n' + (icon['type'] || '');
const field = this._iconsSection.appendFlexedField(title);
if (!icon.sizes) {
imageErrors.push(ls`Icon ${iconUrl} does not specify its size in the manifest`);
} else if (!/^\d+x\d+$/.test(icon.sizes)) {
imageErrors.push(ls`Icon ${iconUrl} should specify its size as \`{width}x{height}\``);
} else {
const [width, height] = icon.sizes.split('x').map(x => parseInt(x, 10));
if (image.naturalWidth !== width && image.naturalHeight !== height) {
imageErrors.push(ls`Actual size (${image.naturalWidth}\xD7${image.naturalHeight})px of icon ${
iconUrl} does not match specified size (${width}\xD7${height}px)`);
} else if (image.naturalWidth !== width) {
imageErrors.push(
ls
`Actual width (${image.naturalWidth}px) of icon ${iconUrl} does not match specified width (${width}px)`);
} else if (image.naturalHeight !== height) {
imageErrors.push(ls`Actual height (${image.naturalHeight}px) of icon ${
iconUrl} does not match specified height (${height}px)`);
}
}
field.appendChild(wrapper);
}
this._installabilitySection.clearContent();
this._installabilitySection.element.classList.toggle('hidden', !installabilityErrors.length);
for (const error of installabilityErrors) {
this._installabilitySection.appendRow().appendChild(UI.createIconLabel(error, 'smallicon-warning'));
}
this._errorsSection.element.classList.toggle('hidden', !errors.length && !imageErrors.length);
for (const error of imageErrors) {
this._errorsSection.appendRow().appendChild(UI.createIconLabel(error, 'smallicon-warning'));
}
/**
* @param {string} name
* @return {string}
*/
function stringProperty(name) {
const value = parsedManifest[name];
if (typeof value !== 'string') {
return '';
}
return value;
}
}
/**
* @param {?string} url
* @return {!Promise<?{image: !Element, wrapper: !Element}>}
*/
async _loadImage(url) {
const wrapper = createElement('div');
wrapper.classList.add('image-wrapper');
const image = createElement('img');
const result = new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = reject;
});
image.src = url;
image.alt = ls`Image from ${url}`;
wrapper.appendChild(image);
try {
await result;
return {wrapper, image};
} catch (e) {
}
return null;
}
};