Fix #780: support connection-based native messenger

This commit is contained in:
Oliver Blanthorn 2026-07-31 14:00:42 +02:00
parent 9c8c15ff77
commit fe2cd487cd
No known key found for this signature in database
GPG key ID: 2BB8C36BB504BFF3
15 changed files with 947 additions and 30 deletions

View file

@ -1 +1 @@
0.5.0
0.6.0

View file

@ -54,6 +54,8 @@ Tridactyl stable can be installed from the [Mozilla add-ons website (the AMO)][a
If you want to use advanced features such as edit-in-Vim, you'll also need to install the native messenger or executable, instructions for which can be found by typing `:nativeinstall` and hitting enter once you are in Tridactyl. Arch users can install the [AUR package](https://aur.archlinux.org/packages/firefox-tridactyl-native/) `firefox-tridactyl-native` instead.
Native messenger 0.6.0 and newer can also send commands to Tridactyl. This is disabled by default. Enable it in Firefox with `:set nativecontrol true`, then run the installed `native_main --request 'EX-COMMAND'`. See the [native messenger README](https://github.com/tridactyl/native_messenger) for more details.
#### Containerized/sandboxed Firefox Installations
**Snap and Flatpak:** Native Messaging support here is fairly recent and may require:

View file

@ -105,6 +105,7 @@ def content(lines, context):
"excmd_content",
"{sig.name}",
[{message_params}],
controller.getCurrentExCmdSource(),
)
}}\n""".format(**locals()))
else:

View file

@ -276,6 +276,39 @@ const messages = {
export type Messages = typeof messages
messaging.setupListener(messages)
let nativeControl: ReturnType<typeof native.createNativeControl>
let nativeControlGeneration = 0
async function configureNativeControl(enabled: "true" | "false") {
const generation = ++nativeControlGeneration
nativeControl?.stop()
nativeControl = undefined
if (enabled !== "true") return
const control = native.createNativeControl({
enabled: true,
dispatchExCmd: command => controller.acceptExCmd(command, "native"),
})
nativeControl = control
try {
await control.start()
} catch (_) {
control.stop()
if (nativeControl === control) nativeControl = undefined
}
if (generation !== nativeControlGeneration) control.stop()
}
void config
.getAsync()
.then(() =>
configureNativeControl(
config.USERCONFIG.nativecontrol ?? config.DEFAULTS.nativecontrol,
),
)
.catch(() => undefined)
config.addChangeListener("nativecontrol", (_, enabled) => {
void configureNativeControl(enabled)
})
// Listen for statistics from the background script and store
// them. Set this one up to log directly to the statsLogger instead of
// going through messaging.

View file

@ -25,8 +25,12 @@ test("runRc updates and saves versioned config", async () => {
await config.set(key, value)
})
await runRc("set configversion 1.0\nset vimium-gi false")
await runRc("set configversion 1.0\nset vimium-gi false", "native")
expect(controller.acceptExCmd).toHaveBeenCalledWith(
"set configversion 1.0",
"native",
)
expect(browser.storage.local.set).toHaveBeenLastCalledWith(
expect.objectContaining({
userconfig: expect.objectContaining({

View file

@ -2,7 +2,9 @@ import * as controller from "@src/lib/controller"
import * as config from "@src/lib/config"
import * as Native from "@src/lib/native"
export async function source(filename = "auto") {
type ExCmdSource = Parameters<typeof controller.acceptExCmd>[1]
export async function source(filename = "auto", commandSource?: ExCmdSource) {
let rctext = ""
if (filename === "auto") {
rctext = await Native.getrc()
@ -10,7 +12,7 @@ export async function source(filename = "auto") {
rctext = (await Native.read(filename)).content
}
if (rctext === undefined) return false
await runRc(rctext)
await runRc(rctext, commandSource)
return true
}
@ -30,10 +32,10 @@ export async function fetchText(url: string) {
}
const fetchConfig = fetchText
export async function sourceFromUrl(url: string) {
export async function sourceFromUrl(url: string, commandSource?: ExCmdSource) {
const rctext = await fetchConfig(url)
if (!rctext) return false
await runRc(rctext)
await runRc(rctext, commandSource)
return true
}
@ -52,9 +54,9 @@ export async function writeRc(conf: string, force = false, filename = "auto") {
return await Native.writerc(path, force, conf)
}
export async function runRc(rc: string) {
export async function runRc(rc: string, commandSource?: ExCmdSource) {
for (const cmd of rcFileToExCmds(rc)) {
await controller.acceptExCmd(cmd)
await controller.acceptExCmd(cmd, commandSource)
}
// Sourced commands have already been saved to the current local config.
await config.update(true)

View file

@ -92,7 +92,9 @@ addContentStateChangedListener((property, _mode, oldValue, newValue) => {
})
messaging.addListener(
"excmd_content",
messaging.attributeCaller(excmds_content),
messaging.attributeCaller(excmds_content, (handler, args, message) =>
controller.invokeExCmd(handler, args, message.excmdSource),
),
)
messaging.addListener(
"controller_content",

View file

@ -4,6 +4,7 @@ import * as config from "@src/lib/config"
import * as Native from "@src/lib/native"
import * as Messaging from "@src/lib/messaging"
import * as DOM from "@src/lib/dom"
import * as Controller from "@src/lib/controller"
import state from "@src/state"
jest.mock("@src/lib/webext", () => ({
@ -22,11 +23,14 @@ jest.mock("@src/background/config_rc")
jest.mock("@src/lib/native", () => ({
...jest.requireActual("@src/lib/native"),
disconnectNativeControls: jest.fn(),
ff_cmdline: jest.fn(),
getrcpath: jest.fn(),
nativegate: jest.fn(),
read: jest.fn(),
reconnectNativeControls: jest.fn(),
run: jest.fn(),
runAsync: jest.fn(),
}))
jest.mock("@src/lib/containers", () => ({
@ -246,9 +250,69 @@ test("`:native` reports native messaging errors", async () => {
"excmd_content",
"fillcmdline",
[expect.stringMatching(/nativeinstall.*native_main\.py/)],
undefined,
)
})
test("`:native` reconnects native control after a successful check", async () => {
const reconnect = jest.mocked(Native.reconnectNativeControls).mockClear()
jest.mocked(browser.runtime.sendNativeMessage).mockResolvedValueOnce({
version: "0.6.0",
})
await backgroundExcmds.native()
expect(reconnect).toHaveBeenCalledWith(true)
})
test("native composite execution propagates command errors", async () => {
const composite = backgroundExcmds.composite
await expect(
Controller.invokeExCmd(composite, ["not-a-real-excmd"], "native"),
).rejects.toThrow("Not an excmd")
})
test("native source is forwarded to content command shims", async () => {
const messageActiveTab = jest.mocked(Messaging.messageActiveTab).mockClear()
await Controller.invokeExCmd(
backgroundExcmds.rssexec,
["https://example.com/feed"],
"native",
)
expect(messageActiveTab).toHaveBeenCalledWith(
"excmd_content",
"rssexec",
["https://example.com/feed", undefined],
"native",
)
})
test("`updatenative` leaves control released while an asynchronous installer runs", async () => {
jest.mocked(browser.runtime.getPlatformInfo).mockResolvedValue({
arch: "x86-64",
os: "linux",
})
jest.mocked(Native.nativegate).mockResolvedValue(true)
jest.mocked(browser.runtime.sendNativeMessage).mockResolvedValue({
version: "0.5.0",
})
const disconnect = jest.mocked(Native.disconnectNativeControls).mockClear()
const reconnect = jest.mocked(Native.reconnectNativeControls).mockClear()
const runAsync = jest.mocked(Native.runAsync).mockResolvedValue()
await backgroundExcmds.updatenative(false)
expect(disconnect).toHaveBeenCalledTimes(1)
expect(runAsync).toHaveBeenCalledTimes(1)
expect(disconnect.mock.invocationCallOrder[0]).toBeLessThan(
runAsync.mock.invocationCallOrder[0],
)
expect(reconnect).not.toHaveBeenCalled()
})
test.each(["mktridactylrc", "source"])(
"`%s` rejects without native",
async command => {

View file

@ -243,6 +243,7 @@ export async function getRssLinks(): Promise<Array<{ type: string; url: string;
*/
//#content
export async function rssexec(url: string, type?: string, ...title: string[]) {
const source = controller.getCurrentExCmdSource()
if (!url || url === "") {
const links = await getRssLinks()
switch (links.length) {
@ -268,7 +269,7 @@ export async function rssexec(url: string, type?: string, ...title: string[]) {
rsscmd += " " + url
}
// Need actual excmd parsing here.
return controller.acceptExCmd(rsscmd)
return controller.acceptExCmd(rsscmd, source)
}
/**
@ -788,6 +789,7 @@ export async function native() {
}
let done
if (version !== undefined) {
Native.reconnectNativeControls(true)
done = fillcmdline("# Native messenger is correctly installed, version " + version)
} else {
done = fillcmdline("# Native messenger not found. Please run `:nativeinstall` and follow the instructions.")
@ -902,20 +904,21 @@ export async function mktridactylrc(...args: string[]) {
*/
//#background
export async function source(...args: string[]) {
const commandSource = controller.getCurrentExCmdSource()
if (args[0] === "--url") {
let url = args[1]
if (!url || url === "%") url = window.location.href
if (!new RegExp("^(https?://)|data:").test(url)) url = "http://" + url
await rc.sourceFromUrl(url)
await rc.sourceFromUrl(url, commandSource)
} else if (args[0] === "--strings") {
await rc.runRc(args.slice(1).join(" "))
await rc.runRc(args.slice(1).join(" "), commandSource)
} else if (args[0] === "--clipboard") {
const text = await getclip()
await rc.runRc(text)
await rc.runRc(text, commandSource)
} else {
const file = args.join(" ") || undefined
if (!(await Native.nativegate("0.1.3", false))) throw new Error("`:source` requires the native messenger for local files. Run `:nativeinstall` or use `:source --clipboard`.")
if (!(await rc.source(file))) {
if (!(await rc.source(file, commandSource))) {
logger.error("Could not find RC file")
}
}
@ -942,7 +945,9 @@ export async function source_quiet(...args: string[]) {
export async function updatenative(interactive = true) {
if (!(await Native.nativegate("0", interactive))) {
return
} else if ((await browser.runtime.getPlatformInfo()).os === "mac") {
}
const platform = await browser.runtime.getPlatformInfo()
if (platform.os === "mac") {
if (interactive) logger.error("Updating the native messenger on OSX is broken. Please use `:nativeinstall` instead.")
return
}
@ -952,14 +957,25 @@ export async function updatenative(interactive = true) {
const native_version = await Native.getNativeMessengerVersion()
if (semverCompare(native_version, "0.2.0") < 0) {
await Native.run(update_command)
Native.disconnectNativeControls()
try {
await Native.run(update_command)
} finally {
Native.reconnectNativeControls()
}
} else if (semverCompare(native_version, "0.3.1") < 0) {
if (interactive) {
throw new Error("Updating is broken on this version of the native messenger. Please use `:nativeinstall` instead.")
}
return
} else {
await Native.runAsync(update_command)
Native.disconnectNativeControls()
try {
await Native.runAsync(update_command)
} catch (error) {
Native.reconnectNativeControls()
throw error
}
if (interactive) await fillcmdline("# Native messenger update started. Please wait a few seconds, then run `:native` to check whether it succeeded.")
}
}
@ -2524,6 +2540,7 @@ if (fullscreenApiIsPrefixed) {
/** @hidden */
//#content
export async function loadaucmds(cmdType: "DocStart" | "DocLoad" | "DocEnd" | "DocFocus" | "DocBlur" | "TabEnter" | "TabLeft" | "FullscreenEnter" | "FullscreenLeft" | "FullscreenChange" | "UriChange" | "HistoryState" | "ModeEnter" | "ModeLeave", target?: string) {
const source = controller.getCurrentExCmdSource()
const aucmds = await config.getAsync("autocmds", cmdType)
if (!aucmds) return
const ausites = Object.keys(aucmds)
@ -2557,7 +2574,7 @@ export async function loadaucmds(cmdType: "DocStart" | "DocLoad" | "DocEnd" | "D
}
try {
autocmd_logger.debug(`${cmdType} matched ${aukey}: ${aucmds[aukey]}`)
await controller.acceptExCmd(aucmds[aukey])
await controller.acceptExCmd(aucmds[aukey], source)
} catch (e) {
autocmd_logger.error((e as Error).toString())
}
@ -4070,11 +4087,12 @@ async function getnexttabs(tabid: number, n?: number) {
*/
//#background
export async function repeat(n = 1, ...exstr: string[]) {
const source = controller.getCurrentExCmdSource()
let cmd = state.last_ex_str
if (exstr.length > 0) cmd = exstr.join(" ")
logger.debug("repeating " + cmd + " " + n + " times")
for (let i = 0; i < n; i++) {
await controller.acceptExCmd(cmd)
await controller.acceptExCmd(cmd, source)
}
}
@ -4093,8 +4111,9 @@ export async function repeat(n = 1, ...exstr: string[]) {
*/
//#both
export async function composite(...cmds: string[]) {
const source = controller.getCurrentExCmdSource()
try {
return (
return await (
cmds
.join(" ")
// Semicolons delimit pipelines
@ -4115,17 +4134,22 @@ export async function composite(...cmds: string[]) {
// nonsense. So we copy-paste the important
// parts of the body of that function instead.
const [fn, args] = excmd_parser.parser(cmds[0], ALL_EXCMDS)
const first_value = fn.call({}, ...args)
const first_value = controller.invokeExCmd(fn, args, source)
// Exec the rest of the pipe in sequence.
return cmds.slice(1).reduce(async (pipedValue, cmd) => {
const [fn, args] = excmd_parser.parser(cmd, ALL_EXCMDS)
return fn.call({}, ...args, await pipedValue)
return controller.invokeExCmd(
fn,
[...args, await pipedValue],
source,
)
}, first_value)
}, null as any)
)
} catch (e) {
logger.error(e)
if (source === "native") throw e
}
}

View file

@ -1217,6 +1217,12 @@ export class default_config {
nativeinstallcmd =
"curl -fsSl https://raw.githubusercontent.com/tridactyl/native_messenger/master/installers/install.sh -o /tmp/trinativeinstall.sh && sh /tmp/trinativeinstall.sh %TAG"
/**
* Allow the native messenger to control Tridactyl via `native_main --request [ex script]`.
* Requires [[native]] to be 0.6.0 or higher.
*/
nativecontrol: "true" | "false" = "false"
/**
* Used by :updatecheck and related built-in functionality to automatically check for updates and prompt users to upgrade.
*/

View file

@ -5,7 +5,7 @@ import * as State from "@src/state"
const logger = new Logger("controller")
type ExCmdSource = "commandline" | "content"
type ExCmdSource = "commandline" | "content" | "native"
let currentExCmdSource: ExCmdSource
let exCmdListener: () => void
@ -22,6 +22,20 @@ export function getCurrentExCmdSource() {
return currentExCmdSource
}
export function invokeExCmd(
func: (...args: any[]) => any,
args: any[],
source?: ExCmdSource,
) {
const previousExCmdSource = currentExCmdSource
currentExCmdSource = source || previousExCmdSource
try {
return func(...args)
} finally {
currentExCmdSource = previousExCmdSource
}
}
/** Resolve an ExCmd for direct invocation without changing repeat state. */
export function resolveExCmd(exstr: string) {
const [func, args] = exmode_parser(exstr, stored_excmds)
@ -36,8 +50,7 @@ export function resolveExCmd(exstr: string) {
/** Parse and execute ExCmds */
export async function acceptExCmd(exstr: string, source?: ExCmdSource): Promise<any> {
const previousExCmdSource = currentExCmdSource
currentExCmdSource = source || previousExCmdSource
const effectiveSource = source || currentExCmdSource
let lastExUpdate = Promise.resolve()
// TODO: Errors should go to CommandLine.
try {
@ -61,16 +74,18 @@ export async function acceptExCmd(exstr: string, source?: ExCmdSource): Promise<
})
}
try {
return await func(...args)
const result = invokeExCmd(func, args, effectiveSource)
return await result
} catch (e) {
// Errors from func are caught here (e.g. no next tab)
logger.error("controller in excmd: ", e)
if (effectiveSource === "native") throw e
}
} catch (e) {
// Errors from parser caught here
logger.error("controller while accepting: ", e)
if (effectiveSource === "native") throw e
} finally {
currentExCmdSource = previousExCmdSource
void lastExUpdate.then(
() => exCmdListener?.(),
() => exCmdListener?.(),

View file

@ -0,0 +1,79 @@
import { acceptExCmd, setExCmds } from "@src/lib/controller"
jest.mock("@src/lib/config", () => ({
get: jest.fn((key: string) => (key === "repeatblacklist" ? [] : {})),
}))
jest.mock("@src/parsers/exmode", () => ({ parser: jest.fn() }))
jest.mock("@src/state", () => ({
getAsync: jest.fn().mockResolvedValue(undefined),
setAsync: jest.fn().mockResolvedValue(undefined),
}))
const parser: jest.Mock = jest.requireMock("@src/parsers/exmode").parser
const acceptNativeExCmd = (excmd: string) =>
(
acceptExCmd as unknown as (
excmd: string,
source: "native",
) => Promise<unknown>
)(excmd, "native")
beforeEach(() => {
parser.mockReset()
setExCmds({ "": {} })
})
test("native execution propagates parser errors", async () => {
const error = new Error("parse failed")
parser.mockImplementation(() => {
throw error
})
await expect(acceptNativeExCmd("invalid")).rejects.toBe(error)
})
test("native execution propagates command errors", async () => {
const error = new Error("command failed")
parser.mockReturnValue([jest.fn().mockRejectedValue(error), []])
await expect(acceptNativeExCmd("boom")).rejects.toBe(error)
})
test("nested native execution propagates errors", async () => {
const error = new Error("nested command failed")
parser
.mockReturnValueOnce([() => acceptExCmd("nested"), []])
.mockReturnValueOnce([jest.fn().mockRejectedValue(error), []])
await expect(acceptNativeExCmd("outer")).rejects.toBe(error)
})
test("overlapping execution does not inherit the native source", async () => {
let finish!: () => void
const pending = new Promise<void>(resolve => (finish = resolve))
parser
.mockReturnValueOnce([jest.fn().mockReturnValue(pending), []])
.mockReturnValueOnce([
jest.fn().mockRejectedValue(new Error("interactive failure")),
[],
])
const native = acceptNativeExCmd("pending")
await expect(acceptExCmd("interactive")).resolves.toBeUndefined()
finish()
await expect(native).resolves.toBeUndefined()
})
test("interactive execution continues to swallow parser and command errors", async () => {
parser
.mockImplementationOnce(() => {
throw new Error("parse failed")
})
.mockReturnValueOnce([
jest.fn().mockRejectedValue(new Error("command failed")),
[],
])
await expect(acceptExCmd("invalid", "commandline")).resolves.toBeUndefined()
await expect(acceptExCmd("boom", "content")).resolves.toBeUndefined()
})

View file

@ -33,6 +33,7 @@ export type MessageType = TabMessageType | NonTabMessageType
export interface Message {
[key: string]: any
type: MessageType
excmdSource?: "commandline" | "content" | "native"
// and other unknown attributes...
}
@ -43,7 +44,14 @@ export type listener = (
) => void | Promise<any>
// Calls methods on obj that match .command and sends responses back
export function attributeCaller(obj) {
export function attributeCaller(
obj,
invoke?: (
handler: (...args: any[]) => any,
args: any[],
message: Message,
) => any,
) {
function handler(message: Message, sender, sendResponse) {
logger.debug(message)
@ -52,7 +60,10 @@ export function attributeCaller(obj) {
// Call command on obj
try {
const response = obj[message.command](...message.args)
const handler = obj[message.command]
const response = invoke
? invoke(handler, message.args, message)
: handler.apply(obj, message.args)
// Return response to sender
if (response instanceof Promise) {
@ -155,8 +166,9 @@ export async function messageActiveTab(
type: TabMessageType,
command?: string,
args?: any[],
excmdSource?: Message["excmdSource"],
) {
return messageTab(await activeTabId(), type, command, args)
return messageTab(await activeTabId(), type, command, args, excmdSource)
}
export async function messageTab(
@ -164,12 +176,14 @@ export async function messageTab(
type: TabMessageType,
command?,
args?,
excmdSource?: Message["excmdSource"],
): Promise<any> {
const message: Message = {
type,
command,
args,
}
if (excmdSource !== undefined) message.excmdSource = excmdSource
return browserBg.tabs.sendMessage(tabId, message)
}

View file

@ -33,6 +33,280 @@ interface MessageResp {
content: string | null
code?: number | null
error?: string | null
capabilities?: string[]
}
const CONTROL_PROTOCOL = 1
const CONTROL_CAPABILITY = "control-port-v1"
const MAX_CONTROL_RESPONSE_BYTES = 900_000
function utf8Length(value: string) {
let length = 0
for (const character of value) {
const codepoint = character.codePointAt(0)
length +=
codepoint <= 0x7f
? 1
: codepoint <= 0x7ff
? 2
: codepoint <= 0xffff
? 3
: 4
}
return length
}
interface NativeControl {
start(): Promise<void>
reprobe(): Promise<void>
disconnect(reprobe?: boolean): void
stop(): void
}
const nativeControls = new Set<NativeControl>()
let nativeControlsSuspended = false
export function disconnectNativeControls() {
nativeControlsSuspended = true
nativeControls.forEach(control => control.disconnect(true))
}
export function reconnectNativeControls(reprobe = false) {
nativeControlsSuspended = false
nativeControls.forEach(control => {
void (reprobe ? control.reprobe() : control.start())
})
}
export function createNativeControl({
enabled = false,
dispatchExCmd,
}: {
enabled?: boolean
dispatchExCmd: (excmd: string) => Promise<unknown>
}) {
let port: browser.runtime.Port | undefined
let starting: Promise<void> | undefined
let startingSession = 0
let supported: boolean | undefined
let stopped = false
let session = 0
let busy = false
const pending = new Set<string>()
const respond = (target: browser.runtime.Port, response: object) => {
const payload = {
type: "control.response",
protocol: CONTROL_PROTOCOL,
...response,
}
try {
if (
utf8Length(JSON.stringify(payload)) > MAX_CONTROL_RESPONSE_BYTES
)
throw new Error("control response is too large")
target.postMessage(payload)
} catch (error) {
try {
target.postMessage({
type: "control.response",
protocol: CONTROL_PROTOCOL,
id: (response as any).id,
ok: false,
error: "control response is not serializable",
})
} catch (_) {
logger.error("Failed to answer native control request", error)
}
}
}
const onMessage = async (
target: browser.runtime.Port,
targetSession: number,
message: any,
) => {
if (message?.type === "control.handshake") {
if (
message.protocol !== CONTROL_PROTOCOL ||
message.enabled !== true
) {
logger.error(
"Native control handshake failed",
message.error || "invalid response",
)
if (port === target) {
port = undefined
session++
}
target.disconnect()
}
return
}
if (message?.type !== "control.request") return
if (stopped || port !== target || session !== targetSession) return
if (typeof message.id !== "string") return
if (message.protocol !== CONTROL_PROTOCOL)
return respond(target, {
id: message.id,
ok: false,
error: "unsupported control protocol",
})
if (message.operation !== "ex")
return respond(target, {
id: message.id,
ok: false,
error: "unsupported control operation",
})
if (typeof message.command !== "string")
return respond(target, {
id: message.id,
ok: false,
error: "control command must be a string",
})
if (pending.has(message.id))
return respond(target, {
id: message.id,
ok: false,
error: "duplicate control request",
})
if (busy)
return respond(target, {
id: message.id,
ok: false,
error: "native control is busy",
})
pending.add(message.id)
busy = true
try {
const result = await dispatchExCmd(message.command)
let serializableResult: unknown
try {
const serialized =
result === undefined ? undefined : JSON.stringify(result)
serializableResult =
serialized === undefined
? undefined
: JSON.parse(serialized)
} catch (_) {
return respond(target, {
id: message.id,
ok: false,
error: "control result is not serializable",
})
}
respond(target, {
id: message.id,
ok: true,
result: serializableResult,
})
} catch (error) {
respond(target, {
id: message.id,
ok: false,
error: (error instanceof Error
? error.message
: String(error)
).slice(0, 4096),
})
} finally {
pending.delete(message.id)
busy = false
}
}
const start = async (): Promise<void> => {
if (!enabled || stopped || port) return
nativeControls.add(control)
if (nativeControlsSuspended) return
const requestedSession = session
if (starting !== undefined) {
const activeSession = startingSession
await starting
if (
requestedSession !== activeSession &&
session === requestedSession &&
!stopped &&
!port &&
supported !== false
)
return start()
return
}
const targetSession = session
startingSession = targetSession
starting = (async () => {
if (supported === undefined) {
try {
const response = (await browser.runtime.sendNativeMessage(
NATIVE_NAME,
{ cmd: "version" },
)) as MessageResp
if (session !== targetSession || stopped) return
supported =
response.capabilities?.includes(CONTROL_CAPABILITY) ===
true
} catch (_) {
return
}
}
if (
!supported ||
stopped ||
port ||
nativeControlsSuspended ||
session !== targetSession
)
return
const connected = browser.runtime.connectNative(NATIVE_NAME)
const connectedSession = ++session
connected.onMessage.addListener(message =>
onMessage(connected, connectedSession, message),
)
connected.onDisconnect.addListener(() => {
if (port === connected) {
port = undefined
session++
}
})
port = connected
try {
connected.postMessage({
type: "control.handshake",
protocol: CONTROL_PROTOCOL,
enable: true,
})
} catch (error) {
port = undefined
connected.disconnect()
throw error
}
})().finally(() => (starting = undefined))
return starting
}
const control: NativeControl = {
start,
async reprobe() {
if (port) return
control.disconnect(true)
await control.start()
},
disconnect(reprobe = false) {
session++
if (reprobe) supported = undefined
const connected = port
port = undefined
connected?.disconnect()
},
stop() {
stopped = true
nativeControls.delete(control)
control.disconnect()
},
}
return control
}
/**

View file

@ -0,0 +1,397 @@
import * as Native from "@src/lib/native"
type DispatchExCmd = (excmd: string) => Promise<unknown>
interface NativeControl {
start(): Promise<void>
disconnect(reprobe?: boolean): void
stop(): void
}
const createNativeControlImpl = (
Native as typeof Native & {
createNativeControl(options: {
enabled?: boolean
dispatchExCmd: DispatchExCmd
}): NativeControl
}
).createNativeControl
const controls: NativeControl[] = []
const createNativeControl = (
options: Parameters<typeof createNativeControlImpl>[0],
) => {
const control = createNativeControlImpl(options)
controls.push(control)
return control
}
type Listener<T extends unknown[]> = (...args: T) => unknown
class FakeEvent<T extends unknown[]> {
private listeners: Listener<T>[] = []
addListener = jest.fn((listener: Listener<T>) =>
this.listeners.push(listener),
)
removeListener = jest.fn((listener: Listener<T>) => {
this.listeners = this.listeners.filter(
candidate => candidate !== listener,
)
})
emit(...args: T) {
return this.listeners.map(listener => listener(...args))
}
}
class FakePort {
postMessage = jest.fn()
disconnect = jest.fn()
onMessage = new FakeEvent<[unknown]>()
onDisconnect = new FakeEvent<[]>()
}
const sendNativeMessage = jest.fn()
const connectNative = jest.fn()
Object.assign(browser.runtime, { sendNativeMessage, connectNative })
const capableVersion = {
cmd: "version",
version: "0.6.0",
capabilities: ["control-port-v1"],
}
const controlRequest = (
id = "request-1",
excmd: unknown = "tabopen example.com",
) => ({
protocol: 1,
type: "control.request",
id,
operation: "ex",
command: excmd,
})
async function connectedControl(dispatchExCmd: jest.Mock = jest.fn()) {
const port = new FakePort()
sendNativeMessage.mockResolvedValue(capableVersion)
connectNative.mockReturnValue(port)
const control = createNativeControl({ enabled: true, dispatchExCmd })
await control.start()
port.postMessage.mockClear()
return { control, dispatchExCmd, port }
}
beforeEach(() => {
sendNativeMessage.mockReset()
connectNative.mockReset()
})
afterEach(() => {
controls.splice(0).forEach(control => control.stop())
Native.reconnectNativeControls()
jest.useRealTimers()
})
test("native control is disabled by default", async () => {
const control = createNativeControl({ dispatchExCmd: jest.fn() })
await control.start()
expect(sendNativeMessage).not.toHaveBeenCalled()
expect(connectNative).not.toHaveBeenCalled()
})
test("an enabled control probes once and leaves legacy messaging unchanged without the capability", async () => {
const legacyResponse = {
cmd: "run",
version: null,
content: "legacy",
code: 0,
}
sendNativeMessage
.mockResolvedValueOnce({ cmd: "version", version: "0.5.0" })
.mockResolvedValueOnce(legacyResponse)
const control = createNativeControl({
enabled: true,
dispatchExCmd: jest.fn(),
})
await Promise.all([control.start(), control.start()])
await control.start()
expect(sendNativeMessage).toHaveBeenCalledTimes(1)
expect(sendNativeMessage).toHaveBeenCalledWith("tridactyl", {
cmd: "version",
})
expect(connectNative).not.toHaveBeenCalled()
await expect(
Native.sendNativeMsg("run", { command: "printf legacy" }),
).resolves.toBe(legacyResponse)
expect(sendNativeMessage).toHaveBeenLastCalledWith("tridactyl", {
cmd: "run",
command: "printf legacy",
})
})
test("control-port-v1 opens one reusable port and opts in with a v1 hello", async () => {
const port = new FakePort()
sendNativeMessage.mockResolvedValue(capableVersion)
connectNative.mockReturnValue(port)
const control = createNativeControl({
enabled: true,
dispatchExCmd: jest.fn(),
})
await Promise.all([control.start(), control.start()])
await control.start()
expect(sendNativeMessage).toHaveBeenCalledTimes(1)
expect(connectNative).toHaveBeenCalledTimes(1)
expect(connectNative).toHaveBeenCalledWith("tridactyl")
expect(port.postMessage).toHaveBeenCalledTimes(1)
expect(port.postMessage).toHaveBeenCalledWith({
protocol: 1,
type: "control.handshake",
enable: true,
})
})
test("a rejected host handshake disconnects the control port", async () => {
const port = new FakePort()
sendNativeMessage.mockResolvedValue(capableVersion)
connectNative.mockReturnValue(port)
const control = createNativeControl({
enabled: true,
dispatchExCmd: jest.fn(),
})
await control.start()
await Promise.all(
port.onMessage.emit({
type: "control.handshake",
protocol: 1,
enabled: false,
error: "could not start the control endpoint",
}),
)
expect(port.disconnect).toHaveBeenCalledTimes(1)
})
test("an excmd request is dispatched and receives a correlated success response", async () => {
const dispatchExCmd = jest.fn().mockResolvedValue("opened")
const { port } = await connectedControl(dispatchExCmd)
await Promise.all(port.onMessage.emit(controlRequest()))
expect(dispatchExCmd).toHaveBeenCalledTimes(1)
expect(dispatchExCmd).toHaveBeenCalledWith("tabopen example.com")
expect(port.postMessage).toHaveBeenCalledWith({
protocol: 1,
type: "control.response",
id: "request-1",
ok: true,
result: "opened",
})
})
test("invalid protocol, method, and params return correlated errors without dispatch", async () => {
const { dispatchExCmd, port } = await connectedControl()
const invalid = [
[
{ ...controlRequest("bad-protocol"), protocol: 2 },
"unsupported control protocol",
],
[
{ ...controlRequest("bad-method"), operation: "read" },
"unsupported control operation",
],
[controlRequest("bad-params", 42), "control command must be a string"],
] as const
for (const [request, code] of invalid) {
await Promise.all(port.onMessage.emit(request))
expect(port.postMessage).toHaveBeenLastCalledWith({
protocol: 1,
type: "control.response",
id: request.id,
ok: false,
error: expect.stringContaining(code),
})
}
expect(dispatchExCmd).not.toHaveBeenCalled()
})
test("command failures become correlated control errors", async () => {
const dispatchExCmd = jest.fn().mockRejectedValue(new Error("Not an excmd"))
const { port } = await connectedControl(dispatchExCmd)
await Promise.all(port.onMessage.emit(controlRequest()))
expect(port.postMessage).toHaveBeenCalledWith({
protocol: 1,
type: "control.response",
id: "request-1",
ok: false,
error: "Not an excmd",
})
})
test("a duplicate pending request does not execute its command twice", async () => {
let finish: (result: string) => void
const pending = new Promise<string>(resolve => (finish = resolve))
const dispatchExCmd = jest.fn().mockReturnValue(pending)
const { port } = await connectedControl(dispatchExCmd)
const request = controlRequest()
const first = port.onMessage.emit(request)
await Promise.resolve()
const duplicate = port.onMessage.emit(request)
await Promise.resolve()
expect(dispatchExCmd).toHaveBeenCalledTimes(1)
finish("opened")
await Promise.all([...first, ...duplicate])
expect(dispatchExCmd).toHaveBeenCalledTimes(1)
})
test("a concurrent command is rejected as busy instead of running late", async () => {
let finish: (result: string) => void
const pending = new Promise<string>(resolve => (finish = resolve))
const dispatchExCmd = jest.fn().mockReturnValue(pending)
const { port } = await connectedControl(dispatchExCmd)
const first = port.onMessage.emit(controlRequest("first"))
await Promise.resolve()
await Promise.all(port.onMessage.emit(controlRequest("second", "reload")))
expect(dispatchExCmd).toHaveBeenCalledTimes(1)
expect(port.postMessage).toHaveBeenLastCalledWith({
protocol: 1,
type: "control.response",
id: "second",
ok: false,
error: "native control is busy",
})
finish("opened")
await Promise.all(first)
})
test("stopping control prevents later messages from executing", async () => {
const dispatchExCmd = jest.fn()
const port = new FakePort()
sendNativeMessage.mockResolvedValue(capableVersion)
connectNative.mockReturnValue(port)
const control = createNativeControl({ enabled: true, dispatchExCmd })
await control.start()
control.stop()
await Promise.all(port.onMessage.emit(controlRequest()))
expect(dispatchExCmd).not.toHaveBeenCalled()
})
test("non-JSON command results become correlated errors", async () => {
const cyclic: any = {}
cyclic.self = cyclic
const { port } = await connectedControl(jest.fn().mockResolvedValue(cyclic))
await Promise.all(port.onMessage.emit(controlRequest()))
expect(port.postMessage).toHaveBeenLastCalledWith({
protocol: 1,
type: "control.response",
id: "request-1",
ok: false,
error: "control result is not serializable",
})
})
test("disconnect clears the port and reconnects once only when requested", async () => {
jest.useFakeTimers()
const firstPort = new FakePort()
const secondPort = new FakePort()
sendNativeMessage.mockResolvedValue(capableVersion)
connectNative.mockReturnValueOnce(firstPort).mockReturnValueOnce(secondPort)
const control = createNativeControl({
enabled: true,
dispatchExCmd: jest.fn(),
})
await control.start()
firstPort.onDisconnect.emit()
jest.runOnlyPendingTimers()
await Promise.resolve()
expect(connectNative).toHaveBeenCalledTimes(1)
await Promise.all([control.start(), control.start()])
expect(sendNativeMessage).toHaveBeenCalledTimes(1)
expect(connectNative).toHaveBeenCalledTimes(2)
expect(secondPort.postMessage).toHaveBeenCalledTimes(1)
})
test("disconnect cancels existing capability-probe callers before reconnecting", async () => {
let resolveProbe!: (response: typeof capableVersion) => void
const probe = new Promise<typeof capableVersion>(resolve => {
resolveProbe = resolve
})
const port = new FakePort()
sendNativeMessage
.mockReturnValueOnce(probe)
.mockResolvedValueOnce(capableVersion)
connectNative.mockReturnValue(port)
const control = createNativeControl({
enabled: true,
dispatchExCmd: jest.fn(),
})
const initialStart = control.start()
const staleWaiter = control.start()
control.disconnect(true)
const restarted = control.start()
resolveProbe(capableVersion)
await Promise.all([initialStart, staleWaiter, restarted])
expect(sendNativeMessage).toHaveBeenCalledTimes(2)
expect(connectNative).toHaveBeenCalledTimes(1)
expect(port.postMessage).toHaveBeenCalledTimes(1)
})
test("global disconnect suspends controls created while an update runs", async () => {
Native.disconnectNativeControls()
const port = new FakePort()
sendNativeMessage.mockResolvedValue(capableVersion)
connectNative.mockReturnValue(port)
const control = createNativeControl({
enabled: true,
dispatchExCmd: jest.fn(),
})
await control.start()
expect(sendNativeMessage).not.toHaveBeenCalled()
Native.reconnectNativeControls()
await control.start()
expect(sendNativeMessage).toHaveBeenCalledTimes(1)
expect(connectNative).toHaveBeenCalledTimes(1)
})
test("reprobing preserves an established control port", async () => {
const { control, port } = await connectedControl()
Native.reconnectNativeControls(true)
await control.start()
expect(port.disconnect).not.toHaveBeenCalled()
expect(connectNative).toHaveBeenCalledTimes(1)
})
test("stopping control disconnects the persistent port", async () => {
const port = new FakePort()
sendNativeMessage.mockResolvedValue(capableVersion)
connectNative.mockReturnValue(port)
const control = createNativeControl({
enabled: true,
dispatchExCmd: jest.fn(),
})
await control.start()
control.stop()
expect(port.disconnect).toHaveBeenCalledTimes(1)
})