refactor(devtools): implement iframe support for Angular DevTools' browser code (#53934)

Modifies the messaging layer of devtools to allow for switching communication between frames on a page. When served as a browser extension.

Design:
- When a page renders, DevTools installs a content script onto it through it's manifest file. The all_frames option is used here to install this script onto every frame in a page.
- When Angular is detected, the content script will install a backend script into it's frame.
- Each content script / backend script pairing is kept track of in the background script. This pairing represents an angular devtools context in a particular frame.
- Angular DevTools is able to ask the background script to list each frame that has been registered on a page.
- Angular Devtools is able to ask the background script to "enable" the connection on a particular frame. This enables the messaging between the content script <-> background script <-> devtools page

Limitations:
- The `inspectedWindow.eval` API is only able to target frames by frameURL. This means some features that integrate with Chrome DevTools like inspect element and open source will not be available when inspecting frames that do not have a unique url on the page.

PR Close #53934
This commit is contained in:
AleksanderBodurri
2024-01-25 19:31:57 -05:00
committed by Andrew Kushnir
parent fba7fe175c
commit 02fcd928b2
15 changed files with 903 additions and 358 deletions
@@ -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;
}
@@ -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",
],
@@ -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<Events> {
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();
}
}
@@ -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<Events> {
const port = chrome.runtime.connect({
name: '' + chrome.devtools.inspectedWindow.tabId,
});
return new PriorityAwareMessageBus(new ZoneAwareChromeMessageBus(port, ngZone));
},
deps: [NgZone],
},
],
})
export class AppModule {}
@@ -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');
});
@@ -8,164 +8,10 @@
/// <reference types="chrome"/>
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);
}
@@ -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;
}
@@ -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);
}
}
@@ -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();
}
@@ -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);
@@ -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?.();
});
};
@@ -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
*/
/// <reference types="chrome"/>
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<Events[Topic]>}) => {
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<Events[Topic]>}) => {
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());
}
}
@@ -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<T>(array: T[], obj: T) {
expect(array).toContain(jasmine.objectContaining(obj as object));
}
function assertArrayDoesNotHaveObj<T extends object>(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<typeof chrome.runtime>;
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);
});
});
});
@@ -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": ["<all_urls>"],
"extension_ids": []
@@ -52,7 +51,18 @@
"js": [
"app/ng_validate_bundle.js"
],
"run_at": "document_idle"
"run_at": "document_idle",
"all_frames": true
},
{
"matches": [
"<all_urls>"
],
"js": [
"app/content_script_bundle.js"
],
"run_at": "document_idle",
"all_frames": true
}
]
}
@@ -29,13 +29,26 @@
"https://*/*",
"file:///*"
],
"content_scripts": [{
"content_scripts": [
{
"matches": [
"<all_urls>"
],
"js": [
"app/ng_validate_bundle.js"
],
"run_at": "document_idle"
}]
"run_at": "document_idle",
"all_frames": true
},
{
"matches": [
"<all_urls>"
],
"js": [
"app/content_script_bundle.js"
],
"run_at": "document_idle",
"all_frames": true
}
]
}