|  | // 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; | 
|  | } | 
|  | }; |