Bug 1911836 - Implement onUserScriptConnect r=zombie

Differential Revision: https://phabricator.services.mozilla.com/D229387
This commit is contained in:
Rob Wu 2024-11-21 21:35:08 +00:00
parent d99a5fd35f
commit e5573410c8
6 changed files with 284 additions and 5 deletions

View File

@ -330,6 +330,14 @@ export class Messenger {
this.onMessageEx = new MessageEvent(context, "runtime.onMessageExternal");
}
get onUserScriptConnect() {
return redefineGetter(
this,
"onUserScriptConnect",
new SimpleEventAPI(this.context, "runtime.onUserScriptConnect")
);
}
get onUserScriptMessage() {
return redefineGetter(
this,
@ -367,17 +375,25 @@ export class Messenger {
return context.wrapPromise(response, callback);
}
connect({ name, native, ...args }) {
connect({ context, name, native, ...args }) {
// this.context is usually used, except with user scripts, where we pass a
// custom context to ensure that the return value is cloned into the right
// USER_SCRIPT world.
context ??= this.context;
let portId = getUniqueId();
let port = new Port(this.context, portId, name, !!native);
let port = new Port(context, portId, name, !!native);
this.conduit
.queryPortConnect({ portId, name, native, ...args })
.catch(error => port.recvPortDisconnect({ error }));
return port.api;
}
recvPortConnect({ extensionId, portId, name, sender }) {
recvPortConnect({ extensionId, portId, name, sender, userScriptWorldId }) {
let event = sender.id === extensionId ? this.onConnect : this.onConnectEx;
if (typeof userScriptWorldId == "string") {
sender = { ...sender, userScriptWorldId };
event = this.onUserScriptConnect;
}
if (this.context.active && event.fires.size) {
let port = new Port(this.context, portId, name, false, sender);
return event.emit(port.api).length;

View File

@ -62,7 +62,7 @@ class WorldConfigHolder {
* instance to support APIs exposed to USER_SCRIPT worlds. Such contexts are
* usually heavier due to the need to track the document lifetime, but because
* all user script worlds and a content script for a document (and extension)
* shares the same lifetime, we delegate to the only ContentScriptContextChild
* share the same lifetime, we delegate to the only ContentScriptContextChild
* that exists for the document+extension.
*/
class UserScriptContext extends BaseContext {
@ -107,6 +107,10 @@ class UserScriptContext extends BaseContext {
this.sandbox = null;
}
async logActivity(type, name, data) {
return this.contentContext.logActivity(type, name, data);
}
get cloneScope() {
return this.sandbox;
}
@ -138,12 +142,27 @@ class UserScriptContext extends BaseContext {
if (this.enableMessaging) {
browser.runtime = {};
browser.runtime.connect = wrapF(this.runtimeConnect);
browser.runtime.sendMessage = wrapF(this.runtimeSendMessage);
}
const value = Cu.cloneInto(browser, this.sandbox, { cloneFunctions: true });
return redefineGetter(this, "browserObj", value);
}
runtimeConnect(...args) {
args = this.#schemaCheckParameters("runtime", "connect", args);
let [extensionId, options] = args;
if (extensionId !== null) {
throw new ExtensionError("extensionId is not supported");
}
let name = options?.name ?? "";
return this.contentContext.messenger.connect({
context: this,
userScriptWorldId: this.worldId,
name,
});
}
runtimeSendMessage(...args) {
// Simplified version of parseBonkersArgs in child/ext-runtime.js
let callback = typeof args[args.length - 1] === "function" && args.pop();
@ -171,6 +190,12 @@ class UserScriptContext extends BaseContext {
callback,
});
}
#schemaCheckParameters(namespace, method, args) {
let ns = this.contentContext.childManager.schema.getNamespace(namespace);
let schemaContext = lazy.Schemas.paramsValidationContexts.get(this);
return ns.get(method).checkParameters(args, schemaContext);
}
}
class WorldCollection {

View File

@ -63,6 +63,13 @@ this.runtime = class extends ExtensionAPI {
onConnectExternal: context.messenger.onConnectEx.api(),
onMessageExternal: context.messenger.onMessageEx.api(),
get onUserScriptConnect() {
return ExtensionCommon.redefineGetter(
this,
"onUserScriptConnect",
context.messenger.onUserScriptConnect.api()
);
},
get onUserScriptMessage() {
return ExtensionCommon.redefineGetter(
this,

View File

@ -36,6 +36,7 @@ this.runtime = class extends ExtensionAPIPersistent {
// - runtime.onMessage
// - runtime.onMessageExternal
// - runtime.onUserScriptMessage
// - runtime.onUserScriptConnect
// For details, see bug 1852317 and test_ext_eventpage_messaging_wakeup.js.
onInstalled({ fire }) {

View File

@ -174,7 +174,7 @@
"userScriptWorldId": {
"type": "string",
"optional": true,
"description": "The worldId of the USER_SCRIPT world that sent the message. Only present on onUserScriptMessage events."
"description": "The worldId of the USER_SCRIPT world that sent the message. Only present on onUserScriptMessage and onUserScriptConnect (in port.sender) events."
}
}
},
@ -750,6 +750,13 @@
"description": "Fired when a connection is made from either an extension process or a content script.",
"parameters": [{ "$ref": "Port", "name": "port" }]
},
{
"name": "onUserScriptConnect",
"type": "function",
"description": "Fired when a connection is made from a USER_SCRIPT world registered through the userScripts API.",
"permissions": ["userScripts"],
"parameters": [{ "$ref": "Port", "name": "port" }]
},
{
"name": "onConnectExternal",
"type": "function",

View File

@ -71,6 +71,7 @@ add_task(async function test_runtime_messaging_errors() {
}
try {
// runtime.sendMessage tests:
assertThrows(
() => browser.runtime.sendMessage(),
"runtime.sendMessage's message argument is missing",
@ -96,6 +97,28 @@ add_task(async function test_runtime_messaging_errors() {
"Could not establish connection. Receiving end does not exist.",
"Expected error when there is no onUserScriptMessage handler"
);
// runtime.connect tests:
assertThrows(
() => browser.runtime.connect("extensionId", {}),
"extensionId is not supported",
"connect with unsupported extensionId parameter"
);
assertThrows(
() => browser.runtime.connect("extensionId"),
"extensionId is not supported",
"connect with unsupported extensionId parameter and no options"
);
assertThrows(
() => browser.runtime.connect({ unknownProp: true }),
`Type error for parameter connectInfo (Unexpected property "unknownProp") for runtime.connect.`,
"connect with unrecognized property"
);
assertThrows(
() => browser.runtime.connect({}, {}),
"Incorrect argument types for runtime.connect.",
"connect with too many parameters"
);
} catch (e) {
contentscriptTest.fail(`Unexpected error in userscript.js: ${e}`);
}
@ -357,3 +380,203 @@ add_task(async function test_configureWorld_messaging_existing_world() {
await extension.unload();
});
// This test tests that runtime.connect() works when called from a user script.
add_task(async function test_onUserScriptConnect() {
let extension = ExtensionTestUtils.loadExtension({
useAddonManager: "permanent",
manifest: {
manifest_version: 3,
permissions: ["userScripts"],
host_permissions: ["*://example.com/*"],
},
files: {
"userscript.js": () => {
let port = browser.runtime.connect({ name: "first_port" });
port.onMessage.addListener(msg => {
port.postMessage({ messageBack: msg });
});
port.onDisconnect.addListener(() => {
let port2 = browser.runtime.connect({ name: "second_port" });
port2.postMessage({ errorFromFirstPort: port.error });
port2.disconnect();
});
},
},
background() {
browser.runtime.onUserScriptConnect.addListener(port => {
browser.test.assertEq(
"connect_world",
port.sender.userScriptWorldId,
`Expected userScriptWorldId in onUserScriptConnect: ${port.name}`
);
if (port.name === "first_port") {
port.onMessage.addListener(msg => {
browser.test.assertDeepEq(
{ messageBack: { hi: 1 } },
msg,
"port.onMessage triggered from user script"
);
port.disconnect();
// ^ should trigger port.onDisconnect in the user script, which
// will signal back the status by connecting again to second_port.
});
port.onDisconnect.addListener(() => {
// We should not expect a disconnect, because we call
// port.disconnect() from this end.
browser.test.fail(`Unexpected port.onDisconnect: ${port.error}`);
});
port.postMessage({ hi: 1 });
return;
}
if (port.name === "second_port") {
port.onMessage.addListener(msg => {
browser.test.assertDeepEq(
{ errorFromFirstPort: null },
msg,
"When we disconnect first_port, other port.error should be null"
);
browser.test.sendMessage("port.onMessage:done");
});
port.onDisconnect.addListener(() => {
browser.test.assertDeepEq(
null,
port.error,
"Our port.error when other side (user script) disconnects"
);
browser.test.sendMessage("port.onDisconnect:done");
});
return;
}
browser.test.fail(`Unexpected port: ${port.name}`);
});
browser.runtime.onInstalled.addListener(async () => {
await browser.userScripts.configureWorld({ messaging: true });
await browser.userScripts.register([
{
id: "messaging checker",
matches: ["*://example.com/dummy"],
js: [{ file: "userscript.js" }],
worldId: "connect_world",
},
]);
browser.test.sendMessage("registered");
});
},
});
await extension.startup();
await extension.awaitMessage("registered");
async function testRuntimeConnect() {
let contentPage = await ExtensionTestUtils.loadContentPage(
"http://example.com/dummy"
);
await Promise.all([
extension.awaitMessage("port.onMessage:done"),
extension.awaitMessage("port.onDisconnect:done"),
]);
await contentPage.close();
}
info("Loading page that should trigger runtime.connect");
await testRuntimeConnect();
await AddonTestUtils.promiseShutdownManager();
ExtensionUserScripts._getStoreForTesting()._uninitForTesting();
await AddonTestUtils.promiseStartupManager();
// Because the background has a persistent listener (runtime.onInstalled), it
// stays suspended after a restart.
await extension.awaitStartup();
ExtensionTestCommon.testAssertions.assertBackgroundStatusStopped(extension);
// We expect that the load of the page that calls runtime.connect from a
// user script will wake it to fire runtime.onUserScriptConnect.
info("Loading page that should load user script and wake event page");
await testRuntimeConnect();
await extension.unload();
});
// This tests:
// - That port.onDisconnect is fired when the document navigates away.
// - That runtime.connect() can be called without parameters.
add_task(
{
// We want to disable the bfcache and use the unload listener to force
// that below. But on Android bfcache.allow_unload_listeners is true by
// default, so we force the pref to false to make sure that the bfcache is
// indeed disabled for the test page.
pref_set: [["docshell.shistory.bfcache.allow_unload_listeners", false]],
},
async function test_onUserScriptConnect_port_disconnect_on_navigate() {
let extension = ExtensionTestUtils.loadExtension({
useAddonManager: "permanent",
manifest: {
manifest_version: 3,
permissions: ["userScripts"],
host_permissions: ["*://example.com/*"],
},
files: {
"userscript.js": () => {
let port = browser.runtime.connect();
// Prevent bfcache from keeping doc + port alive.
// eslint-disable-next-line mozilla/balanced-listeners
window.addEventListener("unload", () => {});
// Global var to avoid garbage collection:
globalThis.portReference = port;
port.onMessage.addListener(msg => {
dump(`Will navigate elsewhere after bye message: ${msg}\n`);
// Change URL. Note: not matched by matches[] above.
location.search = "?something_else";
// ^ Note: we expect the context to unload. If the test times out
// after this point, it may be due to the page unexpectedly not
// being unloaded, e.g. due to it being stored in the bfcache.
// We disable the bfcache with the unload listener above.
});
},
},
async background() {
browser.runtime.onUserScriptConnect.addListener(port => {
browser.test.assertEq("", port.name, "Got default port.name");
browser.test.assertEq(
"",
port.sender.userScriptWorldId,
"Got default userScriptWorldId"
);
port.onDisconnect.addListener(() => {
browser.test.assertDeepEq(
null,
port.error,
"Closing port due to a navigation is not an error"
);
browser.test.sendMessage("done");
});
port.postMessage("bye");
});
await browser.userScripts.configureWorld({ messaging: true });
await browser.userScripts.register([
{
id: "messaging checker",
matches: ["*://example.com/dummy"],
js: [{ file: "userscript.js" }],
},
]);
browser.test.sendMessage("registered");
},
});
await extension.startup();
await extension.awaitMessage("registered");
let contentPage = await ExtensionTestUtils.loadContentPage(
"http://example.com/dummy"
);
await extension.awaitMessage("done");
await contentPage.close();
await extension.unload();
}
);