Files
angular__angular/devtools/projects/shell-browser/src/app/tab_manager.ts
T

246 lines
7.5 KiB
TypeScript
Raw Normal View History

/**
* @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());
}
}