diff --git a/devtools/projects/protocol/src/lib/messages.ts b/devtools/projects/protocol/src/lib/messages.ts index 5ae4c73a47e..20f3c64a002 100644 --- a/devtools/projects/protocol/src/lib/messages.ts +++ b/devtools/projects/protocol/src/lib/messages.ts @@ -213,19 +213,6 @@ 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 { @@ -286,13 +273,4 @@ 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 06fe4ab97f3..83e207de816 100644 --- a/devtools/projects/shell-browser/src/app/BUILD.bazel +++ b/devtools/projects/shell-browser/src/app/BUILD.bazel @@ -1,8 +1,7 @@ load("@io_bazel_rules_sass//:defs.bzl", "sass_binary") load("//devtools/tools:ng_module.bzl", "ng_module") -load("//devtools/tools:typescript.bzl", "ts_library", "ts_test_library") +load("//devtools/tools:typescript.bzl", "ts_library") load("//tools:defaults.bzl", "esbuild") -load("//devtools/tools:defaults.bzl", "karma_web_test_suite") package(default_visibility = ["//visibility:public"]) @@ -16,6 +15,7 @@ ng_module( srcs = [ "app.component.ts", "app.module.ts", + "inject.ts", ], angular_assets = [ "app.component.html", @@ -35,8 +35,6 @@ ng_module( "//packages/core", "//packages/platform-browser", "//packages/platform-browser/animations", - "@npm//@angular/material", - "@npm//rxjs", ], ) @@ -80,10 +78,8 @@ ts_library( "chrome-application-operations.ts", ], deps = [ - ":chrome_application_environment", "//devtools/projects/ng-devtools", "//devtools/projects/protocol", - "//packages/core", "@npm//@types", ], ) @@ -130,38 +126,7 @@ ts_library( srcs = [ "background.ts", ], - 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", - ], + deps = ["//devtools/projects/shell-browser/src/app:detect_angular_for_extension_icon"], ) ts_library( @@ -197,8 +162,6 @@ 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 0b0be191620..0e6d69fe230 100644 --- a/devtools/projects/shell-browser/src/app/app.component.ts +++ b/devtools/projects/shell-browser/src/app/app.component.ts @@ -6,21 +6,39 @@ * found in the LICENSE file at https://angular.io/license */ -import {ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; +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'; @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 { - private _cd = inject(ChangeDetectorRef); + constructor(private _cd: 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 bc10e6838b9..612efc6889e 100644 --- a/devtools/projects/shell-browser/src/app/app.module.ts +++ b/devtools/projects/shell-browser/src/app/app.module.ts @@ -6,20 +6,17 @@ * found in the LICENSE file at https://angular.io/license */ -import {NgModule, NgZone} from '@angular/core'; -import {MatSelect} from '@angular/material/select'; +import {NgModule} from '@angular/core'; 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, MatSelect], + imports: [BrowserAnimationsModule, DevToolsComponent], bootstrap: [AppComponent], providers: [ { @@ -30,17 +27,6 @@ import {Events, MessageBus, PriorityAwareMessageBus} from 'protocol'; 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 585008ce190..8dadbe5e996 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-${location.href}`, - `angular-devtools-content-script-${location.href}`, + 'angular-devtools-backend', + 'angular-devtools-content-script', ); let initialized = false; @@ -47,6 +47,4 @@ 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 fda02d5a8dd..80b94d32513 100644 --- a/devtools/projects/shell-browser/src/app/background.ts +++ b/devtools/projects/shell-browser/src/app/background.ts @@ -8,10 +8,164 @@ /// -import {AngularDetection} from 'protocol'; -import {TabManager, Tabs} from './tab_manager'; +import {AngularDetection} from './detect-angular-for-extension-icon'; -function getPopUpName(ng: AngularDetection): string { +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) => { if (!ng.isAngular) { return 'not-angular.html'; } @@ -22,63 +176,29 @@ function getPopUpName(ng: AngularDetection): string { return 'production.html'; } return 'supported.html'; -} +}; -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({ +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, - 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`), - }, + 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 f839f15a1b3..dd4708d24e0 100644 --- a/devtools/projects/shell-browser/src/app/chrome-application-environment.ts +++ b/devtools/projects/shell-browser/src/app/chrome-application-environment.ts @@ -11,8 +11,6 @@ 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 bffe57b2a19..729e461f32f 100644 --- a/devtools/projects/shell-browser/src/app/chrome-application-operations.ts +++ b/devtools/projects/shell-browser/src/app/chrome-application-operations.ts @@ -11,29 +11,32 @@ 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, target?: URL): void { - const viewSource = `inspect(inspectedApplication.findConstructorByPosition('${position}', ${directiveIndex}))`; - runInInspectedWindow(viewSource, target); + override viewSource(position: ElementPosition, directiveIndex: number): void { + if (chrome.devtools) { + chrome.devtools.inspectedWindow.eval( + `inspect(inspectedApplication.findConstructorByPosition('${position}', ${directiveIndex}))`, + ); + } } - override selectDomElement(position: ElementPosition, target?: URL): void { - const selectDomElement = `inspect(inspectedApplication.findDomElementByPosition('${position}'))`; - runInInspectedWindow(selectDomElement, target); + override selectDomElement(position: ElementPosition): void { + if (chrome.devtools) { + chrome.devtools.inspectedWindow.eval( + `inspect(inspectedApplication.findDomElementByPosition('${position}'))`, + ); + } } - override inspect(directivePosition: DirectivePosition, objectPath: string[], target?: URL): void { - const args = { - directivePosition, - objectPath, - }; - const inspect = `inspect(inspectedApplication.findPropertyByPosition('${JSON.stringify( - args, - )}'))`; - runInInspectedWindow(inspect, 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)}'))`, + ); + } } } diff --git a/devtools/projects/shell-browser/src/app/content-script.ts b/devtools/projects/shell-browser/src/app/content-script.ts index a9b6ada8d8c..8c2c4cd60f0 100644 --- a/devtools/projects/shell-browser/src/app/content-script.ts +++ b/devtools/projects/shell-browser/src/app/content-script.ts @@ -9,92 +9,59 @@ import {ChromeMessageBus} from './chrome-message-bus'; import {SamePageMessageBus} from './same-page-message-bus'; -let backgroundDisconnected = false; -let backendInstalled = false; -let backendInitialized = false; +export const main = () => { + let backgroundDisconnected = false; + let backendInitialized = false; -const port = chrome.runtime.connect({ - name: `${document.title || location.href}`, -}); + // console.log('Content script executing', (window as any)); -const handleDisconnect = (): void => { - // console.log('Background disconnected', new Date()); - localMessageBus.emit('shutdown'); - localMessageBus.destroy(); - chromeMessageBus.destroy(); - backgroundDisconnected = true; -}; + const port = chrome.runtime.connect({ + name: 'content-script', + }); -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); + const handleDisconnect = (): void => { + // console.log('Background disconnected', new Date()); + localMessageBus.emit('shutdown'); + localMessageBus.destroy(); + chromeMessageBus.destroy(); + backgroundDisconnected = true; }; - retry(); -} + + 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(); + } +}; + +// expose to use as callback for chrome.tabs.executeScript in background.ts +(globalThis as any).main = main; 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 ac91dcd2e83..960ea40bc94 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,7 +6,6 @@ * found in the LICENSE file at https://angular.io/license */ -import {AngularDetection} from 'protocol'; import { appIsAngular, appIsAngularInDevMode, @@ -14,12 +13,18 @@ import { appIsSupportedAngularVersion, } from 'shared-utils'; -import {SamePageMessageBus} from './same-page-message-bus'; - -const detectAngularMessageBus = new SamePageMessageBus( - `angular-devtools-detect-angular-${location.href}`, - `angular-devtools-content-script-${location.href}`, -); +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; +} function detectAngular(win: Window): void { const isAngular = appIsAngular(); @@ -27,29 +32,20 @@ function detectAngular(win: Window): void { const isDebugMode = appIsAngularInDevMode(); const isIvy = appIsAngularIvy(); - 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', [ + win.postMessage( { isIvy, isAngular, isDebugMode, isSupportedAngularVersion, isAngularDevTools: true, - }, - ]); + } as AngularDetection, + '*', + ); - setTimeout(() => detectAngular(win), 1000); + if (!isAngular) { + 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 new file mode 100644 index 00000000000..7d9428f8b4a --- /dev/null +++ b/devtools/projects/shell-browser/src/app/inject.ts @@ -0,0 +1,51 @@ +/** + * @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 deleted file mode 100644 index e2fdcc24271..00000000000 --- a/devtools/projects/shell-browser/src/app/tab_manager.ts +++ /dev/null @@ -1,245 +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 - */ - -/// - -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 deleted file mode 100644 index aa20a74441e..00000000000 --- a/devtools/projects/shell-browser/src/app/tab_manager_spec.ts +++ /dev/null @@ -1,355 +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 - */ - -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 e545c7a80cd..6efe7a2a94b 100644 --- a/devtools/projects/shell-browser/src/manifest/manifest.chrome.json +++ b/devtools/projects/shell-browser/src/manifest/manifest.chrome.json @@ -27,7 +27,8 @@ { "resources": [ "app/backend_bundle.js", - "app/detect_angular_for_extension_icon_bundle.js" + "app/detect_angular_for_extension_icon_bundle.js", + "devtools.html" ], "matches": [""], "extension_ids": [] @@ -51,18 +52,7 @@ "js": [ "app/ng_validate_bundle.js" ], - "run_at": "document_idle", - "all_frames": true - }, - { - "matches": [ - "" - ], - "js": [ - "app/content_script_bundle.js" - ], - "run_at": "document_idle", - "all_frames": true + "run_at": "document_idle" } ] } \ 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 5d5ca3d669e..6ec0062c40d 100644 --- a/devtools/projects/shell-browser/src/manifest/manifest.firefox.json +++ b/devtools/projects/shell-browser/src/manifest/manifest.firefox.json @@ -29,26 +29,13 @@ "https://*/*", "file:///*" ], - "content_scripts": [ - { + "content_scripts": [{ "matches": [ "" ], "js": [ "app/ng_validate_bundle.js" ], - "run_at": "document_idle", - "all_frames": true - }, - { - "matches": [ - "" - ], - "js": [ - "app/content_script_bundle.js" - ], - "run_at": "document_idle", - "all_frames": true - } - ] + "run_at": "document_idle" + }] } \ No newline at end of file