From 3806445d8591af2e3b60cca99e0e039cb5c802a1 Mon Sep 17 00:00:00 2001 From: glacambre Date: Sat, 20 Jan 2024 14:54:02 +0100 Subject: [PATCH 1/5] Implement basic proxy providing easy access to tabs from background This commit implements a basic proxy enabling access to arbitrary values/functions in tabs from the background script, like this: tri.tabs[3].document.title.get().then(console.log) tri.tabs[3].document.title.set("New title!").then(console.log) tri.tabs[9].alert.apply("Hello world!") tri.tabs[12].tri.excmds.js.apply("alert('Hello world!')") Ease of implementation was chosen above ease of use. Enabling reading, writing and calling directly through property accesses instead of forcing the use of .get()/.set()/.apply() will be attempted in another commit. --- src/background.ts | 3 +- src/content.ts | 2 ++ src/lib/messaging.ts | 1 + src/lib/omniscient_controller.ts | 38 ++++++++++++++++++++++++ src/lib/tabs.ts | 51 ++++++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 src/lib/omniscient_controller.ts create mode 100644 src/lib/tabs.ts diff --git a/src/background.ts b/src/background.ts index 53f276f6..2f55c40a 100644 --- a/src/background.ts +++ b/src/background.ts @@ -3,7 +3,6 @@ /* tslint:disable:import-spacing */ import * as proxy_background from "@src/lib/browser_proxy_background" - import * as controller from "@src/lib/controller" import * as perf from "@src/perf" import { listenForCounters } from "@src/perf" @@ -30,6 +29,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 +52,7 @@ import * as Proxy from "@src/lib/proxy" R, perf, meta, + tabs: tabsProxy, }) import { HintingCmds } from "@src/background/hinting" diff --git a/src/content.ts b/src/content.ts index 6ca529f6..a3a6385a 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 @@ -124,6 +125,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) 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..fb38bac6 --- /dev/null +++ b/src/lib/tabs.ts @@ -0,0 +1,51 @@ +import * as Messaging from "@src/lib/messaging" + +const allTabs = -1; + +// 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(()=>undefined, { + get(target, p, receiver) { + if (p === Symbol.toPrimitive) { + const prop = `tabs[${tabId}].${props.join(".")}`; + throw `${prop} cannot be used directly - use ${prop}.get() instead`; + } + return tabProxy(tabId, props.concat(p)); + }, + apply(target, thisArg, argArray) { + const last = props[props.length -1]; + switch (last) { + case "get": + case "set": + case "apply": + break; + default: + const call = `tabs[${tabId}].${props.join(".")}`; + const args = `(${argArray.join(", ")})`; + throw `${call}${args} cannot be called directly, use ${call}.apply${args} instead`; + }; + let msg = Messaging.messageAllTabs; + if (tabId !== allTabs) { + msg = (...args) => Messaging.messageTab(tabId, ...args); + } + return msg("omniscient_content", last, [{target: props.slice(0, props.length - 1), value: argArray}]); + }, +}) + +export const tabsProxy = new Proxy(Object.create(null), { + get(target, p, receiver) { + let 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, []) + } + throw "Foreground tabs proxy needs to be indexed by tab ID."; + // Ideally, if p is a string, we should construct a proxy for all + // existing tabs. This unfortunately does not seem to work: + // Messaging.messageAllTab seems to return an array of undefined when + // running e.g. `tabs.document.title`. + // return tabProxy(allTabs, []); + } +}) From 37d841df2caa8b11a447b068fc94e1e8cbe7d2a4 Mon Sep 17 00:00:00 2001 From: glacambre Date: Sat, 20 Jan 2024 16:05:04 +0100 Subject: [PATCH 2/5] Enable accessorless access to tab processes This commit enables accessing content script values directly, like this: (await tri.tabs[3].document.location.href) tri.tabs[6].document.title = "New title!" tri.tabs[9].tri.excmds.js("document.getElementById('blah').textContent").then(console.log) Note that setting values, as shown in the above document.title example above, cannot be synchronously, i.e. there is no way to wait for the value to have been written is the content process before the background process moves on to the next instruction. This is considered okay as writing to the content process synchronously can be done with (await tri.excmds.js("document.title = 'New title!'")) instead. At the moment, accessing all tabs by not specifying an index in tri.tabs is still not supported. --- src/lib/tabs.ts | 103 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 71 insertions(+), 32 deletions(-) diff --git a/src/lib/tabs.ts b/src/lib/tabs.ts index fb38bac6..63ea917a 100644 --- a/src/lib/tabs.ts +++ b/src/lib/tabs.ts @@ -1,51 +1,90 @@ import * as Messaging from "@src/lib/messaging" -const allTabs = -1; +const allTabs = -1 + +// Small wrapper meant to enable sending a message either to a single tab or +// multiple ones. Note that for now, sending messages to all tabs does not +// work, for reasons unknown. +const msg = (tabId, ...args) => { + if (tabId === allTabs) { + return Messaging.messageAllTabs("omniscient_content", ...args) + } 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(()=>undefined, { - get(target, p, receiver) { - if (p === Symbol.toPrimitive) { - const prop = `tabs[${tabId}].${props.join(".")}`; - throw `${prop} cannot be used directly - use ${prop}.get() instead`; - } - return tabProxy(tabId, props.concat(p)); - }, - apply(target, thisArg, argArray) { - const last = props[props.length -1]; - switch (last) { - case "get": - case "set": - case "apply": - break; - default: - const call = `tabs[${tabId}].${props.join(".")}`; - const args = `(${argArray.join(", ")})`; - throw `${call}${args} cannot be called directly, use ${call}.apply${args} instead`; - }; - let msg = Messaging.messageAllTabs; - if (tabId !== allTabs) { - msg = (...args) => Messaging.messageTab(tabId, ...args); - } - return msg("omniscient_content", last, [{target: props.slice(0, props.length - 1), value: argArray}]); - }, -}) +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, receiver) { - let id = Number(p); + 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, []) } - throw "Foreground tabs proxy needs to be indexed by tab ID."; + throw Error("Foreground tabs proxy needs to be indexed by tab ID.") // Ideally, if p is a string, we should construct a proxy for all // existing tabs. This unfortunately does not seem to work: // Messaging.messageAllTab seems to return an array of undefined when // running e.g. `tabs.document.title`. // return tabProxy(allTabs, []); - } + }, }) From a0e4060a9421d81bdf16f68adece422b84476b57 Mon Sep 17 00:00:00 2001 From: glacambre Date: Wed, 24 Jan 2024 08:04:53 +0100 Subject: [PATCH 3/5] excmds.ts: document new `tri.tabs` object enabling access to tab windows --- src/excmds.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/excmds.ts b/src/excmds.ts index d6aeef58..114974c5 100644 --- a/src/excmds.ts +++ b/src/excmds.ts @@ -5884,6 +5884,19 @@ 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[3].document.title = "New title!"` + * - Run `alert()` in all tabs: + * `:jsb browser.tabs.query({}).then(tabs => tabs.forEach(tab => tri.tabs[tab.id].tri.excmds.js('alert()')))` + * + * 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 From 1522b0e2dca4378a98a534b07b09b7e0859f7e2a Mon Sep 17 00:00:00 2001 From: glacambre Date: Fri, 26 Jan 2024 18:56:17 +0100 Subject: [PATCH 4/5] lib/tabs.ts: enable accessing all tabs through `tri.tabs` --- src/excmds.ts | 16 +++++++++++++--- src/lib/tabs.ts | 15 ++++++--------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/excmds.ts b/src/excmds.ts index 114974c5..a76ae610 100644 --- a/src/excmds.ts +++ b/src/excmds.ts @@ -5890,9 +5890,19 @@ export async function js(...str: string[]) { * - 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[3].document.title = "New title!"` - * - Run `alert()` in all tabs: - * `:jsb browser.tabs.query({}).then(tabs => tabs.forEach(tab => tri.tabs[tab.id].tri.excmds.js('alert()')))` + * `: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. diff --git a/src/lib/tabs.ts b/src/lib/tabs.ts index 63ea917a..7339ef4b 100644 --- a/src/lib/tabs.ts +++ b/src/lib/tabs.ts @@ -2,9 +2,6 @@ import * as Messaging from "@src/lib/messaging" const allTabs = -1 -// Small wrapper meant to enable sending a message either to a single tab or -// multiple ones. Note that for now, sending messages to all tabs does not -// work, for reasons unknown. const msg = (tabId, ...args) => { if (tabId === allTabs) { return Messaging.messageAllTabs("omniscient_content", ...args) @@ -80,11 +77,11 @@ export const tabsProxy = new Proxy(Object.create(null), { // for a single tab return tabProxy(id, []) } - throw Error("Foreground tabs proxy needs to be indexed by tab ID.") - // Ideally, if p is a string, we should construct a proxy for all - // existing tabs. This unfortunately does not seem to work: - // Messaging.messageAllTab seems to return an array of undefined when - // running e.g. `tabs.document.title`. - // return tabProxy(allTabs, []); + 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}"`) }, }) From ee540db1939d1af122391f3770109c18b458fbb9 Mon Sep 17 00:00:00 2001 From: glacambre Date: Fri, 26 Jan 2024 19:41:46 +0100 Subject: [PATCH 5/5] Enable accessing background window from content script with tri.bg --- src/background.ts | 2 ++ src/content.ts | 2 ++ src/excmds.ts | 1 + src/lib/tabs.ts | 5 +++++ 4 files changed, 10 insertions(+) diff --git a/src/background.ts b/src/background.ts index 2f55c40a..54d2ec5c 100644 --- a/src/background.ts +++ b/src/background.ts @@ -4,6 +4,7 @@ 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" @@ -259,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 a3a6385a..4e740b42 100644 --- a/src/content.ts +++ b/src/content.ts @@ -96,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") @@ -208,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 a76ae610..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: * diff --git a/src/lib/tabs.ts b/src/lib/tabs.ts index 7339ef4b..3f6d708a 100644 --- a/src/lib/tabs.ts +++ b/src/lib/tabs.ts @@ -1,10 +1,13 @@ 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) } @@ -85,3 +88,5 @@ export const tabsProxy = new Proxy(Object.create(null), { 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, [])