diff --git a/config/gni/devtools_grd_files.gni b/config/gni/devtools_grd_files.gni index 93da3dd9ef..1d1221debe 100644 --- a/config/gni/devtools_grd_files.gni +++ b/config/gni/devtools_grd_files.gni @@ -847,6 +847,7 @@ grd_files_debug_sources = [ "front_end/panels/application/components/InterestGroupAccessGrid.js", "front_end/panels/application/components/OriginTrialTreeView.js", "front_end/panels/application/components/PermissionsPolicySection.js", + "front_end/panels/application/components/ProtocolHandlersView.js", "front_end/panels/application/components/ReportsGrid.js", "front_end/panels/application/components/StackTrace.js", "front_end/panels/application/components/TrustTokensView.js", @@ -857,6 +858,7 @@ grd_files_debug_sources = [ "front_end/panels/application/components/originTrialTokenRows.css.js", "front_end/panels/application/components/originTrialTreeView.css.js", "front_end/panels/application/components/permissionsPolicySection.css.js", + "front_end/panels/application/components/protocolHandlersView.css.js", "front_end/panels/application/components/reportingApiGrid.css.js", "front_end/panels/application/components/stackTraceLinkButton.css.js", "front_end/panels/application/components/stackTraceRow.css.js", diff --git a/front_end/core/host/UserMetrics.ts b/front_end/core/host/UserMetrics.ts index 6e6095bd41..ca9e5fd0e2 100644 --- a/front_end/core/host/UserMetrics.ts +++ b/front_end/core/host/UserMetrics.ts @@ -345,7 +345,8 @@ export enum Action { PerfPanelTraceImported = 54, PerfPanelTraceExported = 55, StackFrameRestarted = 56, - MaxValue = 57, + CaptureTestProtocolClicked = 57, + MaxValue = 58, } /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/front_end/core/i18n/locales/en-US.json b/front_end/core/i18n/locales/en-US.json index cca4541cde..c474b5008f 100644 --- a/front_end/core/i18n/locales/en-US.json +++ b/front_end/core/i18n/locales/en-US.json @@ -2426,6 +2426,9 @@ "panels/application/AppManifestView.ts | primaryManifestIconFromS": { "message": "Primary manifest icon from {PH1}" }, + "panels/application/AppManifestView.ts | protocolHandlers": { + "message": "Protocol Handlers" + }, "panels/application/AppManifestView.ts | screenshot": { "message": "Screenshot" }, @@ -3197,6 +3200,24 @@ "panels/application/components/PermissionsPolicySection.ts | showDetails": { "message": "Show details" }, + "panels/application/components/ProtocolHandlersView.ts | manifest": { + "message": "manifest" + }, + "panels/application/components/ProtocolHandlersView.ts | needHelpReadOur": { + "message": "Need help? Read {PH1}." + }, + "panels/application/components/ProtocolHandlersView.ts | protocolDetected": { + "message": "Found valid protocol handler registration in the {PH1}. With the app installed, test the registered protocols." + }, + "panels/application/components/ProtocolHandlersView.ts | protocolHandlerRegistrations": { + "message": "URL protocol handler registration for PWAs" + }, + "panels/application/components/ProtocolHandlersView.ts | protocolNotDetected": { + "message": "Define protocol handlers in the {PH1} to register your app as a handler for custom protocols when your app is installed." + }, + "panels/application/components/ProtocolHandlersView.ts | testProtocol": { + "message": "Test protocol" + }, "panels/application/components/ReportsGrid.ts | destination": { "message": "Destination" }, diff --git a/front_end/core/i18n/locales/en-XL.json b/front_end/core/i18n/locales/en-XL.json index 89ad5bc0d3..7a4c928121 100644 --- a/front_end/core/i18n/locales/en-XL.json +++ b/front_end/core/i18n/locales/en-XL.json @@ -2426,6 +2426,9 @@ "panels/application/AppManifestView.ts | primaryManifestIconFromS": { "message": "P̂ŕîḿâŕŷ ḿâńîf́êśt̂ íĉón̂ f́r̂óm̂ {PH1}" }, + "panels/application/AppManifestView.ts | protocolHandlers": { + "message": "P̂ŕôt́ôćôĺ Ĥán̂d́l̂ér̂ś" + }, "panels/application/AppManifestView.ts | screenshot": { "message": "Ŝćr̂éêńŝh́ôt́" }, @@ -3197,6 +3200,24 @@ "panels/application/components/PermissionsPolicySection.ts | showDetails": { "message": "Ŝh́ôẃ d̂ét̂áîĺŝ" }, + "panels/application/components/ProtocolHandlersView.ts | manifest": { + "message": "m̂án̂íf̂éŝt́" + }, + "panels/application/components/ProtocolHandlersView.ts | needHelpReadOur": { + "message": "N̂éêd́ ĥél̂ṕ? R̂éâd́ {PH1}." + }, + "panels/application/components/ProtocolHandlersView.ts | protocolDetected": { + "message": "F̂óûńd̂ v́âĺîd́ p̂ŕôt́ôćôĺ ĥán̂d́l̂ér̂ ŕêǵîśt̂ŕât́îón̂ ín̂ t́ĥé {PH1}. Ŵít̂h́ t̂h́ê áp̂ṕ îńŝt́âĺl̂éd̂, t́êśt̂ t́ĥé r̂éĝíŝt́êŕêd́ p̂ŕôt́ôćôĺŝ." + }, + "panels/application/components/ProtocolHandlersView.ts | protocolHandlerRegistrations": { + "message": "ÛŔL̂ ṕr̂ót̂óĉól̂ h́âńd̂ĺêŕ r̂éĝíŝt́r̂át̂íôń f̂ór̂ ṔŴÁŝ" + }, + "panels/application/components/ProtocolHandlersView.ts | protocolNotDetected": { + "message": "D̂éf̂ín̂é p̂ŕôt́ôćôĺ ĥán̂d́l̂ér̂ś îń t̂h́ê {PH1} t́ô ŕêǵîśt̂ér̂ ýôúr̂ áp̂ṕ âś â h́âńd̂ĺêŕ f̂ór̂ ćûśt̂óm̂ ṕr̂ót̂óĉól̂ś ŵh́êń ŷóûŕ âṕp̂ íŝ ín̂śt̂ál̂ĺêd́." + }, + "panels/application/components/ProtocolHandlersView.ts | testProtocol": { + "message": "T̂éŝt́ p̂ŕôt́ôćôĺ" + }, "panels/application/components/ReportsGrid.ts | destination": { "message": "D̂éŝt́îńât́îón̂" }, diff --git a/front_end/panels/application/AppManifestView.ts b/front_end/panels/application/AppManifestView.ts index 310f7e2ada..3b290acf35 100644 --- a/front_end/panels/application/AppManifestView.ts +++ b/front_end/panels/application/AppManifestView.ts @@ -15,6 +15,7 @@ import * as Components from '../../ui/legacy/components/utils/utils.js'; import * as UI from '../../ui/legacy/legacy.js'; import type * as Protocol from '../../generated/protocol.js'; import * as IconButton from '../../ui/components/icon_button/icon_button.js'; +import * as ApplicationComponents from './components/components.js'; const UIStrings = { /** @@ -44,6 +45,10 @@ const UIStrings = { /** *@description Text in App Manifest View of the Application panel */ + protocolHandlers: 'Protocol Handlers', + /** + *@description Text in App Manifest View of the Application panel + */ icons: 'Icons', /** *@description Text for the name of something @@ -423,6 +428,7 @@ export class AppManifestView extends UI.Widget.VBox implements SDK.TargetManager private target?: SDK.Target.Target; private resourceTreeModel?: SDK.ResourceTreeModel.ResourceTreeModel|null; private serviceWorkerManager?: SDK.ServiceWorkerManager.ServiceWorkerManager|null; + private protocolHandlersView: ApplicationComponents.ProtocolHandlersView.ProtocolHandlersView; constructor() { super(true); @@ -448,8 +454,10 @@ export class AppManifestView extends UI.Widget.VBox implements SDK.TargetManager this.errorsSection = this.reportView.appendSection(i18nString(UIStrings.errorsAndWarnings)); this.installabilitySection = this.reportView.appendSection(i18nString(UIStrings.installability)); this.identitySection = this.reportView.appendSection(i18nString(UIStrings.identity)); - this.presentationSection = this.reportView.appendSection(i18nString(UIStrings.presentation)); + const protocolHandlersSection = this.reportView.appendSection(i18nString(UIStrings.protocolHandlers)); + this.protocolHandlersView = new ApplicationComponents.ProtocolHandlersView.ProtocolHandlersView(); + protocolHandlersSection.contentElement.append(this.protocolHandlersView); this.iconsSection = this.reportView.appendSection(i18nString(UIStrings.icons), 'report-section-icons'); this.shortcutSections = []; this.screenshotsSections = []; @@ -701,6 +709,9 @@ export class AppManifestView extends UI.Widget.VBox implements SDK.TargetManager this.newNoteUrlField.appendChild(link); } + const protocolHandlers = parsedManifest['protocol_handlers'] || []; + this.protocolHandlersView.data = {protocolHandlers, manifestLink: url}; + const icons = parsedManifest['icons'] || []; this.iconsSection.clearContent(); diff --git a/front_end/panels/application/components/BUILD.gn b/front_end/panels/application/components/BUILD.gn index f565f83ada..a095abab6d 100644 --- a/front_end/panels/application/components/BUILD.gn +++ b/front_end/panels/application/components/BUILD.gn @@ -16,6 +16,7 @@ generate_css("css_files") { "originTrialTokenRows.css", "originTrialTreeView.css", "permissionsPolicySection.css", + "protocolHandlersView.css", "reportingApiGrid.css", "stackTraceLinkButton.css", "stackTraceRow.css", @@ -33,6 +34,7 @@ devtools_module("components") { "InterestGroupAccessGrid.ts", "OriginTrialTreeView.ts", "PermissionsPolicySection.ts", + "ProtocolHandlersView.ts", "ReportsGrid.ts", "StackTrace.ts", "TrustTokensView.ts", @@ -54,6 +56,7 @@ devtools_module("components") { "../../../ui/components/expandable_list:bundle", "../../../ui/components/icon_button:bundle", "../../../ui/components/report_view:bundle", + "../../../ui/components/text_prompt:bundle", "../../../ui/components/tree_outline:bundle", "../../../ui/legacy:bundle", "../../../ui/legacy/components/source_frame:bundle", diff --git a/front_end/panels/application/components/ProtocolHandlersView.ts b/front_end/panels/application/components/ProtocolHandlersView.ts new file mode 100644 index 0000000000..a02bb63584 --- /dev/null +++ b/front_end/panels/application/components/ProtocolHandlersView.ts @@ -0,0 +1,182 @@ +// Copyright 2022 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. + +import * as Host from '../../../core/host/host.js'; +import * as i18n from '../../../core/i18n/i18n.js'; +import * as Platform from '../../../core/platform/platform.js'; +import * as Buttons from '../../../ui/components/buttons/buttons.js'; +import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js'; +import * as IconButton from '../../../ui/components/icon_button/icon_button.js'; +import * as Input from '../../../ui/components/input/input.js'; +// inspectorCommonStyles is imported for the chrome-select class that is used for the dropdown +// eslint-disable-next-line rulesdir/es_modules_import +import inspectorCommonStyles from '../../../ui/legacy/inspectorCommon.css.js'; +import * as UI from '../../../ui/legacy/legacy.js'; +import * as LitHtml from '../../../ui/lit-html/lit-html.js'; + +import protocolHandlersViewStyles from './protocolHandlersView.css.js'; + +const PROTOCOL_DOCUMENT_URL = 'https://web.dev/url-protocol-handler/'; +const UIStrings = { + /** + *@description Status message for when protocol handlers are detected in the manifest + *@example {protocolhandler/manifest.json} PH1 + */ + protocolDetected: + 'Found valid protocol handler registration in the {PH1}. With the app installed, test the registered protocols.', + /** + *@description Status message for when protocol handlers are not detected in the manifest + *@example {protocolhandler/manifest.json} PH1 + */ + protocolNotDetected: + 'Define protocol handlers in the {PH1} to register your app as a handler for custom protocols when your app is installed.', + /** + *@description Text wrapping a link pointing to more information on handling protocol handlers + *@example {https://example.com/} PH1 + */ + needHelpReadOur: 'Need help? Read {PH1}.', + /** + *@description Link text for more information on URL protocol handler registrations for PWAs + */ + protocolHandlerRegistrations: 'URL protocol handler registration for PWAs', + /** + *@description In text hyperlink to the PWA manifest + */ + manifest: 'manifest', + /** + *@description Text for test protocol button + */ + testProtocol: 'Test protocol', +}; + +const str_ = i18n.i18n.registerUIStrings('panels/application/components/ProtocolHandlersView.ts', UIStrings); +const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); + +interface HTMLSelectElementEvent extends Event { + target: HTMLSelectElement; +} + +interface HTMLInputElementEvent extends Event { + target: HTMLInputElement; +} + +export interface ProtocolHandler { + protocol: string; + url: string; +} + +export interface ProtocolHandlersData { + protocolHandlers: ProtocolHandler[]; + manifestLink: Platform.DevToolsPath.UrlString; +} + +export class ProtocolHandlersView extends HTMLElement { + static readonly litTagName = LitHtml.literal`devtools-protocol-handlers-view`; + readonly #shadow = this.attachShadow({mode: 'open'}); + #protocolHandlers: ProtocolHandler[] = []; + #manifestLink: Platform.DevToolsPath.UrlString = Platform.DevToolsPath.EmptyUrlString; + #selectedProtocolState: string = ''; + #queryInputState: string = ''; + + set data(data: ProtocolHandlersData) { + const isNewManifest = this.#manifestLink !== data.manifestLink; + this.#protocolHandlers = data.protocolHandlers; + this.#manifestLink = data.manifestLink; + if (isNewManifest) { + this.#update(); + } + } + + #update(): void { + this.#queryInputState = ''; + this.#selectedProtocolState = this.#protocolHandlers[0]?.protocol ?? ''; + this.#render(); + } + + #renderStatusMessage(): LitHtml.TemplateResult { + const manifestInTextLink = UI.XLink.XLink.create(this.#manifestLink, i18nString(UIStrings.manifest)); + const statusString = this.#protocolHandlers.length > 0 ? UIStrings.protocolDetected : UIStrings.protocolNotDetected; + const iconData: IconButton.Icon.IconData = { + iconName: this.#protocolHandlers.length > 0 ? 'ic_checkmark_16x16' : 'ic_info_black_18dp', + color: this.#protocolHandlers.length > 0 ? 'var( --color-ic-file-image)' : 'var(--color-link)', + width: '16px', + }; + return LitHtml.html` +
+ <${IconButton.Icon.Icon.litTagName} class="inline-icon" .data=${iconData as IconButton.Icon.IconData}> + + ${i18n.i18n.getFormatLocalizedString(str_, statusString, { + PH1: manifestInTextLink, + })} +
+ `; + } + + #renderProtocolTest(): LitHtml.LitTemplate { + if (this.#protocolHandlers.length === 0) { + return LitHtml.nothing; + } + const protocolOptions = this.#protocolHandlers.filter(p => p.protocol) + .map(p => LitHtml.html``); + return LitHtml.html` +
+ + + <${Buttons.Button.Button.litTagName} .variant=${Buttons.Button.Variant.PRIMARY} @click=${ + this.#handleTestProtocolClick}> + ${i18nString(UIStrings.testProtocol)} + +
+ `; + } + + #handleProtocolSelect = (evt: HTMLSelectElementEvent): void => { + this.#selectedProtocolState = evt.target.value; + }; + + #handleQueryInputChange = (evt: HTMLInputElementEvent): void => { + this.#queryInputState = evt.target.value; + this.#render(); + }; + + #handleTestProtocolClick = (): void => { + const protocolURL = `${this.#selectedProtocolState}://${this.#queryInputState}` as Platform.DevToolsPath.UrlString; + Host.InspectorFrontendHost.InspectorFrontendHostInstance.openInNewTab(protocolURL); + Host.userMetrics.actionTaken(Host.UserMetrics.Action.CaptureTestProtocolClicked); + }; + + connectedCallback(): void { + this.#shadow.adoptedStyleSheets = [ + protocolHandlersViewStyles, + inspectorCommonStyles, + Input.textInputStyles, + ]; + } + + #render(): void { + const protocolDocLink = + UI.XLink.XLink.create(PROTOCOL_DOCUMENT_URL, i18nString(UIStrings.protocolHandlerRegistrations)); + // clang-format off + LitHtml.render(LitHtml.html` + ${this.#renderStatusMessage()} +
+ ${i18n.i18n.getFormatLocalizedString(str_, UIStrings.needHelpReadOur, {PH1: protocolDocLink})} +
+ ${this.#renderProtocolTest()} + `, this.#shadow, {host: this}); + // clang-format on + } +} + +ComponentHelpers.CustomElements.defineComponent('devtools-protocol-handlers-view', ProtocolHandlersView); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface HTMLElementTagNameMap { + 'devtools-protocol-handlers-view': ProtocolHandlersView; + } +} diff --git a/front_end/panels/application/components/components.ts b/front_end/panels/application/components/components.ts index ee780c5f07..46baa883aa 100644 --- a/front_end/panels/application/components/components.ts +++ b/front_end/panels/application/components/components.ts @@ -7,6 +7,7 @@ import * as EndpointsGrid from './EndpointsGrid.js'; import * as FrameDetailsView from './FrameDetailsView.js'; import * as InterestGroupAccessGrid from './InterestGroupAccessGrid.js'; import * as OriginTrialTreeView from './OriginTrialTreeView.js'; +import * as ProtocolHandlersView from './ProtocolHandlersView.js'; import * as ReportsGrid from './ReportsGrid.js'; import * as StackTrace from './StackTrace.js'; import * as TrustTokensView from './TrustTokensView.js'; @@ -17,6 +18,7 @@ export { FrameDetailsView, InterestGroupAccessGrid, OriginTrialTreeView, + ProtocolHandlersView, ReportsGrid, StackTrace, TrustTokensView, diff --git a/front_end/panels/application/components/protocolHandlersView.css b/front_end/panels/application/components/protocolHandlersView.css new file mode 100644 index 0000000000..46d4116db6 --- /dev/null +++ b/front_end/panels/application/components/protocolHandlersView.css @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 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. + */ + +.devtools-link { + color: var(--color-link); + text-decoration: underline; + cursor: pointer; + padding: 2px 0; /* adjust focus ring size */ +} + +input.devtools-text-input[type="text"] { + padding: 3px 6px; + margin-left: 4px; + margin-right: 4px; + width: 250px; + height: 25px; +} + +.protocol-handlers-row { + margin: 10px 0 2px 18px; +} + +.inline-icon { + vertical-align: text-bottom; +} + +@media (forced-colors: active) { + .devtools-link:not(.devtools-link-prevent-click) { + color: linktext; + } + + .devtools-link:focus-visible { + background: Highlight; + color: HighlightText; + } +} diff --git a/test/e2e/helpers/application-helpers.ts b/test/e2e/helpers/application-helpers.ts index 569492b222..afc875f0e7 100644 --- a/test/e2e/helpers/application-helpers.ts +++ b/test/e2e/helpers/application-helpers.ts @@ -18,6 +18,13 @@ export async function navigateToServiceWorkers() { await click(SERVICE_WORKER_ROW_SELECTOR); } +export async function navigateToManifestInApplicationTab(testName: string) { + const MANIFEST_SELECTOR = '[aria-label="Manifest"]'; + const {target} = getBrowserAndPages(); + await navigateToApplicationTab(target, testName); + await click(MANIFEST_SELECTOR); +} + export async function doubleClickSourceTreeItem(selector: string) { const element = await waitFor(selector); element.evaluate(el => el.scrollIntoView(true)); diff --git a/test/unittests/front_end/panels/application/components/BUILD.gn b/test/unittests/front_end/panels/application/components/BUILD.gn index 7eaabf1a05..f799db0c82 100644 --- a/test/unittests/front_end/panels/application/components/BUILD.gn +++ b/test/unittests/front_end/panels/application/components/BUILD.gn @@ -11,6 +11,7 @@ ts_library("components") { "FrameDetailsView_test.ts", "InterestGroupAccessGrid_test.ts", "OriginTrialTreeView_test.ts", + "ProtocolHandlersView_test.ts", "ReportsGrid_test.ts", "StackTrace_test.ts", "TrustTokensView_test.ts", diff --git a/test/unittests/front_end/panels/application/components/ProtocolHandlersView_test.ts b/test/unittests/front_end/panels/application/components/ProtocolHandlersView_test.ts new file mode 100644 index 0000000000..fb97bea6f3 --- /dev/null +++ b/test/unittests/front_end/panels/application/components/ProtocolHandlersView_test.ts @@ -0,0 +1,77 @@ +// Copyright 2022 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. + +import * as ApplicationComponents from '../../../../../../front_end/panels/application/components/components.js'; +import type * as Platform from '../../../../../../front_end/core/platform/platform.js'; +import { + assertElement, + assertShadowRoot, + getCleanTextContentFromElements, + getElementWithinComponent, + renderElementIntoDOM, +} from '../../../helpers/DOMHelpers.js'; +import {describeWithEnvironment} from '../../../helpers/EnvironmentHelpers.js'; + +const {assert} = chai; + +async function renderProtocolHandlersComponent( + manifestLink: Platform.DevToolsPath.UrlString, + protocolHandlers: ApplicationComponents.ProtocolHandlersView.ProtocolHandler[]) { + const component = new ApplicationComponents.ProtocolHandlersView.ProtocolHandlersView(); + renderElementIntoDOM(component); + component.data = {manifestLink, protocolHandlers}; + return component; +} + +describeWithEnvironment('ProtocolHandlersView', () => { + it('renders view when protocols are detected', async () => { + const protocols = [ + { + 'protocol': 'web+coffee', + 'url': './?coffee=%s', + }, + { + 'protocol': 'web+pwinter', + 'url': 'index.html?colors=%s', + }, + { + 'invalid-protocol': 'this is an invalid protocol entry for testing purposes', + }, + ]; + const manifestURL = 'https://www.example.com/index.html/manifest-protocol.json' as Platform.DevToolsPath.UrlString; + const component = await renderProtocolHandlersComponent( + manifestURL, protocols as ApplicationComponents.ProtocolHandlersView.ProtocolHandler[]); + assertShadowRoot(component.shadowRoot); + + const statusElement = component.shadowRoot.querySelector('.protocol-handlers-row.status'); + assertElement(statusElement, HTMLElement); + + // Tests if status message for when protocols are detected in the manifest is rendering + const protocolsDetectedMessage = getCleanTextContentFromElements(statusElement, 'span'); + const expectedStatusMessage = + 'Found valid protocol handler registration in the manifest. With the app installed, test the registered protocols.'; + assert.deepEqual(protocolsDetectedMessage[0], expectedStatusMessage); + + // Tests if protocols are rendering properly in dropdown + const selectElement = getElementWithinComponent(component, '.protocol-select', HTMLSelectElement); + const values = getCleanTextContentFromElements(selectElement, 'option'); + assert.deepEqual(values, ['web+coffee://', 'web+pwinter://']); + }); + + it('renders protocols not detected status message', async () => { + const protocols: ApplicationComponents.ProtocolHandlersView.ProtocolHandler[] = []; + const manifestURL = 'https://www.example.com/index.html/manifest-protocol.json' as Platform.DevToolsPath.UrlString; + const component = await renderProtocolHandlersComponent( + manifestURL, protocols as ApplicationComponents.ProtocolHandlersView.ProtocolHandler[]); + assertShadowRoot(component.shadowRoot); + + const noStatusElement = component.shadowRoot.querySelector('.protocol-handlers-row.status'); + assertElement(noStatusElement, HTMLElement); + + const protocolsNotDetectedMessage = getCleanTextContentFromElements(noStatusElement, 'span'); + const expectedStatusMessage = + 'Define protocol handlers in the manifest to register your app as a handler for custom protocols when your app is installed.'; + assert.deepEqual(protocolsNotDetectedMessage[0], expectedStatusMessage); + }); +});