diff --git a/devtools/projects/protocol/src/lib/messages.ts b/devtools/projects/protocol/src/lib/messages.ts index 20f3c64a002..5ae4c73a47e 100644 --- a/devtools/projects/protocol/src/lib/messages.ts +++ b/devtools/projects/protocol/src/lib/messages.ts @@ -213,6 +213,19 @@ export interface Route { isAux: boolean; } +export interface AngularDetection { + // This is necessary because the runtime + // message listener handles messages globally + // including from other extensions. We don't + // want to set icon and/or popup based on + // a message coming from an unrelated extension. + isAngularDevTools: true; + isIvy: boolean; + isAngular: boolean; + isDebugMode: boolean; + isSupportedAngularVersion: boolean; +} + export type Topic = keyof Events; export interface InjectorGraphViewQuery { @@ -273,4 +286,13 @@ export interface Events { ) => void; logProvider: (injector: SerializedInjector, providers: SerializedProviderRecord) => void; + + contentScriptConnected: (frameId: number, name: string, url: string) => void; + contentScriptDisconnected: (frameId: number, name: string, url: string) => void; + enableFrameConnection: (frameId: number, tabId: number) => void; + frameConnected: (frameId: number) => void; + detectAngular: (detectionResult: AngularDetection) => void; + backendReady: () => void; + + log: (logEvent: {message: string; level: 'log' | 'warn' | 'debug' | 'error'}) => void; } diff --git a/devtools/projects/shell-browser/src/app/BUILD.bazel b/devtools/projects/shell-browser/src/app/BUILD.bazel index 83e207de816..06fe4ab97f3 100644 --- a/devtools/projects/shell-browser/src/app/BUILD.bazel +++ b/devtools/projects/shell-browser/src/app/BUILD.bazel @@ -1,7 +1,8 @@ load("@io_bazel_rules_sass//:defs.bzl", "sass_binary") load("//devtools/tools:ng_module.bzl", "ng_module") -load("//devtools/tools:typescript.bzl", "ts_library") +load("//devtools/tools:typescript.bzl", "ts_library", "ts_test_library") load("//tools:defaults.bzl", "esbuild") +load("//devtools/tools:defaults.bzl", "karma_web_test_suite") package(default_visibility = ["//visibility:public"]) @@ -15,7 +16,6 @@ ng_module( srcs = [ "app.component.ts", "app.module.ts", - "inject.ts", ], angular_assets = [ "app.component.html", @@ -35,6 +35,8 @@ ng_module( "//packages/core", "//packages/platform-browser", "//packages/platform-browser/animations", + "@npm//@angular/material", + "@npm//rxjs", ], ) @@ -78,8 +80,10 @@ ts_library( "chrome-application-operations.ts", ], deps = [ + ":chrome_application_environment", "//devtools/projects/ng-devtools", "//devtools/projects/protocol", + "//packages/core", "@npm//@types", ], ) @@ -126,7 +130,38 @@ ts_library( srcs = [ "background.ts", ], - deps = ["//devtools/projects/shell-browser/src/app:detect_angular_for_extension_icon"], + deps = [ + ":tab_manager", + "//devtools/projects/protocol", + "//devtools/projects/shell-browser/src/app:detect_angular_for_extension_icon", + ], +) + +ts_library( + name = "tab_manager", + srcs = [ + "tab_manager.ts", + ], + deps = [ + "//devtools/projects/protocol", + ], +) + +karma_web_test_suite( + name = "tab_manager_test", + deps = [ + ":tab_manager_test_lib", + ], +) + +ts_test_library( + name = "tab_manager_test_lib", + srcs = [ + "tab_manager_spec.ts", + ], + deps = [ + ":tab_manager", + ], ) ts_library( @@ -162,6 +197,8 @@ ts_library( "detect-angular-for-extension-icon.ts", ], deps = [ + ":same_page_message_bus", + "//devtools/projects/protocol", "//devtools/projects/shared-utils", "@npm//@types", ], diff --git a/devtools/projects/shell-browser/src/app/app.component.ts b/devtools/projects/shell-browser/src/app/app.component.ts index 0e6d69fe230..0b0be191620 100644 --- a/devtools/projects/shell-browser/src/app/app.component.ts +++ b/devtools/projects/shell-browser/src/app/app.component.ts @@ -6,39 +6,21 @@ * found in the LICENSE file at https://angular.io/license */ -import {ChangeDetectorRef, Component, NgZone, OnInit} from '@angular/core'; -import {Events, MessageBus, PriorityAwareMessageBus} from 'protocol'; - -import {injectScripts} from './inject'; -import {ZoneAwareChromeMessageBus} from './zone-aware-chrome-message-bus'; -import {DevToolsComponent} from 'ng-devtools'; +import {ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'], - providers: [ - { - provide: MessageBus, - useFactory(ngZone: NgZone): MessageBus { - const port = chrome.runtime.connect({ - name: '' + chrome.devtools.inspectedWindow.tabId, - }); - return new PriorityAwareMessageBus(new ZoneAwareChromeMessageBus(port, ngZone)); - }, - deps: [NgZone], - }, - ], }) export class AppComponent implements OnInit { - constructor(private _cd: ChangeDetectorRef) {} + private _cd = inject(ChangeDetectorRef); ngOnInit(): void { chrome.devtools.network.onNavigated.addListener(() => { window.location.reload(); }); - injectScripts(['app/backend_bundle.js']); this._cd.detectChanges(); } } diff --git a/devtools/projects/shell-browser/src/app/app.module.ts b/devtools/projects/shell-browser/src/app/app.module.ts index 612efc6889e..bc10e6838b9 100644 --- a/devtools/projects/shell-browser/src/app/app.module.ts +++ b/devtools/projects/shell-browser/src/app/app.module.ts @@ -6,17 +6,20 @@ * found in the LICENSE file at https://angular.io/license */ -import {NgModule} from '@angular/core'; +import {NgModule, NgZone} from '@angular/core'; +import {MatSelect} from '@angular/material/select'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {ApplicationEnvironment, ApplicationOperations, DevToolsComponent} from 'ng-devtools'; import {AppComponent} from './app.component'; import {ChromeApplicationEnvironment} from './chrome-application-environment'; import {ChromeApplicationOperations} from './chrome-application-operations'; +import {ZoneAwareChromeMessageBus} from './zone-aware-chrome-message-bus'; +import {Events, MessageBus, PriorityAwareMessageBus} from 'protocol'; @NgModule({ declarations: [AppComponent], - imports: [BrowserAnimationsModule, DevToolsComponent], + imports: [BrowserAnimationsModule, DevToolsComponent, MatSelect], bootstrap: [AppComponent], providers: [ { @@ -27,6 +30,17 @@ import {ChromeApplicationOperations} from './chrome-application-operations'; provide: ApplicationEnvironment, useClass: ChromeApplicationEnvironment, }, + { + provide: MessageBus, + useFactory(ngZone: NgZone): MessageBus { + const port = chrome.runtime.connect({ + name: '' + chrome.devtools.inspectedWindow.tabId, + }); + + return new PriorityAwareMessageBus(new ZoneAwareChromeMessageBus(port, ngZone)); + }, + deps: [NgZone], + }, ], }) export class AppModule {} diff --git a/devtools/projects/shell-browser/src/app/backend.ts b/devtools/projects/shell-browser/src/app/backend.ts index 8dadbe5e996..585008ce190 100644 --- a/devtools/projects/shell-browser/src/app/backend.ts +++ b/devtools/projects/shell-browser/src/app/backend.ts @@ -14,8 +14,8 @@ import {initializeExtendedWindowOperations} from './chrome-window-extensions'; import {SamePageMessageBus} from './same-page-message-bus'; const messageBus = new SamePageMessageBus( - 'angular-devtools-backend', - 'angular-devtools-content-script', + `angular-devtools-backend-${location.href}`, + `angular-devtools-content-script-${location.href}`, ); let initialized = false; @@ -47,4 +47,6 @@ messageBus.on('handshake', () => { }, false, ); + + messageBus.emit('backendReady'); }); diff --git a/devtools/projects/shell-browser/src/app/background.ts b/devtools/projects/shell-browser/src/app/background.ts index 80b94d32513..fda02d5a8dd 100644 --- a/devtools/projects/shell-browser/src/app/background.ts +++ b/devtools/projects/shell-browser/src/app/background.ts @@ -8,164 +8,10 @@ /// -import {AngularDetection} from './detect-angular-for-extension-icon'; +import {AngularDetection} from 'protocol'; +import {TabManager, Tabs} from './tab_manager'; -const isManifestV3 = chrome.runtime.getManifest().manifest_version === 3; - -const browserAction = (() => { - // Electron does not expose browserAction object, - // Use empty calls as fallback if they are not defined. - const noopAction = {setIcon: () => {}, setPopup: () => {}}; - - if (isManifestV3) { - return chrome.action || noopAction; - } - - return chrome.browserAction || noopAction; -})(); - -// By default use the black and white icon. -// Replace it only when we detect an Angular app. -browserAction.setIcon( - { - path: { - 16: chrome.runtime.getURL(`assets/icon-bw16.png`), - 48: chrome.runtime.getURL(`assets/icon-bw48.png`), - 128: chrome.runtime.getURL(`assets/icon-bw128.png`), - }, - }, - () => {}, -); - -const ports: { - [tab: string]: - | { - 'content-script': chrome.runtime.Port | null; - devtools: chrome.runtime.Port | null; - } - | undefined; -} = {}; - -chrome.runtime.onConnect.addListener((port) => { - let tab: string | null = null; - let name: 'devtools' | 'content-script' | null = null; - // tslint:disable-next-line:no-console - console.log('Connection event in the background script'); - - if (isNumeric(port.name)) { - tab = port.name; - - // tslint:disable-next-line:no-console - console.log('Angular devtools connected, injecting the content script', port.name, ports[tab]); - - name = 'devtools'; - installContentScript(parseInt(port.name, 10)); - } else { - if (!port.sender || !port.sender.tab) { - // tslint:disable-next-line:no-console - console.error('Unable to access the port sender and sender tab'); - - return; - } - if (port.sender.tab.id === undefined) { - // tslint:disable-next-line:no-console - console.error('Sender tab id is undefined'); - - return; - } - - // tslint:disable-next-line:no-console - console.log('Content script connected', port.sender.tab.id); - tab = port.sender.tab.id.toString(); - name = 'content-script'; - } - - let portsTab = ports[tab]; - if (!portsTab) { - // tslint:disable-next-line:no-console - console.log('Creating a tab port'); - - portsTab = ports[tab] = { - devtools: null, - 'content-script': null, - }; - } - - portsTab[name] = port; - - if (portsTab.devtools && portsTab['content-script']) { - doublePipe(portsTab.devtools, portsTab['content-script'], tab); - } -}); - -const isNumeric = (str: string): boolean => { - return +str + '' === str; -}; - -const installContentScript = (tabId: number) => { - // tslint:disable-next-line:no-console - console.log('Installing the content-script'); - - // We first inject the content-script and after that - // invoke the global that it exposes. - - if (isManifestV3) { - chrome.scripting.executeScript( - {files: ['app/content_script_bundle.js'], target: {tabId}}, - () => { - chrome.scripting.executeScript({func: () => (globalThis as any).main(), target: {tabId}}); - }, - ); - - return; - } - - // manifest V2 APIs - chrome.tabs.executeScript(tabId, {file: 'app/content_script_bundle.js'}, (result) => { - chrome.tabs.executeScript(tabId, { - code: 'globalThis.main()', - }); - }); -}; - -const doublePipe = ( - devtoolsPort: chrome.runtime.Port | null, - contentScriptPort: chrome.runtime.Port, - tab: string, -) => { - if (devtoolsPort === null) { - console.warn('DevTools port is equal to null'); - return; - } - - // tslint:disable-next-line:no-console - console.log('Creating two-way communication channel', Date.now(), ports); - - const onDevToolsMessage = (message: chrome.runtime.Port) => { - contentScriptPort.postMessage(message); - }; - devtoolsPort.onMessage.addListener(onDevToolsMessage); - - const onContentScriptMessage = (message: chrome.runtime.Port) => { - devtoolsPort.postMessage(message); - }; - contentScriptPort.onMessage.addListener(onContentScriptMessage); - - const shutdown = (source: string) => { - // tslint:disable-next-line:no-console - console.log('Disconnecting', source); - - devtoolsPort.onMessage.removeListener(onDevToolsMessage); - contentScriptPort.onMessage.removeListener(onContentScriptMessage); - devtoolsPort.disconnect(); - contentScriptPort.disconnect(); - ports[tab] = undefined; - }; - devtoolsPort.onDisconnect.addListener(shutdown.bind(null, 'devtools')); - contentScriptPort.onDisconnect.addListener(shutdown.bind(null, 'content-script')); -}; - -const getPopUpName = (ng: AngularDetection) => { +function getPopUpName(ng: AngularDetection): string { if (!ng.isAngular) { return 'not-angular.html'; } @@ -176,29 +22,63 @@ const getPopUpName = (ng: AngularDetection) => { return 'production.html'; } return 'supported.html'; -}; +} -chrome.runtime.onMessage.addListener((req: AngularDetection, sender) => { - if (!req.isAngularDevTools) { - return; - } - if (sender && sender.tab) { - browserAction.setPopup({ - tabId: sender.tab.id, - popup: `popups/${getPopUpName(req)}`, - }); - } - if (sender && sender.tab && req.isAngular) { - browserAction.setIcon( - { - tabId: sender.tab.id, - path: { - 16: chrome.runtime.getURL(`assets/icon16.png`), - 48: chrome.runtime.getURL(`assets/icon48.png`), - 128: chrome.runtime.getURL(`assets/icon128.png`), - }, +if (chrome !== undefined && chrome.runtime !== undefined) { + const isManifestV3 = chrome.runtime.getManifest().manifest_version === 3; + + const browserAction = (() => { + // Electron does not expose browserAction object, + // Use empty calls as fallback if they are not defined. + const noopAction = {setIcon: () => {}, setPopup: () => {}}; + + if (isManifestV3) { + return chrome.action || noopAction; + } + + return chrome.browserAction || noopAction; + })(); + + // By default use the black and white icon. + // Replace it only when we detect an Angular app. + browserAction.setIcon( + { + path: { + 16: chrome.runtime.getURL(`assets/icon-bw16.png`), + 48: chrome.runtime.getURL(`assets/icon-bw48.png`), + 128: chrome.runtime.getURL(`assets/icon-bw128.png`), }, - () => {}, - ); - } -}); + }, + () => {}, + ); + + chrome.runtime.onMessage.addListener((req: AngularDetection, sender) => { + if (!req.isAngularDevTools) { + return; + } + + if (sender && sender.tab) { + browserAction.setPopup({ + tabId: sender.tab.id, + popup: `popups/${getPopUpName(req)}`, + }); + } + + if (sender && sender.tab && req.isAngular) { + browserAction.setIcon( + { + tabId: sender.tab.id, + path: { + 16: chrome.runtime.getURL(`assets/icon16.png`), + 48: chrome.runtime.getURL(`assets/icon48.png`), + 128: chrome.runtime.getURL(`assets/icon128.png`), + }, + }, + () => {}, + ); + } + }); + + const tabs = {}; + TabManager.initialize(tabs); +} diff --git a/devtools/projects/shell-browser/src/app/chrome-application-environment.ts b/devtools/projects/shell-browser/src/app/chrome-application-environment.ts index dd4708d24e0..f839f15a1b3 100644 --- a/devtools/projects/shell-browser/src/app/chrome-application-environment.ts +++ b/devtools/projects/shell-browser/src/app/chrome-application-environment.ts @@ -11,6 +11,8 @@ import {ApplicationEnvironment, Environment} from 'ng-devtools'; import {environment} from '../environments/environment'; export class ChromeApplicationEnvironment extends ApplicationEnvironment { + frameSelectorEnabled = true; + override get environment(): Environment { return environment; } diff --git a/devtools/projects/shell-browser/src/app/chrome-application-operations.ts b/devtools/projects/shell-browser/src/app/chrome-application-operations.ts index 729e461f32f..bffe57b2a19 100644 --- a/devtools/projects/shell-browser/src/app/chrome-application-operations.ts +++ b/devtools/projects/shell-browser/src/app/chrome-application-operations.ts @@ -11,32 +11,29 @@ import {ApplicationOperations} from 'ng-devtools'; import {DirectivePosition, ElementPosition} from 'protocol'; +function runInInspectedWindow(script: string, frameURL?: URL): void { + chrome.devtools.inspectedWindow.eval(script, {frameURL: frameURL?.toString?.()}); +} + export class ChromeApplicationOperations extends ApplicationOperations { - override viewSource(position: ElementPosition, directiveIndex: number): void { - if (chrome.devtools) { - chrome.devtools.inspectedWindow.eval( - `inspect(inspectedApplication.findConstructorByPosition('${position}', ${directiveIndex}))`, - ); - } + override viewSource(position: ElementPosition, directiveIndex?: number, target?: URL): void { + const viewSource = `inspect(inspectedApplication.findConstructorByPosition('${position}', ${directiveIndex}))`; + runInInspectedWindow(viewSource, target); } - override selectDomElement(position: ElementPosition): void { - if (chrome.devtools) { - chrome.devtools.inspectedWindow.eval( - `inspect(inspectedApplication.findDomElementByPosition('${position}'))`, - ); - } + override selectDomElement(position: ElementPosition, target?: URL): void { + const selectDomElement = `inspect(inspectedApplication.findDomElementByPosition('${position}'))`; + runInInspectedWindow(selectDomElement, target); } - override inspect(directivePosition: DirectivePosition, objectPath: string[]): void { - if (chrome.devtools) { - const args = { - directivePosition, - objectPath, - }; - chrome.devtools.inspectedWindow.eval( - `inspect(inspectedApplication.findPropertyByPosition('${JSON.stringify(args)}'))`, - ); - } + override inspect(directivePosition: DirectivePosition, objectPath: string[], target?: URL): void { + const args = { + directivePosition, + objectPath, + }; + const inspect = `inspect(inspectedApplication.findPropertyByPosition('${JSON.stringify( + args, + )}'))`; + runInInspectedWindow(inspect, target); } } diff --git a/devtools/projects/shell-browser/src/app/content-script.ts b/devtools/projects/shell-browser/src/app/content-script.ts index 8c2c4cd60f0..a9b6ada8d8c 100644 --- a/devtools/projects/shell-browser/src/app/content-script.ts +++ b/devtools/projects/shell-browser/src/app/content-script.ts @@ -9,59 +9,92 @@ import {ChromeMessageBus} from './chrome-message-bus'; import {SamePageMessageBus} from './same-page-message-bus'; -export const main = () => { - let backgroundDisconnected = false; - let backendInitialized = false; +let backgroundDisconnected = false; +let backendInstalled = false; +let backendInitialized = false; - // console.log('Content script executing', (window as any)); +const port = chrome.runtime.connect({ + name: `${document.title || location.href}`, +}); - const port = chrome.runtime.connect({ - name: 'content-script', - }); - - const handleDisconnect = (): void => { - // console.log('Background disconnected', new Date()); - localMessageBus.emit('shutdown'); - localMessageBus.destroy(); - chromeMessageBus.destroy(); - backgroundDisconnected = true; - }; - - port.onDisconnect.addListener(handleDisconnect); - - const localMessageBus = new SamePageMessageBus( - 'angular-devtools-content-script', - 'angular-devtools-backend', - ); - const chromeMessageBus = new ChromeMessageBus(port); - - const handshakeWithBackend = (): void => { - localMessageBus.emit('handshake'); - }; - - chromeMessageBus.onAny((topic, args) => { - localMessageBus.emit(topic, args); - }); - - localMessageBus.onAny((topic, args) => { - backendInitialized = true; - chromeMessageBus.emit(topic, args); - }); - - if (!backendInitialized) { - // tslint:disable-next-line:no-console - console.log('Attempting initialization', new Date()); - - const retry = () => { - if (backendInitialized || backgroundDisconnected) { - return; - } - handshakeWithBackend(); - setTimeout(retry, 500); - }; - retry(); - } +const handleDisconnect = (): void => { + // console.log('Background disconnected', new Date()); + localMessageBus.emit('shutdown'); + localMessageBus.destroy(); + chromeMessageBus.destroy(); + backgroundDisconnected = true; }; -// expose to use as callback for chrome.tabs.executeScript in background.ts -(globalThis as any).main = main; +port.onDisconnect.addListener(handleDisconnect); + +const detectAngularMessageBus = new SamePageMessageBus( + `angular-devtools-content-script-${location.href}`, + `angular-devtools-detect-angular-${location.href}`, +); + +detectAngularMessageBus.on('detectAngular', (detectionResult) => { + // only install backend once + if (backendInstalled) { + return; + } + + if (detectionResult.isAngularDevTools !== true) { + return; + } + + if (detectionResult.isAngular !== true) { + return; + } + + if (detectionResult.isDebugMode !== true) { + return; + } + + if (detectionResult.isSupportedAngularVersion !== true) { + return; + } + + // Defensive check against non html page. Realistically this should never happen. + if (document.contentType !== 'text/html') { + return; + } + + const script = document.createElement('script'); + script.src = chrome.runtime.getURL('app/backend_bundle.js'); + document.documentElement.appendChild(script); + document.documentElement.removeChild(script); + backendInstalled = true; +}); + +const localMessageBus = new SamePageMessageBus( + `angular-devtools-content-script-${location.href}`, + `angular-devtools-backend-${location.href}`, +); +const chromeMessageBus = new ChromeMessageBus(port); + +const handshakeWithBackend = (): void => { + localMessageBus.emit('handshake'); +}; + +chromeMessageBus.onAny((topic, args) => { + localMessageBus.emit(topic, args); +}); + +localMessageBus.onAny((topic, args) => { + backendInitialized = true; + chromeMessageBus.emit(topic, args); +}); + +if (!backendInitialized) { + // tslint:disable-next-line:no-console + console.log('Attempting initialization', new Date()); + + const retry = () => { + if (backendInitialized || backgroundDisconnected) { + return; + } + handshakeWithBackend(); + setTimeout(retry, 500); + }; + retry(); +} diff --git a/devtools/projects/shell-browser/src/app/detect-angular-for-extension-icon.ts b/devtools/projects/shell-browser/src/app/detect-angular-for-extension-icon.ts index 960ea40bc94..ac91dcd2e83 100644 --- a/devtools/projects/shell-browser/src/app/detect-angular-for-extension-icon.ts +++ b/devtools/projects/shell-browser/src/app/detect-angular-for-extension-icon.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {AngularDetection} from 'protocol'; import { appIsAngular, appIsAngularInDevMode, @@ -13,18 +14,12 @@ import { appIsSupportedAngularVersion, } from 'shared-utils'; -export interface AngularDetection { - // This is necessary because the runtime - // message listener handles messages globally - // including from other extensions. We don't - // want to set icon and/or popup based on - // a message coming from an unrelated extension. - isAngularDevTools: true; - isIvy: boolean; - isAngular: boolean; - isDebugMode: boolean; - isSupportedAngularVersion: boolean; -} +import {SamePageMessageBus} from './same-page-message-bus'; + +const detectAngularMessageBus = new SamePageMessageBus( + `angular-devtools-detect-angular-${location.href}`, + `angular-devtools-content-script-${location.href}`, +); function detectAngular(win: Window): void { const isAngular = appIsAngular(); @@ -32,20 +27,29 @@ function detectAngular(win: Window): void { const isDebugMode = appIsAngularInDevMode(); const isIvy = appIsAngularIvy(); - win.postMessage( + const detection: AngularDetection = { + isIvy, + isAngular, + isDebugMode, + isSupportedAngularVersion, + isAngularDevTools: true, + }; + + // For the background script to toggle the icon. + win.postMessage(detection, '*'); + + // For the content script to inject the backend. + detectAngularMessageBus.emit('detectAngular', [ { isIvy, isAngular, isDebugMode, isSupportedAngularVersion, isAngularDevTools: true, - } as AngularDetection, - '*', - ); + }, + ]); - if (!isAngular) { - setTimeout(() => detectAngular(win), 1000); - } + setTimeout(() => detectAngular(win), 1000); } detectAngular(window); diff --git a/devtools/projects/shell-browser/src/app/inject.ts b/devtools/projects/shell-browser/src/app/inject.ts deleted file mode 100644 index 7d9428f8b4a..00000000000 --- a/devtools/projects/shell-browser/src/app/inject.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -const loadScripts = (urls: string[]) => { - return urls - .map((url, idx) => { - return ` - const script${idx} = document.constructor.prototype.createElement.call(document, 'script'); - script${idx}.src = getScriptName("${url}"); - document.documentElement.appendChild(script${idx}); - script${idx}.parentNode.removeChild(script${idx}); - `; - }) - .join('\n'); -}; - -let loaded = false; -export const injectScripts = (urls: string[], cb?: () => void) => { - if (loaded) { - // Not throwing a hard error here, because we don't want to stop the - // execution when folks are not using Trusted Types or when they have - // allowed redeclaration of a security policy. - console.error('Trying to reinject scripts'); - } - loaded = true; - urls = urls.map((s) => chrome.runtime.getURL(s)); - const script = ` - (function () { - let policy = null; - if (window.trustedTypes && window.trustedTypes.createPolicy) { - policy = window.trustedTypes.createPolicy('angular#devtools', { - createScriptURL: url => ${JSON.stringify(urls)}.indexOf(url) >= 0 ? url : null - }); - } - const getScriptName = name => policy ? policy.createScriptURL(name) : name; - ${loadScripts(urls)} - })(); - `; - chrome.devtools.inspectedWindow.eval(script, (_, err) => { - if (err) { - // tslint:disable-next-line:no-console - console.log(err); - } - cb?.(); - }); -}; diff --git a/devtools/projects/shell-browser/src/app/tab_manager.ts b/devtools/projects/shell-browser/src/app/tab_manager.ts new file mode 100644 index 00000000000..e2fdcc24271 --- /dev/null +++ b/devtools/projects/shell-browser/src/app/tab_manager.ts @@ -0,0 +1,245 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/// + +import {Events, Topic} from 'protocol'; + +export interface ContentScriptConnection { + port: chrome.runtime.Port | null; + enabled: boolean; + frameId: 'devtools' | number; + backendReady?: boolean; +} + +export interface DevToolsConnection { + devtools: chrome.runtime.Port | null; + contentScripts: {[name: string]: ContentScriptConnection}; +} + +function isNumeric(str: string): boolean { + return +str + '' === str; +} + +export interface Tabs { + [tabId: string]: DevToolsConnection | undefined; +} + +export class TabManager { + constructor( + private tabs: Tabs, + private runtime: typeof chrome.runtime, + ) {} + + static initialize(tabs: Tabs, runtime: typeof chrome.runtime = chrome.runtime): TabManager { + const manager = new TabManager(tabs, runtime); + manager.initialize(); + return manager; + } + + private initialize(): void { + this.runtime.onConnect.addListener((port) => { + if (isNumeric(port.name)) { + this.registerDevToolsForTab(port); + return; + } + + if ( + !port.sender || + !port.sender.tab || + port.sender.tab.id === undefined || + port.sender.frameId === undefined + ) { + console.warn('Received a connection from an unknown sender', port); + return; + } + + this.registerContentScriptForTab(port); + }); + } + + private ensureTabExists(tabId: number): void { + this.tabs[tabId] ??= { + devtools: null, + contentScripts: {}, + }; + } + + private registerDevToolsForTab(port: chrome.runtime.Port): void { + // For the devtools page, our port name is the tab id. + const tabId = parseInt(port.name, 10); + + this.ensureTabExists(tabId); + const tab = this.tabs[tabId]!; + + tab.devtools = port; + tab.devtools.onDisconnect.addListener(() => { + tab.devtools = null; + + for (const connection of Object.values(tab.contentScripts)) { + connection.enabled = false; + } + }); + + // DevTools may register after the content script has already registered. If that's the case, + // we need to set up the double pipe between the devtools and each content script, and send + // the contentScriptConnected message to the devtools page to inform it of all frames on the page. + for (const [frameId, connection] of Object.entries(tab.contentScripts)) { + if (connection.port === null || connection.backendReady !== true) { + continue; + } + + tab.devtools!.postMessage({ + topic: 'contentScriptConnected', + args: [parseInt(frameId, 10), connection.port.name, connection.port.sender!.url], + }); + this.doublePipe(tab.devtools, connection); + } + } + + private registerContentScriptForTab(port: chrome.runtime.Port): void { + // A content script connection will have a sender and a tab id. + const sender = port.sender!; + const frameId = sender.frameId!; + const tabId = sender.tab!.id!; + + this.ensureTabExists(tabId); + const tab = this.tabs[tabId]!; + + if (tab.contentScripts[frameId] === undefined) { + tab.contentScripts[frameId] = { + port: null, + enabled: false, + frameId: -1, + }; + } + + const contentScript = tab.contentScripts[frameId]!; + contentScript.port = port; + contentScript.frameId = frameId; + contentScript.enabled = contentScript.enabled ?? false; + + // When the content script disconnects, clean up the connection state we're storing in the + // background page. + contentScript.port.onDisconnect.addListener(() => { + delete tab.contentScripts[frameId]; + + if (Object.keys(tab.contentScripts).length === 0) { + delete this.tabs[tabId]; + } + }); + + // Listen for the backendReady message from the content script. This message is sent when the + // content script has loaded the backend bundle and is ready to receive messages from the + // backend. + contentScript.port!.onMessage.addListener((message) => { + if (message.topic === 'backendReady') { + contentScript.backendReady = true; + } + }); + + // If the devtools connection is already established, set up the double pipe between the + // devtools and the content script. + if (tab.devtools) { + this.doublePipe(tab.devtools, tab.contentScripts[frameId]); + } + } + + private doublePipe( + devtoolsPort: chrome.runtime.Port | null, + contentScriptConnection: ContentScriptConnection, + ): void { + if (devtoolsPort === null) { + throw new Error('DevTools port is equal to null'); + } + + const contentScriptPort = contentScriptConnection.port; + + if (contentScriptPort === null) { + throw new Error('Content script port is equal to null'); + } + + // tslint:disable-next-line:no-console + console.log('Creating two-way communication channel', Date.now(), this.tabs); + + const onDevToolsMessage = (message: {topic: Topic; args: Parameters}) => { + if (message.topic === 'enableFrameConnection') { + if (message.args.length !== 2) { + throw new Error('Expected two arguments for enableFrameConnection'); + } + + const [frameId, tabId] = message.args as [frameId: number, tabId: number]; + + if (frameId === contentScriptConnection.frameId) { + const tab = this.tabs[tabId]; + + if (tab === undefined) { + throw new Error(`Expected tab to be registered with tabId ${tabId}`); + } + + for (const frameId of Object.keys(tab.contentScripts)) { + tab.contentScripts[frameId].enabled = false; + } + + contentScriptConnection.enabled = true; + devtoolsPort.postMessage({ + topic: 'frameConnected', + args: [contentScriptConnection.frameId], + }); + } + } + + // Do not allow any message to be sent if a content script is not enabled. This is the + // mechanism that lets us select which content script connection Angular Devtools is connected + // to. + if (!contentScriptConnection.enabled) { + return; + } + + contentScriptPort.postMessage(message); + }; + devtoolsPort.onMessage.addListener(onDevToolsMessage); + + const onContentScriptMessage = (message: {topic: Topic; args: Parameters}) => { + if (message.topic === 'backendReady') { + devtoolsPort.postMessage({ + topic: 'contentScriptConnected', + args: [ + contentScriptConnection.frameId, + contentScriptConnection.port!.name, + contentScriptConnection.port!.sender!.url, + ], + }); + return; + } + + // Do not allow any message to be sent if a content script is not enabled. This is the + // mechanism that lets us select which content script connection Angular Devtools is connected + // to. + if (!contentScriptConnection.enabled) { + return; + } + + devtoolsPort.postMessage(message); + }; + contentScriptPort.onMessage.addListener(onContentScriptMessage); + + const shutdownContentScript = () => { + devtoolsPort.onMessage.removeListener(onDevToolsMessage); + devtoolsPort.postMessage({ + topic: 'contentScriptDisconnected', + args: [contentScriptConnection.frameId, contentScriptConnection.port!.name], + }); + + contentScriptPort.onMessage.removeListener(onContentScriptMessage); + contentScriptPort.disconnect(); + }; + + contentScriptPort.onDisconnect.addListener(() => shutdownContentScript()); + } +} diff --git a/devtools/projects/shell-browser/src/app/tab_manager_spec.ts b/devtools/projects/shell-browser/src/app/tab_manager_spec.ts new file mode 100644 index 00000000000..aa20a74441e --- /dev/null +++ b/devtools/projects/shell-browser/src/app/tab_manager_spec.ts @@ -0,0 +1,355 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {DevToolsConnection, TabManager, Tabs} from './tab_manager'; + +interface MockSender { + url: string; + tab: { + id: number; + }; + frameId: number; +} + +const TEST_MESSAGE_ONE = {topic: 'test', args: ['test1']}; +const TEST_MESSAGE_TWO = {topic: 'test', args: ['test2']}; + +class MockPort { + onMessageListeners: Function[] = []; + onDisconnectListeners: Function[] = []; + messagesPosted: any[] = []; + name: string; + sender?: MockSender; + + constructor( + public properties: { + name: string; + sender?: MockSender; + }, + ) { + this.name = properties.name; + this.sender = properties.sender; + } + + postMessage(message: any) { + this.messagesPosted.push(message); + } + + onMessage = { + addListener: (listener: Function) => { + this.onMessageListeners.push(listener); + }, + }; + + onDisconnect = { + addListener: (listener: Function) => { + this.onDisconnectListeners.push(listener); + }, + }; +} + +function assertArrayHasObj(array: T[], obj: T) { + expect(array).toContain(jasmine.objectContaining(obj as object)); +} + +function assertArrayDoesNotHaveObj(array: T[], obj: T) { + expect(array).not.toContain(jasmine.objectContaining(obj)); +} + +function mockSpyFunction(obj: any, property: string, returnValue: any) { + (obj[property] as any).and.returnValue(() => returnValue); +} + +function mockSpyProperty(obj: any, property: string, value: any) { + (Object.getOwnPropertyDescriptor(obj, property)!.get as any).and.returnValue(value); +} + +describe('Tab Manager - ', () => { + let tabs: Tabs; + const tabId = 12345; + // let devtoolsPort: MockPort; + let chromeRuntime: jasmine.SpyObj; + let tabManager: TabManager; + let tab: DevToolsConnection; + let chromeRuntimeOnConnectListeners: ((port: MockPort) => void)[] = []; + + function connectToChromeRuntime(port: MockPort) { + chromeRuntimeOnConnectListeners.forEach((listener) => listener(port)); + } + + function emitMessageToPort(port: MockPort, message: any) { + port.onMessageListeners.forEach((listener) => listener(message)); + } + + function emitDisconnectToPort(port: MockPort) { + port.onDisconnectListeners.forEach((listener) => listener()); + } + + function createDevToolsPort() { + const port = new MockPort({ + name: tabId.toString(), + }); + connectToChromeRuntime(port); + return port; + } + + beforeEach(() => { + chromeRuntimeOnConnectListeners = []; + chromeRuntime = jasmine.createSpyObj( + 'chrome.runtime', + ['getManifest', 'getURL'], + ['onConnect', 'onDisconnect'], + ); + mockSpyFunction(chromeRuntime, 'getManifest', {manifest_version: 3}); + mockSpyFunction(chromeRuntime, 'getURL', (path: string) => path); + mockSpyProperty(chromeRuntime, 'onConnect', { + addListener: (listener: (port: MockPort) => void) => { + chromeRuntimeOnConnectListeners.push(listener); + }, + }); + }); + + describe('Single Frame', () => { + const testURL = 'http://example.com'; + const contentScriptFrameId = 0; + // let contentPort: MockPort; + + function createContentScriptPort() { + const port = new MockPort({ + name: 'Content Script', + sender: { + url: testURL, + tab: { + id: tabId, + }, + frameId: contentScriptFrameId, + }, + }); + connectToChromeRuntime(port); + return port; + } + + beforeEach(() => { + tabs = {}; + tabManager = TabManager.initialize(tabs, chromeRuntime); + }); + + it('should setup tab object in the tab manager', () => { + const contentScriptPort = createContentScriptPort(); + const devtoolsPort = createDevToolsPort(); + tab = tabs[tabId]!; + + expect(tab).toBeDefined(); + expect(tab!.devtools).toBe(devtoolsPort as unknown as chrome.runtime.Port); + expect(tab!.contentScripts[contentScriptFrameId].port).toBe( + contentScriptPort as unknown as chrome.runtime.Port, + ); + }); + + it('should set frame connection as enabled when an enableFrameConnection message is recieved', () => { + createContentScriptPort(); + const devtoolsPort = createDevToolsPort(); + tab = tabs[tabId]!; + + // Test backendReady and contentScriptConnected messages. + expect(tab?.contentScripts[contentScriptFrameId]?.enabled).toBe(false); + emitMessageToPort(devtoolsPort, { + topic: 'enableFrameConnection', + args: [contentScriptFrameId, tabId], + }); + + expect(tab?.contentScripts[contentScriptFrameId]?.enabled).toBe(true); + assertArrayHasObj(devtoolsPort.messagesPosted, { + topic: 'frameConnected', + args: [contentScriptFrameId], + }); + }); + + it('should pipe messages from the content script and devtools script to each other when the content script frame is enabled', () => { + const contentScriptPort = createContentScriptPort(); + const devtoolsPort = createDevToolsPort(); + + emitMessageToPort(devtoolsPort, { + topic: 'enableFrameConnection', + args: [contentScriptFrameId, tabId], + }); + + // Verify that the double pipe is set up between the content script and the devtools page. + emitMessageToPort(contentScriptPort, TEST_MESSAGE_ONE); + assertArrayHasObj(devtoolsPort.messagesPosted, TEST_MESSAGE_ONE); + assertArrayDoesNotHaveObj(contentScriptPort.messagesPosted, TEST_MESSAGE_ONE); + + emitMessageToPort(devtoolsPort, TEST_MESSAGE_TWO); + assertArrayHasObj(contentScriptPort.messagesPosted, TEST_MESSAGE_TWO); + assertArrayDoesNotHaveObj(devtoolsPort.messagesPosted, TEST_MESSAGE_TWO); + }); + + it('should not pipe messages from the content script and devtools script to each other when the content script frame is disabled', () => { + const contentScriptPort = createContentScriptPort(); + const devtoolsPort = createDevToolsPort(); + + expect(tab?.contentScripts[contentScriptFrameId]?.enabled).toBe(false); + emitMessageToPort(contentScriptPort, TEST_MESSAGE_ONE); + expect(contentScriptPort.messagesPosted.length).toBe(0); + + emitMessageToPort(devtoolsPort, TEST_MESSAGE_TWO); + expect(devtoolsPort.messagesPosted.length).toBe(0); + }); + + it('should set backendReady when the contentPort recieves the backendReady message', () => { + const contentScriptPort = createContentScriptPort(); + const devtoolsPort = createDevToolsPort(); + + emitMessageToPort(devtoolsPort, { + topic: 'enableFrameConnection', + args: [contentScriptFrameId, tabId], + }); + emitMessageToPort(contentScriptPort, {topic: 'backendReady'}); + expect(tab?.contentScripts[contentScriptFrameId]?.backendReady).toBe(true); + assertArrayHasObj(devtoolsPort.messagesPosted, { + topic: 'contentScriptConnected', + args: [contentScriptFrameId, contentScriptPort.name, contentScriptPort.sender!.url], + }); + }); + + it('should set tab.devtools to null when the devtoolsPort disconnects', () => { + const contentScriptPort = createContentScriptPort(); + const devtoolsPort = createDevToolsPort(); + + emitMessageToPort(devtoolsPort, { + topic: 'enableFrameConnection', + args: [contentScriptFrameId, tabId], + }); + expect(tab?.contentScripts[contentScriptFrameId]?.enabled).toBe(true); + + emitDisconnectToPort(devtoolsPort); + expect(tab.devtools).toBeNull(); + expect(tab?.contentScripts[contentScriptFrameId]?.enabled).toBe(false); + }); + }); + + describe('Multiple Frames', () => { + const topLevelFrameId = 0; + const childFrameId = 1; + + function createTopLevelContentScriptPort() { + const port = new MockPort({ + name: 'Top level content script', + sender: { + url: 'TEST_URL', + tab: { + id: tabId, + }, + frameId: topLevelFrameId, + }, + }); + connectToChromeRuntime(port); + return port; + } + function createChildContentScriptPort() { + const port = new MockPort({ + name: 'Child content script', + sender: { + url: 'TEST_URL_2', + tab: { + id: tabId, + }, + frameId: childFrameId, + }, + }); + connectToChromeRuntime(port); + return port; + } + + beforeEach(() => { + tabs = {}; + tabManager = TabManager.initialize(tabs, chromeRuntime); + }); + + it('should setup tab object in the tab manager', () => { + const devtoolsPort = createDevToolsPort(); + const topLevelContentScriptPort = createTopLevelContentScriptPort(); + const childContentScriptPort = createChildContentScriptPort(); + + tab = tabs[tabId]!; + + expect(tab).toBeDefined(); + expect(tab!.devtools).toBe(devtoolsPort as unknown as chrome.runtime.Port); + expect(tab!.contentScripts[topLevelFrameId].port).toBe( + topLevelContentScriptPort as unknown as chrome.runtime.Port, + ); + expect(tab!.contentScripts[childFrameId].port).toBe( + childContentScriptPort as unknown as chrome.runtime.Port, + ); + }); + + it('should setup message and disconnect listeners on devtools and content script ports', () => { + const devtoolsPort = createDevToolsPort(); + const topLevelContentScriptPort = createTopLevelContentScriptPort(); + const childContentScriptPort = createChildContentScriptPort(); + + // 1 listener to clean up tab object if this was the last content script connection. + // 1 listener to cleanup the douple pipe between the content script and the devtools page. + expect(topLevelContentScriptPort.onDisconnectListeners.length).toBe(2); + expect(childContentScriptPort.onDisconnectListeners.length).toBe(2); + + // 1 listener to clean up devtools connection + expect(devtoolsPort.onDisconnectListeners.length).toBe(1); + + // 1 listener set when the content script is registered to check for backendReady. + // 1 listener set when the double pipe is set up between the content script and the devtools page. + expect(topLevelContentScriptPort.onMessageListeners.length).toBe(2); + expect(childContentScriptPort.onMessageListeners.length).toBe(2); + }); + + it('should set the correct frame connection as enabled when an enableFrameConnection message is recieved', () => { + const devtoolsPort = createDevToolsPort(); + createTopLevelContentScriptPort(); + createChildContentScriptPort(); + + expect(tab?.contentScripts[topLevelFrameId]?.enabled).toBe(false); + expect(tab?.contentScripts[childFrameId]?.enabled).toBe(false); + emitMessageToPort(devtoolsPort, { + topic: 'enableFrameConnection', + args: [topLevelFrameId, tabId], + }); + expect(tab?.contentScripts[topLevelFrameId]?.enabled).toBe(true); + expect(tab?.contentScripts[childFrameId]?.enabled).toBe(false); + assertArrayHasObj(devtoolsPort.messagesPosted, { + topic: 'frameConnected', + args: [topLevelFrameId], + }); + assertArrayDoesNotHaveObj(devtoolsPort.messagesPosted, { + topic: 'frameConnected', + args: [childFrameId], + }); + }); + + it('should pipe messages from the correct content script and devtools script when that content script frame is enabled', () => { + const devtoolsPort = createDevToolsPort(); + const topLevelContentScriptPort = createTopLevelContentScriptPort(); + const childContentScriptPort = createChildContentScriptPort(); + + emitMessageToPort(devtoolsPort, { + topic: 'enableFrameConnection', + args: [topLevelFrameId, tabId], + }); + emitMessageToPort(devtoolsPort, TEST_MESSAGE_ONE); + assertArrayHasObj(topLevelContentScriptPort.messagesPosted, TEST_MESSAGE_ONE); + assertArrayDoesNotHaveObj(childContentScriptPort.messagesPosted, TEST_MESSAGE_ONE); + + emitMessageToPort(devtoolsPort, { + topic: 'enableFrameConnection', + args: [childFrameId, tabId], + }); + emitMessageToPort(devtoolsPort, TEST_MESSAGE_TWO); + assertArrayHasObj(childContentScriptPort.messagesPosted, TEST_MESSAGE_TWO); + assertArrayDoesNotHaveObj(topLevelContentScriptPort.messagesPosted, TEST_MESSAGE_TWO); + }); + }); +}); diff --git a/devtools/projects/shell-browser/src/manifest/manifest.chrome.json b/devtools/projects/shell-browser/src/manifest/manifest.chrome.json index f1b6e36cb52..2614b86f9f9 100644 --- a/devtools/projects/shell-browser/src/manifest/manifest.chrome.json +++ b/devtools/projects/shell-browser/src/manifest/manifest.chrome.json @@ -27,8 +27,7 @@ { "resources": [ "app/backend_bundle.js", - "app/detect_angular_for_extension_icon_bundle.js", - "devtools.html" + "app/detect_angular_for_extension_icon_bundle.js" ], "matches": [""], "extension_ids": [] @@ -52,7 +51,18 @@ "js": [ "app/ng_validate_bundle.js" ], - "run_at": "document_idle" + "run_at": "document_idle", + "all_frames": true + }, + { + "matches": [ + "" + ], + "js": [ + "app/content_script_bundle.js" + ], + "run_at": "document_idle", + "all_frames": true } ] } \ No newline at end of file diff --git a/devtools/projects/shell-browser/src/manifest/manifest.firefox.json b/devtools/projects/shell-browser/src/manifest/manifest.firefox.json index d0620740c8e..2e2ac7ce89c 100644 --- a/devtools/projects/shell-browser/src/manifest/manifest.firefox.json +++ b/devtools/projects/shell-browser/src/manifest/manifest.firefox.json @@ -29,13 +29,26 @@ "https://*/*", "file:///*" ], - "content_scripts": [{ + "content_scripts": [ + { "matches": [ "" ], "js": [ "app/ng_validate_bundle.js" ], - "run_at": "document_idle" - }] + "run_at": "document_idle", + "all_frames": true + }, + { + "matches": [ + "" + ], + "js": [ + "app/content_script_bundle.js" + ], + "run_at": "document_idle", + "all_frames": true + } + ] } \ No newline at end of file