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

This reverts commit dd3dac9cc9.

PR Close #54629
This commit is contained in:
Doug Parker
2024-02-27 09:12:53 -08:00
committed by Dylan Hunn
parent 133319eba0
commit dd9f9d7d44
15 changed files with 357 additions and 902 deletions
@@ -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;
}
@@ -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",
],
@@ -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<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 {
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();
}
}
@@ -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<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-${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');
});
@@ -8,10 +8,164 @@
/// <reference types="chrome"/>
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);
}
},
() => {},
);
}
});
@@ -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;
}
@@ -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)}'))`,
);
}
}
}
@@ -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;
@@ -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);
@@ -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?.();
});
};
@@ -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
*/
/// <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());
}
}
@@ -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<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,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": ["<all_urls>"],
"extension_ids": []
@@ -51,18 +52,7 @@
"js": [
"app/ng_validate_bundle.js"
],
"run_at": "document_idle",
"all_frames": true
},
{
"matches": [
"<all_urls>"
],
"js": [
"app/content_script_bundle.js"
],
"run_at": "document_idle",
"all_frames": true
"run_at": "document_idle"
}
]
}
@@ -29,26 +29,13 @@
"https://*/*",
"file:///*"
],
"content_scripts": [
{
"content_scripts": [{
"matches": [
"<all_urls>"
],
"js": [
"app/ng_validate_bundle.js"
],
"run_at": "document_idle",
"all_frames": true
},
{
"matches": [
"<all_urls>"
],
"js": [
"app/content_script_bundle.js"
],
"run_at": "document_idle",
"all_frames": true
}
]
"run_at": "document_idle"
}]
}