mirror of
https://github.com/tridactyl/tridactyl.git
synced 2026-09-10 07:16:33 -04:00
Fix #780: support connection-based native messenger
This commit is contained in:
parent
9c8c15ff77
commit
fe2cd487cd
|
|
@ -1 +1 @@
|
|||
0.5.0
|
||||
0.6.0
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ def content(lines, context):
|
|||
"excmd_content",
|
||||
"{sig.name}",
|
||||
[{message_params}],
|
||||
controller.getCurrentExCmdSource(),
|
||||
)
|
||||
}}\n""".format(**locals()))
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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?.(),
|
||||
|
|
|
|||
79
src/lib/controller_native.test.ts
Normal file
79
src/lib/controller_native.test.ts
Normal 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()
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
397
src/lib/native_control.test.ts
Normal file
397
src/lib/native_control.test.ts
Normal 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)
|
||||
})
|
||||
Loading…
Reference in a new issue