browsh/webext/src/background/manager.js
Thomas Buckley-Houston 889263ac07
Linting
2022-07-25 23:16:46 -04:00

302 lines
9.9 KiB
JavaScript

import _ from "lodash";
import utils from "utils";
import CommonMixin from "background/common_mixin";
import TTYCommandsMixin from "background/tty_commands_mixin";
import Tab from "background/tab";
import Dimensions from "background/dimensions";
// Boots the background process. Mainly involves connecting to the websocket server
// launched by the Browsh CLI client and setting up listeners for new tabs that
// have our webextension content script inside them.
export default class extends utils.mixins(CommonMixin, TTYCommandsMixin) {
constructor() {
super();
this.dimensions = new Dimensions();
// All of the tabs open in the real browser
this.tabs = {};
// The ID of the tab currently opened tab
this.active_tab_id = null;
// When the real GUI browser first launches it's sized to the same size as the desktop
this._is_initial_window_size_pending = true;
// Used so that reconnections to the terminal don't also attempt to reconnect to the
// browser DOM.
this._is_connected_to_browser_dom = false;
// Raw text mode is for when Browsh is running as an HTTP server that serves single
// pages as entire DOMs, in plain text.
this._is_raw_text_mode = false;
// Toggle user agent
this._is_using_mobile_user_agent = false;
this._addUserAgentListener();
// Listen to HTTP requests. This allows us to display some helpful status messages at the
// bottom of the page, eg; "Loading https://coolwebsite.com..."
this._addWebRequestListener();
// The manager is the hub between tabs and the terminal. First we connect to the
// terminal, as that is the process that would have initially booted the browser and
// this very code that now runs.
this._connectToTerminal();
}
_connectToTerminal() {
// This is the websocket server run by the CLI client
this.terminal = new WebSocket("ws://localhost:3334");
this.terminal.addEventListener("open", (_event) => {
this.log("Webextension connected to the terminal's websocket server");
this.dimensions.terminal = this.terminal;
this._listenForTerminalMessages();
this._connectToBrowserDOM();
});
this.terminal.addEventListener("close", (_event) => {
this._reconnectToTerminal();
});
}
// If we've disconnected from the terminal, but we're still running, then that likely
// means the terminal crashed, so we wait to see if the user restarts the terminal.
_reconnectToTerminal() {
try {
this._connectToTerminal();
} catch (_e) {
_.debounce(() => this._reconnectToTerminal(), 50);
}
}
// Mostly listening for forwarded STDIN from the terminal. Therefore, the user
// pressing the arrow keys, typing, moving the mouse, etc, etc. But we also listen
// to TTY resize events too.
_listenForTerminalMessages() {
this.log("Starting to listen to TTY");
this.terminal.addEventListener("message", (event) => {
this.handleTerminalMessage(event.data);
});
}
_connectToBrowserDOM() {
if (!this._is_connected_to_browser_dom) {
this._initialDOMConnection();
} else {
this._reconnectToDOM();
}
}
_initialDOMConnection() {
this._listenForNewTab();
this._listenForTabUpdates();
this._listenForTabChannelOpen();
this._listenForFocussedTab();
}
_reconnectToDOM() {
this.log("Attempting to resend browser state to terminal...");
this.currentTab().sendStateToTerminal();
if (!this._is_raw_text_mode) {
this.sendToCurrentTab("/rebuild_text");
}
}
// For when a tab's content script, triggered by `onDOMContentLoaded`,
// phone's home.
// Curiously `browser.runtime.onMessage` receives the tab's ID, whereas
// `browser.runtime.onConnect` doesn't. So we have to have 2 tab listeners:
// 1. to get the tab ID so we can talk to it later with 2.
// 2. to maintain a long-lived connection to continuously pass messages
// back and forth.
_listenForNewTab() {
browser.runtime.onMessage.addListener(this._newTabHandler.bind(this));
}
// There's what seems to be a bug: tabs can exist and be processed without
// triggering any `browser.tabs.onUpdated` events. Therefore we need to
// manually poll :/
// TODO: Detect deleted tabs to remove the key from `this.tabs[]`
_listenForTabUpdates() {
setInterval(() => {
this._pollAllTabs((native_tab_object) => {
let tab = this._applyUpdates(native_tab_object);
tab.ensureConnectionToBackground();
});
}, 100);
}
_maybeNewTab(tabish_object) {
const tab_id = parseInt(tabish_object.id);
if (this.tabs[tab_id] === undefined) {
let new_tab = new Tab(tabish_object);
this.tabs[tab_id] = new_tab;
}
return this.tabs[tab_id];
}
_handleTabUpdate(_tab_id, changes, native_tab_object) {
this.log(
`Tab ${native_tab_object.id} detected chages: ${JSON.stringify(changes)}`
);
let tab = this.tabs[native_tab_object.id];
tab.native_last_change = changes;
tab.ensureConnectionToBackground();
tab.sendGlobalConfig(this.config);
}
// Note that although this callback signifies that the tab now exists, it is not fully
// booted and functional until it has opened a communication channel. It can't do that
// until it knows its internally represented ID.
_newTabHandler(_request, sender, sendResponse) {
this.log(
`Tab ${sender.tab.id} (${sender.tab.title}) registered with background process`
);
// Send the tab back to itself, such that it can be enlightened unto its own nature
sendResponse(sender.tab);
this._acknowledgeNewTab(sender.tab);
}
_acknowledgeNewTab(native_tab_object) {
let tab = this._applyUpdates(native_tab_object);
tab._is_raw_text_mode = this._is_raw_text_mode;
tab.postDOMLoadInit(this.terminal, this.dimensions);
}
_applyUpdates(tabish_object) {
let tab = this._maybeNewTab({
id: tabish_object.id,
});
[
"id",
"title",
"url",
"active",
"request_id",
"raw_text_mode_type",
"start_time",
].map((key) => {
if (tabish_object.hasOwnProperty(key)) {
tab[key] = tabish_object[key];
}
});
if (tabish_object.active) {
this.active_tab_id = tab.id;
}
return tab;
}
// This is the main communication channel for all back and forth messages to tabs
_listenForTabChannelOpen() {
browser.runtime.onConnect.addListener(
this._tabChannelOpenHandler.bind(this)
);
}
_tabChannelOpenHandler(channel) {
this.log(
`Tab ${channel.name} connected for communication with background process`
);
let tab = this.tabs[parseInt(channel.name)];
tab.postConnectionInit(channel, this.config);
if (!this._is_connected_to_browser_dom) {
this._startFrameRequestLoop();
}
this._is_connected_to_browser_dom = true;
}
_listenForFocussedTab() {
browser.tabs.onActivated.addListener(this._focussedTabHandler.bind(this));
}
_focussedTabHandler(tab) {
this.log(`Tab ${tab.id} received new focus`);
this.active_tab_id = tab.id;
}
_getTabsOnSuccess(windowInfoArray, callback) {
for (let windowInfo of windowInfoArray) {
windowInfo.tabs.map((tab) => {
callback(tab);
});
}
}
_getTabsOnError(error) {
this.log(`Error: ${error}`);
}
_pollAllTabs(callback) {
var getting = browser.windows.getAll({
populate: true,
windowTypes: ["normal"],
});
getting.then(
(windowInfoArray) => this._getTabsOnSuccess(windowInfoArray, callback),
() => this._getTabsOnError(callback)
);
}
// The browser window can only be resized once we have both the character dimensions from
// the browser tab _and the TTY dimensions from the terminal. There's probably a more
// efficient way of triggering this initial window resize, than just waiting for the data
// on every frame tick.
_initialWindowResize() {
if (!this._is_initial_window_size_pending) return;
this.dimensions.resizeBrowserWindow();
this._is_initial_window_size_pending = false;
}
// Instead of having each tab manage its own frame rate, just keep this single, centralised
// heartbeat in the background process that switches automatically to the current active
// tab.
//
// Note that by "frame rate" here we justs mean the rate at which a TTY-sized frame of
// graphics pixles are sent. Larger frames are sent in response to scroll events and
// TTY-sized text frames are sent in response to DOM mutation events.
_startFrameRequestLoop() {
this.log(
"BACKGROUND: Frame loop starting at " +
this.config.tty.small_pixel_frame_rate +
"ms intervals"
);
setInterval(() => {
if (this._is_initial_window_size_pending) this._initialWindowResize();
if (this._isAbleToRequestFrame()) {
this.sendToCurrentTab("/request_frame");
}
}, this.config.tty.small_pixel_frame_rate);
}
_isAbleToRequestFrame() {
if (this._is_raw_text_mode) {
return false;
}
if (!this.dimensions.tty.width || !this.dimensions.tty.height) {
this.log("Not sending frame to TTY without TTY size");
return false;
}
if (!this.tabs.hasOwnProperty(this.active_tab_id)) {
this.log("No active tab, so not requesting a frame");
return false;
}
if (this.currentTab().channel === undefined) {
this.log(
`Active tab ${this.active_tab_id} does not have a channel, so not requesting a frame`
);
return false;
}
return true;
}
// Listen for HTTP activity so we can notify the user that something is loading in the background
_addWebRequestListener() {
browser.webRequest.onBeforeRequest.addListener(
(e) => {
let message;
if (e.type == "main_frame") {
message = `Loading ${e.url}`;
if (this.currentTab() !== undefined) {
this.currentTab().updateStatus("info", message);
}
}
},
{
urls: ["*://*/*"],
},
["blocking"]
);
}
}