@ima/devtools
Version:
IMA.js debugging panel in the Chrome Developer Tools window.
354 lines (309 loc) • 10.5 kB
JavaScript
import { setIcon } from '@/utils';
import { Actions, State } from '@/constants';
const CACHE_SIZE = 2048;
class TabConnection {
constructor(tabId) {
// Init tab ports defaults
this.ports = {
devtools: null,
contentScript: null,
panel: null,
popup: null,
pipeCreated: false,
};
// Initial state
this.tabId = tabId;
this.cache = [];
this.state = State.RELOAD;
this.appData = null;
this.domain = null;
this._emptyListener = null;
this._settingsListener = null;
// Bind listeners
this._aliveCallback = this._aliveCallback.bind(this);
this._settingsCallback = this._settingsCallback.bind(this);
this._cacheMessagesCallback = this._cacheMessagesCallback.bind(this);
this._onDisconnect = this._onDisconnect.bind(this);
}
/**
* Add port to this container. After port is added it runs through initialization process
* and listener assignment based on it's type.
*
* @param {string} name Name of the port identifying it's location in this.ports object.
* @param {chrome.runtime.Port} port Opened chrome runtime port.
*/
addPort(name, port) {
this.ports[name] = port;
switch (name) {
/**
* Revive devtools on alive state so they can create devtools panel.
*/
case 'devtools':
this._reviveDevtools();
break;
/**
* Assign cache listener for caching of messages incoming from content script and
* alive listener, which takes care of initialization and sending app state to other
* tool windows. There's no need to define onDisconnect callback for _cacheMessagesListener
* since it is removed on tab close along with other opened ports anyway.
*/
case 'contentScript':
this.ports.contentScript.onMessage.addListener(this._aliveCallback);
this.ports.contentScript.onMessage.addListener(
this._cacheMessagesCallback
);
// Assign on disconnect listeners, in case contentScript is closed
this.ports.contentScript.onDisconnect.addListener(() => {
this._onDisconnect(
'contentScript',
this._aliveCallback,
this._cacheMessagesCallback
);
});
break;
/**
* Notify popup about current state on opening and assign on disconnect listener.
* Also add listener on settings change to cache enabled value in background script.
*/
case 'popup':
this._notifyPopup();
this.ports.popup.onMessage.addListener(this._settingsCallback);
// Assign on disconnect listeners, in case popup is closed
this.ports.popup.onDisconnect.addListener(() => {
this._onDisconnect('popup', this._settingsCallback);
});
break;
}
/**
* Create bi-directional communication bridge between content script
* and panel if both are already connected.
*/
if (
this.ports.panel &&
this.ports.contentScript &&
!this.ports.pipeCreated
) {
this._createPipe();
}
}
/**
* Send message to all opened ports at the same time. You can use this to let all opened
* tool windows know about some action that happened. For example page reload.
*
* @param {Object} msg Message to sent to all opened ports.
*/
notify(msg) {
this.ports.panel &&
this.ports.panel.onMessage.hasListeners() &&
this.ports.panel.postMessage(msg);
this.ports.devtools &&
this.ports.devtools.onMessage.hasListeners() &&
this.ports.devtools.postMessage(msg);
this.ports.contentScript &&
this.ports.contentScript.onMessage.hasListeners() &&
this.ports.contentScript.postMessage(msg);
this.ports.popup &&
this.ports.popup.onMessage.hasListeners() &&
this.ports.popup.postMessage(msg);
}
/**
* Clear cache and notify all connected ports about reloading.
*
* @param {string} newDomain New domain extracted from url.
*/
reload(newDomain) {
if (newDomain) {
this.domain = newDomain;
}
// Reset cache and notify ports about reloading
this.cache = [];
this.notify({ action: Actions.RELOADING });
}
/**
* Calls disconnect() method on all opened ports.
*/
disconnect() {
this.ports.panel && this.ports.panel.disconnect();
this.ports.devtools && this.ports.devtools.disconnect();
this.ports.contentScript && this.ports.contentScript.disconnect();
this.ports.popup && this.ports.popup.disconnect();
}
/**
* Register listener, which is triggered after all ports that were opened
* are now are closed. Be aware that this does not trigger BEFORE
* any port is registered, which would result to call on init.
*
* @param {function} callback Callback function to execute when last opened port closes.
*/
addOnEmptyListener(callback) {
this._emptyListener = callback;
}
addOnSettingsListener(callback) {
this._settingsListener = callback;
}
/**
* Checks if all tool window ports are closed or not.
*
* @returns {boolean} True if none of the tool window ports are opened.
*/
isEmpty() {
return (
this.ports.panel === null &&
this.ports.devtools === null &&
this.ports.contentScript === null &&
this.ports.popup === null
);
}
/**
* Send current content of cache to devtools panel.
*/
resendCache() {
if (this.cache.length > 0) {
this.cache.forEach(msg => {
this.ports.panel.postMessage(msg);
});
}
}
/**
* Notify popup about current state of the detection, so it can display
* informative messages. In case of alive event we also display appData
* extracted from contentScript.
*
* @private
*/
_notifyPopup() {
this.ports.popup.postMessage({
action: Actions.POPUP,
payload: { state: this.state, appData: this.appData },
});
}
/**
* Re-sends alive message to devtools, so they can create a devtool panel.
* The devtools port is then closed, since after panel is created, it's
* no longer needed for anything else.
*
* @private
*/
_reviveDevtools() {
if (this.state === State.ALIVE) {
this.ports.devtools.postMessage({ action: Actions.ALIVE });
this.ports.devtools.disconnect();
this.ports.devtools = null;
}
}
/**
* Creates bi-directional bridge between contentScript and panel, so both can sent
* messages directly to each other without any additional man in the middle.
*
* @private
*/
_createPipe() {
const resendContentScript = msg => {
if (this.ports.panel !== null) {
this.ports.panel.postMessage(msg);
}
};
const resendPanel = msg => {
this.ports.contentScript.postMessage(msg);
};
const shutdownContentScript = () => {
this._onDisconnect('contentScript', resendContentScript);
this.ports.pipeCreated = false;
};
const shutdownPanel = () => {
this._onDisconnect('panel', resendPanel);
this.ports.pipeCreated = false;
};
// Assign callback functions
this.ports.contentScript.onMessage.addListener(resendContentScript);
this.ports.panel.onMessage.addListener(resendPanel);
this.ports.contentScript.onDisconnect.addListener(shutdownContentScript);
this.ports.panel.onDisconnect.addListener(shutdownPanel);
// Re-send all cached messages from content script to panel on initial creation of connection.
this.resendCache();
this.ports.pipeCreated = true;
}
/**
* Disconnect utility function, which is usually defined on all onDisconnect callbacks.
* It check is given port has any other listeners defined, if not it is closed. In case
* all ports are closed it also calls all registered OnEmpty listeners.
*
* @param {string} name Name identifying port.
* @param {function} listeners Additional array of listeners which are also removed.
* @private
*/
_onDisconnect(name, ...listeners) {
if (!this.ports[name]) {
return;
}
// Assign additional listeners to remove
listeners.forEach(listener =>
this.ports[name].onMessage.removeListener(listener)
);
// Check if given port has any other listeners, if not remove it completely
if (!this.ports[name].onMessage.hasListeners()) {
this.ports[name].disconnect();
this.ports[name] = null;
}
// If all ports are empty, call onDestroy callbacks
if (this.isEmpty()) {
this.cache = [];
if (this._emptyListener) {
this._emptyListener(this.tabId);
}
}
}
_settingsCallback({ action, payload }) {
if (action === Actions.SETTINGS && this._settingsListener) {
this._settingsListener(payload.enabled);
}
}
/**
* Alive listener, which takes care of saving current ima detection states and
* resending them to devtools and popup so they can react accordingly. After IMA
* application is either detected or detection fails, this listener is removed.
*
* @param {string} action Name identifying message intention.
* @param {Object} payload Data from received message.
* @private
*/
_aliveCallback({ action, payload }) {
if (action === Actions.DETECTING) {
this.state = State.DETECTING;
} else if (action === Actions.DEAD) {
this.state = State.DEAD;
} else if (action === Actions.ALIVE) {
this.state = State.ALIVE;
this.appData = { ...payload };
// Light up popup icon
setIcon(State.ALIVE, this.tabId);
// If devtools exist, let them know ima is alive
if (this.ports.devtools) {
this._reviveDevtools();
}
}
// Notify popup about current state if it's opened
if (this.ports.popup) {
this._notifyPopup();
}
// Destroy this callback since it's no longer needed once app is alive or dead
if (this.state === State.ALIVE || this.state === State.DEAD) {
this.ports.contentScript.onMessage.removeListener(this._aliveCallback);
}
}
/**
* This listener caches all received messages from contentScript up to the
* defined CACHE_SIZE. After that it works like queue, where the oldest message
* is removed and new one is added, so the max CACHE_SIZE is maintained.
*
* @param {Object} msg Message to cache.
* @private
*/
_cacheMessagesCallback(msg) {
if (this.cache.length >= CACHE_SIZE) {
this.cache.shift();
}
this.cache.push(msg);
}
}
export { TabConnection, CACHE_SIZE };