diff --git a/src/background.ts b/src/background.ts index 53f276f6..54d2ec5c 100644 --- a/src/background.ts +++ b/src/background.ts @@ -3,8 +3,8 @@ /* tslint:disable:import-spacing */ import * as proxy_background from "@src/lib/browser_proxy_background" - import * as controller from "@src/lib/controller" +import { omniscient_controller } from "@src/lib/omniscient_controller" import * as perf from "@src/perf" import { listenForCounters } from "@src/perf" import * as messaging from "@src/lib/messaging" @@ -30,6 +30,7 @@ import * as commands from "@src/background/commands" import * as meta from "@src/background/meta" import * as Logging from "@src/lib/logging" import * as Proxy from "@src/lib/proxy" +import { tabsProxy } from "@src/lib/tabs" // Add various useful modules to the window for debugging ;(window as any).tri = Object.assign(Object.create(null), { @@ -52,6 +53,7 @@ import * as Proxy from "@src/lib/proxy" R, perf, meta, + tabs: tabsProxy, }) import { HintingCmds } from "@src/background/hinting" @@ -258,6 +260,7 @@ const messages = { downloadUrlAs: download_background.downloadUrlAs, }, browser_proxy_background: { shim: proxy_background.shim }, + omniscient_background: omniscient_controller, } export type Messages = typeof messages diff --git a/src/content.ts b/src/content.ts index 6ca529f6..4e740b42 100644 --- a/src/content.ts +++ b/src/content.ts @@ -83,6 +83,7 @@ try { } const controller = await import("@src/lib/controller") +const { omniscient_controller } = await import("@src/lib/omniscient_controller") const excmds_content = await import("@src/.excmds_content.generated") const hinting_content = await import("@src/content/hinting") // Hook the keyboard up to the controller @@ -95,6 +96,7 @@ const excmds = await import("@src/.excmds_content.generated") const finding_content = await import("@src/content/finding") const itertools = await import("@src/lib/itertools") const messaging = await import("@src/lib/messaging") +const backgroundProxy = await import("@src/lib/tabs") const State = await import("@src/state") const webext = await import("@src/lib/webext") const perf = await import("@src/perf") @@ -124,6 +126,7 @@ messaging.addListener( "controller_content", messaging.attributeCaller(controller), ) +messaging.addListener("omniscient_content", messaging.attributeCaller(omniscient_controller)) // eslint-disable-next-line @typescript-eslint/require-await messaging.addListener("alive", async () => true) @@ -206,6 +209,7 @@ config.getAsync("preventautofocusjackhammer").then(allowautofocus => { }) ;(window as any).tri = Object.assign(Object.create(null), { browserBg: webext.browserBg, + bg: backgroundProxy.backgroundProxy, commandline_content, convert, config, diff --git a/src/excmds.ts b/src/excmds.ts index d6aeef58..22265f51 100644 --- a/src/excmds.ts +++ b/src/excmds.ts @@ -5853,6 +5853,7 @@ async function js_helper(str: string[]) { * - -r load the js source from a Javascript file relative to your RC file. (NB: will throw an error if no RC file exists) * * Some of Tridactyl's functions are accessible here via the `tri` object. Just do `console.log(tri)` in the web console on the new tab page to see what's available. + * `tri.bg` is an object enabling access to the background script's context. It works similarly to the `tri.tabs` objects documented in the [[jsb]] documentation. * * If you want to pipe an argument to `js`, you need to use the "-p" flag or "-d" flag with an argument and then use the JS_ARG global variable, e.g: * @@ -5884,6 +5885,29 @@ export async function js(...str: string[]) { /** * Lets you execute JavaScript in the background context. All the help from [[js]] applies. Gives you a different `tri` object which has access to more excmds and web-extension APIs. + * + * In `:jsb`, the `tri` object has a special `tabs` property that can be used to access the window object of the corresponding tab by indexing it with the tab ID. Here are a few examples: + * + * - Get the URL of the tab whose id 3: + * `:jsb tri.tabs[3].location.href.then(console.log)` + * - Set the title of the tab whose id is 6: + * `:jsb tri.tabs[6].document.title = "New title!"` + * - Run `alert()` in a tab whose id is 9: + * `:jsb tri.tabs[9].alert()` + * + * You can also directly access the corresonding property in all tabs by using + * the "tabs" object itself, e.g. + * + * - Build a string containing the id of the active element of each tab: + * `:jsb tri.tabs.document.activeElement.id.then(ids => ids.reduce(s, id => s + " " + id))` + * - Scroll all tabs to the tenth pixel: + * `:jsb tri.tabs.document.documentElement.scrollTop = 10` + * - Use tridactyl's JS ex command to perform a complex computation: + * `:jsb tri.tabs.tri.excmds.js("let x = 1; let y = 2; x + y").then(console.log)` + * + * When fetching a value or running a function in a tab through the `tabs` property, the returned value is a Promise and must be awaited. + * Setting values through the `tab` property is asynchronous too and there is no way to await this operation. + * If you need to ensure that the value has been set before performing another action, use tri.tabs[tab.id].tri.excmds.js to set the value instead and await the result. */ /* tslint:disable:no-identical-functions */ //#background diff --git a/src/lib/messaging.ts b/src/lib/messaging.ts index de2946a6..77d7a624 100644 --- a/src/lib/messaging.ts +++ b/src/lib/messaging.ts @@ -9,6 +9,7 @@ export type TabMessageType = | "controller_content" | "commandline_content" | "finding_content" + | "omniscient_content" | "commandline_cmd" | "commandline_frame" | "state" diff --git a/src/lib/omniscient_controller.ts b/src/lib/omniscient_controller.ts new file mode 100644 index 00000000..32d49652 --- /dev/null +++ b/src/lib/omniscient_controller.ts @@ -0,0 +1,38 @@ +export const omniscient_controller = { + set: ({ target, value }) => { + let result + try { + const the_last_one = target[target.length - 1] + const everything_except_the_last_one = target.slice( + 0, + target.length - 1, + ) + const second_to_last = everything_except_the_last_one.reduce( + (acc, prop) => acc[prop], + window, + ) + result = second_to_last[the_last_one] = value + } catch (e) { + console.error(e) + } + return result + }, + get: ({ target, _ }) => { + let result + try { + result = target.reduce((acc, prop) => acc[prop], window) + } catch (e) { + console.error(e) + } + return result + }, + apply: ({ target, value }) => { + let result + try { + result = target.reduce((acc, prop) => acc[prop], window)(...value) + } catch (e) { + console.error(e) + } + return result + }, +} diff --git a/src/lib/tabs.ts b/src/lib/tabs.ts new file mode 100644 index 00000000..3f6d708a --- /dev/null +++ b/src/lib/tabs.ts @@ -0,0 +1,92 @@ +import * as Messaging from "@src/lib/messaging" + +const allTabs = -1 +const background = -2 + +const msg = (tabId, ...args) => { + if (tabId === allTabs) { + return Messaging.messageAllTabs("omniscient_content", ...args) + } else if (tabId === background) { + return (Messaging.message as any)("omniscient_background", args[0], args[1][0]) + } else { + return Messaging.messageTab(tabId, "omniscient_content", ...args) + } +} + +// This function is used to generate proxies. We use a function rather than an +// object created through Object.create(null) in order to make our proxy +// callable. As all proxy calls should be handled through the proxy's "apply" +// function, we make ItWouldBeAMistakeToCallThis throw an error, to make bugs +// as obvious as possible. +const ItWouldBeAMistakeToCallThis = () => { + throw Error("Error, base function ItWouldBeAMistakeToCallThis was called!") +} + +// tabProxy accumulates all accesses to a tab's properties and then, when the +// value of said properties are read, set or called, sends a request to the +// corresponding tab to either retrieve or set the value. +const tabProxy = (tabId, props) => + new Proxy(ItWouldBeAMistakeToCallThis, { + get(target, p) { + if (p === Symbol.toPrimitive) { + // Symbol.toPrimitive will be accessed when the user attempts + // to use a content process value without awaiting it first, + // e.g.: + // + // tri.tabs[3].document.title + "!" + // + // It is a mistake to do this, so we throw an error - see the + // if condition checking for "then" for more details. + throw Error( + `TypeError: tabs[${tabId}].${props.join(".")} is a Promise, you need to await its value.`, + ) + } + if (p === "then") { + // Because we can only access content process values by + // messaging a tab, we can only get values as a promise. We + // take advantage of this fact to wait until we get an access + // to a "then" property before fetching anything - this enables + // rapid traversal of objects instead of having to perform slow + // back and forths for every property. + // + // This works with the "await" keyword too as awaits are turned + // into calls to "then" by the javascript engine. + // One drawback of this approach is that properties named + // + // "then" in the content process cannot be directly accessed. + // We consider this an okay trade-off, as there exists an + // alternative: using tri.tabs[3].eval("x.then") instead. + const promise = msg(tabId, "get", [ + { target: props, value: undefined }, + ]) + return promise.then.bind(promise) + } + return tabProxy(tabId, props.concat(p)) + }, + set(target, p, value) { + msg(tabId, "set", [{ target: props.concat(p), value }]) + return true + }, + apply(target, thisArg, argArray) { + return msg(tabId, "apply", [{ target: props, value: argArray }]) + }, + }) + +export const tabsProxy = new Proxy(Object.create(null), { + get(target, p) { + const id = Number(p) + if (typeof id === "number" && isFinite(id)) { + // We're accessing tabs[i] - meaning that we should return a proxy + // for a single tab + return tabProxy(id, []) + } + if (typeof p === "string") { + // If `p` is a string, then we return a proxy with a sentinel value + // indicating that the request should be sent to all tabs instead. + return tabProxy(allTabs, [])[p] + } + throw new Error(`'tabs' object can only be accessed by a number (e.g. tabs[3]) or a string (e.g. tabs.document or tabs['document']). Type of accessor: "${typeof p}"`) + }, +}) + +export const backgroundProxy = tabProxy(background, [])