Bug 1350151 Part 1: Add requireUserInput property for functions in webextension schemas draft
authorAndrew Swan <aswan@mozilla.com>
Tue, 25 Jul 2017 22:45:47 -0700
changeset 618761 0bbb118d2d00fb6d4a0b2471726d2449e3d2f923
parent 618576 87824406b9feb420a3150720707b424d7cee5915
child 618762 8211c210e4e9b7182c70c7130e25510bd3580490
push id71443
push useraswan@mozilla.com
push dateTue, 01 Aug 2017 02:18:17 +0000
bugs1350151
milestone56.0a1
Bug 1350151 Part 1: Add requireUserInput property for functions in webextension schemas MozReview-Commit-ID: BrMAwbwEu8b
toolkit/components/extensions/ExtensionChild.jsm
toolkit/components/extensions/ExtensionCommon.jsm
toolkit/components/extensions/Schemas.jsm
toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
--- 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