Bug 1310427 support protocol handlers, r?kmag draft
authorShane Caraveo <scaraveo@mozilla.com>
Fri, 24 Feb 2017 11:20:20 -0800
changeset 489325 ba5b696c3869deed63e836fe16bcae3cbb749a50
parent 488863 5069348353f8fc1121e632e3208da33900627214
child 546987 00d33881bf67252e042be4aa784914cdce34d383
push id46807
push usermixedpuppy@gmail.com
push dateFri, 24 Feb 2017 19:22:17 +0000
reviewerskmag
bugs1310427
milestone54.0a1
Bug 1310427 support protocol handlers, r?kmag MozReview-Commit-ID: 7sHh8YZWe3f
toolkit/components/extensions/ext-protocolHandlers.js
toolkit/components/extensions/extensions-toolkit.manifest
toolkit/components/extensions/jar.mn
toolkit/components/extensions/schemas/extension_protocol_handlers.json
toolkit/components/extensions/schemas/jar.mn
toolkit/components/extensions/schemas/manifest.json
toolkit/components/extensions/test/mochitest/chrome.ini
toolkit/components/extensions/test/mochitest/head.js
toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html
toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/ext-protocolHandlers.js
@@ -0,0 +1,69 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(this, "handlerService",
+                                   "@mozilla.org/uriloader/handler-service;1",
+                                   "nsIHandlerService");
+XPCOMUtils.defineLazyServiceGetter(this, "protocolService",
+                                   "@mozilla.org/uriloader/external-protocol-service;1",
+                                   "nsIExternalProtocolService");
+Cu.importGlobalProperties(["URL"]);
+
+const handlers = new WeakMap();
+
+function hasHandlerApp(handlerConfig) {
+  let protoInfo = protocolService.getProtocolHandlerInfo(handlerConfig.protocol);
+  let appHandlers = protoInfo.possibleApplicationHandlers;
+  for (let i = 0; i < appHandlers.length; i++) {
+    let handler = appHandlers.queryElementAt(i, Ci.nsISupports);
+    if (handler instanceof Ci.nsIWebHandlerApp &&
+        handler.uriTemplate === handlerConfig.uriTemplate) {
+      return true;
+    }
+  }
+  return false;
+}
+
+/* eslint-disable mozilla/balanced-listeners */
+extensions.on("manifest_protocol_handlers", (type, directive, extension, manifest) => {
+  for (let handlerConfig of manifest.protocol_handlers) {
+    if (hasHandlerApp(handlerConfig)) {
+      continue;
+    }
+
+    let handler = Cc["@mozilla.org/uriloader/web-handler-app;1"]
+                    .createInstance(Ci.nsIWebHandlerApp);
+    handler.name = handlerConfig.name;
+    handler.uriTemplate = handlerConfig.uriTemplate;
+
+    let protoInfo = protocolService.getProtocolHandlerInfo(handlerConfig.protocol);
+    protoInfo.possibleApplicationHandlers.appendElement(handler, false);
+    handlerService.store(protoInfo);
+  }
+  handlers.set(extension, manifest.protocol_handlers);
+});
+
+extensions.on("shutdown", (type, extension) => {
+  if (!handlers.has(extension) || extension.shutdownReason === "APP_SHUTDOWN") {
+    return;
+  }
+  for (let handlerConfig of handlers.get(extension)) {
+    let protoInfo = protocolService.getProtocolHandlerInfo(handlerConfig.protocol);
+    let appHandlers = protoInfo.possibleApplicationHandlers;
+    for (let i = 0; i < appHandlers.length; i++) {
+      let handler = appHandlers.queryElementAt(i, Ci.nsISupports);
+      if (handler instanceof Ci.nsIWebHandlerApp &&
+          handler.uriTemplate === handlerConfig.uriTemplate) {
+        appHandlers.removeElementAt(i);
+        if (protoInfo.preferredApplicationHandler === handler) {
+          protoInfo.preferredApplicationHandler = null;
+          protoInfo.alwaysAskBeforeHandling = true;
+        }
+        handlerService.store(protoInfo);
+        break;
+      }
+    }
+  }
+  handlers.delete(extension);
+});
--- a/toolkit/components/extensions/extensions-toolkit.manifest
+++ b/toolkit/components/extensions/extensions-toolkit.manifest
@@ -6,16 +6,17 @@ category webextension-scripts cookies ch
 category webextension-scripts downloads chrome://extensions/content/ext-downloads.js
 category webextension-scripts geolocation chrome://extensions/content/ext-geolocation.js
 category webextension-scripts management chrome://extensions/content/ext-management.js
 category webextension-scripts notifications chrome://extensions/content/ext-notifications.js
 category webextension-scripts i18n chrome://extensions/content/ext-i18n.js
 category webextension-scripts idle chrome://extensions/content/ext-idle.js
 category webextension-scripts webRequest chrome://extensions/content/ext-webRequest.js
 category webextension-scripts webNavigation chrome://extensions/content/ext-webNavigation.js
+category webextension-scripts handlers chrome://extensions/content/ext-protocolHandlers.js
 category webextension-scripts runtime chrome://extensions/content/ext-runtime.js
 category webextension-scripts extension chrome://extensions/content/ext-extension.js
 category webextension-scripts storage chrome://extensions/content/ext-storage.js
 category webextension-scripts topSites chrome://extensions/content/ext-topSites.js
 category webextension-scripts privacy chrome://extensions/content/ext-privacy.js
 
 # scripts specific for content process.
 category webextension-scripts-content extension chrome://extensions/content/ext-c-extension.js
@@ -45,16 +46,17 @@ category webextension-scripts-addon stor
 # schemas
 category webextension-schemas alarms chrome://extensions/content/schemas/alarms.json
 category webextension-schemas contextualIdentities chrome://extensions/content/schemas/contextual_identities.json
 category webextension-schemas cookies chrome://extensions/content/schemas/cookies.json
 category webextension-schemas downloads chrome://extensions/content/schemas/downloads.json
 category webextension-schemas events chrome://extensions/content/schemas/events.json
 category webextension-schemas extension chrome://extensions/content/schemas/extension.json
 category webextension-schemas extension_types chrome://extensions/content/schemas/extension_types.json
+category webextension-schemas handlers chrome://extensions/content/schemas/extension_protocol_handlers.json
 category webextension-schemas i18n chrome://extensions/content/schemas/i18n.json
 #ifndef ANDROID
 category webextension-schemas identity chrome://extensions/content/schemas/identity.json
 #endif
 category webextension-schemas idle chrome://extensions/content/schemas/idle.json
 category webextension-schemas management chrome://extensions/content/schemas/management.json
 category webextension-schemas native_host_manifest chrome://extensions/content/schemas/native_host_manifest.json
 category webextension-schemas notifications chrome://extensions/content/schemas/notifications.json
--- a/toolkit/components/extensions/jar.mn
+++ b/toolkit/components/extensions/jar.mn
@@ -12,16 +12,17 @@ toolkit.jar:
     content/extensions/ext-downloads.js
     content/extensions/ext-geolocation.js
     content/extensions/ext-management.js
     content/extensions/ext-notifications.js
     content/extensions/ext-i18n.js
     content/extensions/ext-idle.js
     content/extensions/ext-webRequest.js
     content/extensions/ext-webNavigation.js
+    content/extensions/ext-protocolHandlers.js
     content/extensions/ext-runtime.js
     content/extensions/ext-extension.js
     content/extensions/ext-storage.js
     content/extensions/ext-topSites.js
     content/extensions/ext-privacy.js
     content/extensions/ext-c-backgroundPage.js
     content/extensions/ext-c-extension.js
 #ifndef ANDROID
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/schemas/extension_protocol_handlers.json
@@ -0,0 +1,51 @@
+[
+  {
+    "namespace": "manifest",
+    "types": [
+      {
+        "id": "ProtocolHandler",
+        "type": "object",
+        "description": "Represents a protocol handler definition.",
+        "properties": {
+          "name": {
+            "description": "A user-readable title string for the protocol handler. This will be displayed to the user in interface objects as needed.",
+            "type": "string"
+          },
+          "protocol": {
+            "description": "The protocol the site wishes to handle, specified as a string. For example, you can register to handle SMS text message links by registering to handle the \"sms\" scheme.",
+            "choices": [{
+              "type": "string",
+              "enum": [
+                "bitcoin", "geo", "im", "irc", "ircs", "magnet", "mailto",
+                "mms", "news", "nntp", "sip", "sms", "smsto", "ssh", "tel",
+                "urn", "webcal", "wtai", "xmpp"
+              ]
+            }, {
+              "type": "string",
+              "pattern": "^(ext|web)\\+[a-z0-9.+-]+$"
+            }]
+          },
+          "uriTemplate": {
+            "description": "The URL of the handler, as a string. This string should include \"%s\" as a placeholder which will be replaced with the escaped URL of the document to be handled. This URL might be a true URL, or it could be a phone number, email address, or so forth.",
+            "preprocess": "localize",
+            "choices": [
+              {"$ref": "ExtensionURL"},
+              {"$ref": "HttpURL"}
+            ]
+          }
+        }
+      },
+      {
+        "$extend": "WebExtensionManifest",
+        "properties": {
+          "protocol_handlers": {
+            "description": "A list of protocol handler definitions.",
+            "optional": true,
+            "type": "array",
+            "items": {"$ref": "ProtocolHandler"}
+          }
+        }
+      }
+    ]
+  }
+]
--- a/toolkit/components/extensions/schemas/jar.mn
+++ b/toolkit/components/extensions/schemas/jar.mn
@@ -7,16 +7,17 @@ toolkit.jar:
     content/extensions/schemas/alarms.json
     content/extensions/schemas/contextual_identities.json
     content/extensions/schemas/cookies.json
     content/extensions/schemas/downloads.json
     content/extensions/schemas/events.json
     content/extensions/schemas/experiments.json
     content/extensions/schemas/extension.json
     content/extensions/schemas/extension_types.json
+    content/extensions/schemas/extension_protocol_handlers.json
     content/extensions/schemas/i18n.json
 #ifndef ANDROID
     content/extensions/schemas/identity.json
 #endif
     content/extensions/schemas/idle.json
     content/extensions/schemas/management.json
     content/extensions/schemas/manifest.json
     content/extensions/schemas/native_host_manifest.json
--- a/toolkit/components/extensions/schemas/manifest.json
+++ b/toolkit/components/extensions/schemas/manifest.json
@@ -219,16 +219,22 @@
               "notifications",
               "storage"
             ]
           },
           { "$ref": "MatchPattern" }
         ]
       },
       {
+        "id": "HttpURL",
+        "type": "string",
+        "format": "url",
+        "pattern": "^https?://.*$"
+      },
+      {
         "id": "ExtensionURL",
         "type": "string",
         "format": "strictRelativeUrl"
       },
       {
         "id": "ExtensionID",
         "choices": [
           {
--- a/toolkit/components/extensions/test/mochitest/chrome.ini
+++ b/toolkit/components/extensions/test/mochitest/chrome.ini
@@ -25,16 +25,17 @@ skip-if = (toolkit == 'android') # andro
 [test_chrome_native_messaging_paths.html]
 skip-if = os != "mac" && os != "linux"
 [test_ext_cookies_expiry.html]
 [test_ext_cookies_permissions_bad.html]
 [test_ext_cookies_permissions_good.html]
 [test_ext_cookies_containers.html]
 [test_ext_jsversion.html]
 [test_ext_schema.html]
+[test_ext_protocolHandlers.html]
 [test_chrome_ext_storage_cleanup.html]
 [test_chrome_ext_idle.html]
 [test_chrome_ext_identity.html]
 skip-if = os == 'android' # unsupported.
 [test_chrome_ext_downloads_saveAs.html]
 [test_chrome_ext_webrequest_background_events.html]
 [test_chrome_ext_webrequest_host_permissions.html]
 [test_chrome_ext_trackingprotection.html]
--- a/toolkit/components/extensions/test/mochitest/head.js
+++ b/toolkit/components/extensions/test/mochitest/head.js
@@ -1,11 +1,11 @@
 "use strict";
 
-/* exported AppConstants */
+/* exported AppConstants, Assert */
 
 var {AppConstants} = SpecialPowers.Cu.import("resource://gre/modules/AppConstants.jsm", {});
 
 // We run tests under two different configurations, from mochitest.ini and
 // mochitest-remote.ini. When running from mochitest-remote.ini, the tests are
 // copied to the sub-directory "test-oop-extensions", which we detect here, and
 // use to select our configuration.
 if (location.pathname.includes("test-oop-extensions")) {
@@ -30,16 +30,26 @@ if (location.pathname.includes("test-oop
     chromeScript.destroy();
 
     if (results.extraWindows.length || results.extraTabs.length) {
       ok(false, `Test left extra windows or tabs: ${JSON.stringify(results)}\n`);
     }
   });
 }
 
+let Assert = {
+  rejects(promise, msg) {
+    return promise.then(() => {
+      ok(false, msg);
+    }, () => {
+      ok(true, msg);
+    });
+  },
+};
+
 /* exported waitForLoad */
 
 function waitForLoad(win) {
   return new Promise(resolve => {
     win.addEventListener("load", function() {
       resolve();
     }, {capture: true, once: true});
   });
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html
@@ -0,0 +1,253 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>Test for protocol handlers</title>
+  <script type="text/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="text/javascript" src="chrome://mochikit/content/tests/SimpleTest/SpawnTask.js"></script>
+  <script type="text/javascript" src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+  <script type="text/javascript" src="chrome_head.js"></script>
+  <script type="text/javascript" src="head.js"></script>
+  <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+/* eslint-disable mozilla/balanced-listeners */
+/* global addMessageListener, sendAsyncMessage */
+
+add_task(function* test_protocolHandler() {
+  let extensionData = {
+    manifest: {
+      "protocol_handlers": [
+        {
+          "protocol": "ext+foo",
+          "name": "a foo protocol handler",
+          "uriTemplate": "foo.html?val=%s",
+        },
+      ],
+    },
+
+    background() {
+      browser.test.sendMessage("test-url", browser.runtime.getURL("foo.html"));
+    },
+
+    files: {
+      "foo.js": function() {
+        browser.test.sendMessage("test-query", location.search);
+      },
+      "foo.html": `<!DOCTYPE html>
+        <html>
+          <head>
+            <meta charset="utf-8">
+            <script src="foo.js"><\/script>
+          </head>
+        </html>`,
+    },
+  };
+
+  let extension = ExtensionTestUtils.loadExtension(extensionData);
+  yield extension.startup();
+  let handlerUrl = yield extension.awaitMessage("test-url");
+
+  // Ensure that the protocol handler is configured, and set it as default to
+  // bypass the dialog.
+  let chromeScript = SpecialPowers.loadChromeScript(() => {
+    const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+    addMessageListener("setup", () => {
+      let data = {};
+      const protoSvc = Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+                         .getService(Ci.nsIExternalProtocolService);
+      let protoInfo = protoSvc.getProtocolHandlerInfo("ext+foo");
+      data.preferredAction = protoInfo.preferredAction === protoInfo.useHelperApp;
+      data.preferredApplicationHandler = !protoInfo.preferredApplicationHandler;
+
+      let handlers = protoInfo.possibleApplicationHandlers;
+      data.handlers = handlers.length;
+
+      let handler = handlers.queryElementAt(0, Ci.nsIHandlerApp);
+      data.isWebHandler = handler instanceof Ci.nsIWebHandlerApp;
+      data.uriTemplate =  handler.uriTemplate;
+
+      protoInfo.preferredApplicationHandler = handler;
+      protoInfo.alwaysAskBeforeHandling = false;
+      const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"]
+                           .getService(Ci.nsIHandlerService);
+      handlerSvc.store(protoInfo);
+
+      sendAsyncMessage("handlerData", data);
+    });
+  });
+
+  let msg = chromeScript.promiseOneMessage("handlerData");
+  chromeScript.sendAsyncMessage("setup");
+  let data = yield msg;
+  ok(data.preferredAction, "using a helper application is the preferred action");
+  ok(data.preferredApplicationHandler, "no preferred handler is set");
+  is(data.handlers, 1, "one handler is set");
+  ok(data.isWebHandler, "the handler is a web handler");
+  is(data.uriTemplate, `${handlerUrl}?val=%s`, "correct url template");
+  chromeScript.destroy();
+
+  let win = window.open("ext+foo:test");
+  let query = yield extension.awaitMessage("test-query");
+  is(query, "?val=ext%2Bfoo%3Atest", "test query ok");
+  win.close();
+
+  // Shutdown the addon, then ensure the protocol was removed.
+  yield extension.unload();
+  chromeScript = SpecialPowers.loadChromeScript(() => {
+    const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+    addMessageListener("setup", () => {
+      const protoSvc = Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+                         .getService(Ci.nsIExternalProtocolService);
+      let protoInfo = protoSvc.getProtocolHandlerInfo("ext+foo");
+      sendAsyncMessage("preferredApplicationHandler", !protoInfo.preferredApplicationHandler);
+      let handlers = protoInfo.possibleApplicationHandlers;
+
+      sendAsyncMessage("handlerData", {
+        preferredApplicationHandler: !protoInfo.preferredApplicationHandler,
+        handlers: handlers.length,
+      });
+    });
+  });
+
+  msg = chromeScript.promiseOneMessage("handlerData");
+  chromeScript.sendAsyncMessage("setup");
+  data = yield msg;
+  ok(data.preferredApplicationHandler, "no preferred handler is set");
+  is(data.handlers, 0, "no handler is set");
+  chromeScript.destroy();
+});
+
+add_task(function* test_protocolHandler_https_target() {
+  let extensionData = {
+    manifest: {
+      "protocol_handlers": [
+        {
+          "protocol": "ext+foo",
+          "name": "http target",
+          "uriTemplate": "https://example.com/foo.html?val=%s",
+        },
+      ],
+    },
+  };
+
+  let extension = ExtensionTestUtils.loadExtension(extensionData);
+  yield extension.startup();
+  ok(true, "https uriTemplate target works");
+  yield extension.unload();
+});
+
+add_task(function* test_protocolHandler_http_target() {
+  let extensionData = {
+    manifest: {
+      "protocol_handlers": [
+        {
+          "protocol": "ext+foo",
+          "name": "http target",
+          "uriTemplate": "http://example.com/foo.html?val=%s",
+        },
+      ],
+    },
+  };
+
+  let extension = ExtensionTestUtils.loadExtension(extensionData);
+  yield extension.startup();
+  ok(true, "http uriTemplate target works");
+  yield extension.unload();
+});
+
+add_task(function* test_protocolHandler_restricted_protocol() {
+  let extensionData = {
+    manifest: {
+      "protocol_handlers": [
+        {
+          "protocol": "http",
+          "name": "take over the http protocol",
+          "uriTemplate": "http.html?val=%s",
+        },
+      ],
+    },
+  };
+
+  let waitForConsole = new Promise(resolve => {
+    SimpleTest.monitorConsole(resolve, [{message: /processing protocol_handlers\.0\.protocol/}]);
+  });
+
+  let extension = ExtensionTestUtils.loadExtension(extensionData);
+  yield Assert.rejects(extension.startup(), "unable to register restricted handler protocol");
+
+  SimpleTest.endMonitorConsole();
+  yield waitForConsole;
+});
+
+add_task(function* test_protocolHandler_restricted_uriTemplate() {
+  let extensionData = {
+    manifest: {
+      "protocol_handlers": [
+        {
+          "protocol": "ext+foo",
+          "name": "take over the http protocol",
+          "uriTemplate": "ftp://example.com/file.txt",
+        },
+      ],
+    },
+  };
+
+  let waitForConsole = new Promise(resolve => {
+    SimpleTest.monitorConsole(resolve, [{message: /processing protocol_handlers\.0\.uriTemplate/}]);
+  });
+
+  let extension = ExtensionTestUtils.loadExtension(extensionData);
+  yield Assert.rejects(extension.startup(), "unable to register restricted handler uriTemplate");
+
+  SimpleTest.endMonitorConsole();
+  yield waitForConsole;
+});
+
+add_task(function* test_protocolHandler_duplicate() {
+  let extensionData = {
+    manifest: {
+      "protocol_handlers": [
+        {
+          "protocol": "ext+foo",
+          "name": "foo protocol",
+          "uriTemplate": "foo.html?val=%s",
+        },
+        {
+          "protocol": "ext+foo",
+          "name": "foo protocol",
+          "uriTemplate": "foo.html?val=%s",
+        },
+      ],
+    },
+  };
+
+  let extension = ExtensionTestUtils.loadExtension(extensionData);
+  yield extension.startup();
+
+  // Get the count of handlers installed.
+  let chromeScript = SpecialPowers.loadChromeScript(() => {
+    const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+    addMessageListener("setup", () => {
+      const protoSvc = Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+                         .getService(Ci.nsIExternalProtocolService);
+      let protoInfo = protoSvc.getProtocolHandlerInfo("ext+foo");
+      let handlers = protoInfo.possibleApplicationHandlers;
+      sendAsyncMessage("handlerData", handlers.length);
+    });
+  });
+
+  let msg = chromeScript.promiseOneMessage("handlerData");
+  chromeScript.sendAsyncMessage("setup");
+  let data = yield msg;
+  is(data, 1, "cannot re-register the same handler config");
+  chromeScript.destroy();
+  yield extension.unload();
+});
+</script>
+
+</body>
+</html>
--- a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html
@@ -7,26 +7,16 @@
   <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
   <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
   <script type="text/javascript" src="head_webrequest.js"></script>
   <script type="text/javascript" src="head.js"></script>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
 <script>
 "use strict";
 
-let Assert = {
-  rejects(promise, msg) {
-    return promise.then(() => {
-      ok(false, msg);
-    }, () => {
-      ok(true, msg);
-    });
-  },
-};
-
 let baseUrl = "http://mochi.test:8888/tests/toolkit/components/passwordmgr/test/authenticate.sjs";
 function testXHR(url) {
   return new Promise((resolve, reject) => {
     let xhr = new XMLHttpRequest();
     xhr.open("GET", url);
     xhr.onload = resolve;
     xhr.onabort = reject;
     xhr.onerror = reject;