Bug 1350151 Part 1: Add requireUserInput property for functions in webextension schemas
MozReview-Commit-ID: BrMAwbwEu8b
--- a/toolkit/components/extensions/ExtensionChild.jsm
+++ b/toolkit/components/extensions/ExtensionChild.jsm
@@ -635,17 +635,25 @@ class ProxyAPIImplementation extends Sch
this.path = null;
this.childApiManager = null;
}
callFunctionNoReturn(args) {
this.childApiManager.callParentFunctionNoReturn(this.path, args);
}
- callAsyncFunction(args, callback) {
+ callAsyncFunction(args, callback, requireUserInput) {
+ if (requireUserInput) {
+ let context = this.childApiManager.context;
+ let winUtils = context.contentWindow.getInterface(Ci.nsIDOMWindowUtils);
+ if (!winUtils.isHandlingUserInput) {
+ let err = new context.cloneScope.Error(`${this.path} may only be called from a user input handler`);
+ return context.wrapPromise(Promise.reject(err), callback);
+ }
+ }
return this.childApiManager.callParentAsyncFunction(this.path, args, callback);
}
addListener(listener, args) {
let map = this.childApiManager.listeners.get(this.path);
if (map.listeners.has(listener)) {
// TODO: Called with different args?
--- a/toolkit/components/extensions/ExtensionCommon.jsm
+++ b/toolkit/components/extensions/ExtensionCommon.jsm
@@ -445,20 +445,22 @@ class SchemaAPIInterface {
/**
* Calls this as a function that completes asynchronously.
*
* @abstract
* @param {Array} args The parameters for the function.
* @param {function(*)} [callback] The callback to be called when the function
* completes.
+ * @param {boolean} [requireUserInput=false] If true, the function should
+ * fail if the browser is not currently handling user input.
* @returns {Promise|undefined} Must be void if `callback` is set, and a
* promise otherwise. The promise is resolved when the function completes.
*/
- callAsyncFunction(args, callback) {
+ callAsyncFunction(args, callback, requireUserInput = false) {
throw new Error("Not implemented");
}
/**
* Retrieves the value of this as a property.
*
* @abstract
* @returns {*} The value of the property.
@@ -554,19 +556,26 @@ class LocalAPIImplementation extends Sch
callFunction(args) {
return this.pathObj[this.name](...args);
}
callFunctionNoReturn(args) {
this.pathObj[this.name](...args);
}
- callAsyncFunction(args, callback) {
+ callAsyncFunction(args, callback, requireUserInput) {
let promise;
try {
+ if (requireUserInput) {
+ let winUtils = this.context.contentWindow
+ .getInterface(Ci.nsIDOMWindowUtils);
+ if (!winUtils.isHandlingUserInput) {
+ throw new ExtensionError(`${this.name} may only be called from a user input handler`);
+ }
+ }
promise = this.pathObj[this.name](...args) || Promise.resolve();
} catch (e) {
promise = Promise.reject(e);
}
return this.context.wrapPromise(promise, callback);
}
getProperty() {
--- a/toolkit/components/extensions/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -1831,17 +1831,18 @@ class ArrayType extends Type {
checkBaseType(baseType) {
return baseType == "array";
}
}
class FunctionType extends Type {
static get EXTRA_PROPERTIES() {
- return ["parameters", "async", "returns", ...super.EXTRA_PROPERTIES];
+ return ["parameters", "async", "returns", "requireUserInput",
+ ...super.EXTRA_PROPERTIES];
}
static parseSchema(schema, path, extraProperties = []) {
this.checkSchemaProperties(schema, path, extraProperties);
let isAsync = !!schema.async;
let isExpectingCallback = typeof schema.async === "string";
let parameters = null;
@@ -1882,24 +1883,26 @@ class FunctionType extends Type {
}
if (isAsync && schema.allowAmbiguousOptionalArguments && !hasAsyncCallback) {
throw new Error("Internal error: Async functions with ambiguous " +
"arguments must declare the callback as the last parameter");
}
}
- return new this(schema, parameters, isAsync, hasAsyncCallback);
+ return new this(schema, parameters, isAsync, hasAsyncCallback,
+ !!schema.requireUserInput);
}
- constructor(schema, parameters, isAsync, hasAsyncCallback) {
+ constructor(schema, parameters, isAsync, hasAsyncCallback, requireUserInput) {
super(schema);
this.parameters = parameters;
this.isAsync = isAsync;
this.hasAsyncCallback = hasAsyncCallback;
+ this.requireUserInput = requireUserInput;
}
normalize(value, context) {
return this.normalizeBase("function", value, context);
}
checkBaseType(baseType) {
return baseType == "function";
@@ -2162,16 +2165,17 @@ FunctionEntry = class FunctionEntry exte
constructor(schema, path, name, type, unsupported, allowAmbiguousOptionalArguments, returns, permissions) {
super(schema, path, name, type.parameters, allowAmbiguousOptionalArguments);
this.unsupported = unsupported;
this.returns = returns;
this.permissions = permissions;
this.isAsync = type.isAsync;
this.hasAsyncCallback = type.hasAsyncCallback;
+ this.requireUserInput = type.requireUserInput;
}
checkValue({type, optional, name}, value, context) {
if (optional && value == null) {
return;
}
if (type.reference === "ExtensionPanel" || type.reference === "Port") {
// TODO: We currently treat objects with functions as SubModuleType,
@@ -2211,17 +2215,17 @@ FunctionEntry = class FunctionEntry exte
}
if (DEBUG && this.hasAsyncCallback && callback) {
let original = callback;
callback = (...args) => {
this.checkCallback(args, context);
original(...args);
};
}
- let result = apiImpl.callAsyncFunction(actuals, callback);
+ let result = apiImpl.callAsyncFunction(actuals, callback, this.requireUserInput);
if (DEBUG && this.hasAsyncCallback && !callback) {
return result.then(result => {
this.checkCallback([result], context);
return result;
});
}
return result;
};
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js
@@ -0,0 +1,135 @@
+"use strict";
+
+/* global ExtensionAPIs */
+Components.utils.import("resource://gre/modules/ExtensionAPI.jsm");
+const {ExtensionManager} = Components.utils.import("resource://gre/modules/ExtensionChild.jsm", {});
+
+Components.utils.importGlobalProperties(["Blob", "URL"]);
+
+let schema = [
+ {
+ namespace: "userinputtest",
+ functions: [
+ {
+ name: "test",
+ type: "function",
+ async: true,
+ requireUserInput: true,
+ parameters: [],
+ },
+ ],
+ },
+];
+
+class API extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ userinputtest: {
+ test() {},
+ },
+ };
+ }
+}
+
+let schemaUrl = `data:,${JSON.stringify(schema)}`;
+
+// Set the "handlingUserInput" flag for the given extension's background page.
+// Returns an RAIIHelper that should be destruct()ed eventually.
+function setHandlingUserInput(extension) {
+ let extensionChild = ExtensionManager.extensions.get(extension.extension.id);
+ let bgwin = null;
+ for (let view of extensionChild.views) {
+ if (view.viewType == "background") {
+ bgwin = view.contentWindow;
+ break;
+ }
+ }
+ notEqual(bgwin, null, "Found background window for the test extension");
+ let winutils = bgwin.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils);
+ return winutils.setHandlingUserInput(true);
+}
+
+// Test that the schema requireUserInput flag works correctly for
+// proxied api implementations.
+add_task(async function test_proxy() {
+ let apiUrl = URL.createObjectURL(new Blob([API.toString()]));
+ ExtensionAPIs.register("userinputtest", schemaUrl, apiUrl);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.onMessage.addListener(async () => {
+ try {
+ await browser.userinputtest.test();
+ browser.test.sendMessage("result", null);
+ } catch (err) {
+ browser.test.sendMessage("result", err.message);
+ }
+ });
+ },
+ manifest: {
+ permissions: ["experiments.userinputtest"],
+ },
+ });
+
+ await extension.startup();
+
+ extension.sendMessage("test");
+ let result = await extension.awaitMessage("result");
+ ok(/test may only be called from a user input handler/.test(result),
+ "function failed when not called from a user input handler");
+
+ let handle = setHandlingUserInput(extension);
+ extension.sendMessage("test");
+ result = await extension.awaitMessage("result");
+ equal(result, null, "function succeeded when called from a user input handler");
+ handle.destruct();
+
+ await extension.unload();
+ ExtensionAPIs.unregister("userinputtest");
+});
+
+// Test that the schema requireUserInput flag works correctly for
+// non-proxied api implementations.
+add_task(async function test_local() {
+ let apiString = `this.userinputtest = ${API.toString()};`;
+ let apiUrl = URL.createObjectURL(new Blob([apiString]));
+ await Schemas.load(schemaUrl);
+ const {apiManager} = Components.utils.import("resource://gre/modules/ExtensionPageChild.jsm", {});
+ apiManager.registerModules({
+ userinputtest: {
+ url: apiUrl,
+ scopes: ["addon_child"],
+ paths: [["userinputtest"]],
+ },
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.onMessage.addListener(async () => {
+ try {
+ await browser.userinputtest.test();
+ browser.test.sendMessage("result", null);
+ } catch (err) {
+ browser.test.sendMessage("result", err.message);
+ }
+ });
+ },
+ manifest: {},
+ });
+
+ await extension.startup();
+
+ extension.sendMessage("test");
+ let result = await extension.awaitMessage("result");
+ ok(/test may only be called from a user input handler/.test(result),
+ "function failed when not called from a user input handler");
+
+ let handle = setHandlingUserInput(extension);
+ extension.sendMessage("test");
+ result = await extension.awaitMessage("result");
+ equal(result, null, "function succeeded when called from a user input handler");
+ handle.destruct();
+
+ await extension.unload();
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -36,16 +36,17 @@ tags = webextensions in-process-webexten
[test_ext_json_parser.js]
[test_ext_manifest_content_security_policy.js]
[test_ext_manifest_incognito.js]
[test_ext_manifest_minimum_chrome_version.js]
[test_ext_manifest_themes.js]
[test_ext_schemas.js]
[test_ext_schemas_async.js]
[test_ext_schemas_allowed_contexts.js]
+[test_ext_schemas_interactive.js]
[test_ext_schemas_revoke.js]
[test_ext_themes_supported_properties.js]
[test_ext_unknown_permissions.js]
[test_locale_converter.js]
[test_locale_data.js]
[test_ext_permissions.js]
skip-if = os == "android" # Bug 1350559