Bug 1245597 - implement the basics of chrome.downloads.download() r=kmag
☠☠ backed out by f5a59776ddd3 ☠ ☠
authorAndrew Swan <aswan@mozilla.com>
Sun, 21 Feb 2016 21:20:22 -0800
changeset 285517 418bf315261c3a3bd1e6a53cb653195439dc9091
parent 285516 0ed2152fd50bd4655adf35c77e26ab64c15181a1
child 285518 f3dcf982a76f82ead9dcf2e0a116e2dda2d8aed7
push id72403
push usercbook@mozilla.com
push dateThu, 25 Feb 2016 10:59:17 +0000
treeherdermozilla-inbound@3b913f81cb98 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerskmag
bugs1245597
milestone47.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1245597 - implement the basics of chrome.downloads.download() r=kmag
toolkit/components/extensions/ext-downloads.js
toolkit/components/extensions/moz.build
toolkit/components/extensions/schemas/downloads.json
toolkit/components/extensions/test/mochitest/chrome.ini
toolkit/components/extensions/test/mochitest/file_download.txt
toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_download.html
--- a/toolkit/components/extensions/ext-downloads.js
+++ b/toolkit/components/extensions/ext-downloads.js
@@ -1,20 +1,110 @@
 "use strict";
 
 var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Downloads",
+                                  "resource://gre/modules/Downloads.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "DownloadPaths",
+                                  "resource://gre/modules/DownloadPaths.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "OS",
+                                  "resource://gre/modules/osfile.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
+                                  "resource://gre/modules/FileUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+                                  "resource://gre/modules/NetUtil.jsm");
+
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 const {
   ignoreEvent,
 } = ExtensionUtils;
 
+let currentId = 0;
+
 extensions.registerSchemaAPI("downloads", "downloads", (extension, context) => {
   return {
     downloads: {
+      download(options) {
+        if (options.filename != null) {
+          if (options.filename.length == 0) {
+            return Promise.reject({message: "filename must not be empty"});
+          }
+
+          let path = OS.Path.split(options.filename);
+          if (path.absolute) {
+            return Promise.reject({message: "filename must not be an absolute path"});
+          }
+
+          if (path.components.some(component => component == "..")) {
+            return Promise.reject({message: "filename must not contain back-references (..)"});
+          }
+        }
+
+        if (options.conflictAction == "prompt") {
+          // TODO
+          return Promise.reject({message: "conflictAction prompt not yet implemented"});
+        }
+
+        function createTarget(downloadsDir) {
+          // TODO
+          // if (options.saveAs) { }
+
+          let target;
+          if (options.filename) {
+            target = OS.Path.join(downloadsDir, options.filename);
+          } else {
+            let uri = NetUtil.newURI(options.url).QueryInterface(Ci.nsIURL);
+            target = OS.Path.join(downloadsDir, uri.fileName);
+          }
+
+          // This has a race, something else could come along and create
+          // the file between this test and them time the download code
+          // creates the target file.  But we can't easily fix it without
+          // modifying DownloadCore so we live with it for now.
+          return OS.File.exists(target).then(exists => {
+            if (exists) {
+              switch (options.conflictAction) {
+                case "uniquify":
+                default:
+                  target = DownloadPaths.createNiceUniqueFile(new FileUtils.File(target)).path;
+                  break;
+
+                case "overwrite":
+                  break;
+              }
+            }
+            return target;
+          });
+        }
+
+        let download;
+        return Downloads.getPreferredDownloadsDirectory()
+          .then(downloadsDir => createTarget(downloadsDir))
+          .then(target => Downloads.createDownload({
+            source: options.url,
+            target: target,
+          })).then(dl => {
+            download = dl;
+            return Downloads.getList(Downloads.ALL);
+          }).then(list => {
+            list.add(download);
+
+            // This is necessary to make pause/resume work.
+            download.tryToKeepPartialData = true;
+            download.start();
+
+            // Without other chrome.downloads methods, we can't actually
+            // do anything with the id so just return a dummy value for now.
+            return currentId++;
+          });
+      },
+
       // When we do open(), check for additional downloads.open permission.
       // i.e.:
       // open(downloadId) {
       //   if (!extension.hasPermission("downloads.open")) {
       //     throw new context.cloneScope.Error("Permission denied because 'downloads.open' permission is missing.");
       //   }
       //   ...
       // }
--- a/toolkit/components/extensions/moz.build
+++ b/toolkit/components/extensions/moz.build
@@ -14,9 +14,10 @@ EXTRA_JS_MODULES += [
     'Schemas.jsm',
 ]
 
 DIRS += ['schemas']
 
 JAR_MANIFESTS += ['jar.mn']
 
 MOCHITEST_MANIFESTS += ['test/mochitest/mochitest.ini']
+MOCHITEST_CHROME_MANIFESTS += ['test/mochitest/chrome.ini']
 XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell/xpcshell.ini']
--- a/toolkit/components/extensions/schemas/downloads.json
+++ b/toolkit/components/extensions/schemas/downloads.json
@@ -17,17 +17,17 @@
   },
   {
     "namespace": "downloads",
     "types": [
       {
         "id": "FilenameConflictAction",
         "type": "string",
         "enum": [
-          "uniqify",
+          "uniquify",
           "overwrite",
           "prompt"
         ]
       },
       {
         "id": "InterruptReason",
         "type": "string",
         "enum": [
@@ -209,52 +209,56 @@
           }
         }
       }
     ],
     "functions": [
       {
         "name": "download",
         "type": "function",
-        "unsupported": true,
+        "async": "callback",
         "description": "Download a URL. If the URL uses the HTTP[S] protocol, then the request will include all cookies currently set for its hostname. If both <code>filename</code> and <code>saveAs</code> are specified, then the Save As dialog will be displayed, pre-populated with the specified <code>filename</code>. If the download started successfully, <code>callback</code> will be called with the new <a href='#type-DownloadItem'>DownloadItem</a>'s <code>downloadId</code>. If there was an error starting the download, then <code>callback</code> will be called with <code>downloadId=undefined</code> and <a href='extension.html#property-lastError'>chrome.extension.lastError</a> will contain a descriptive string. The error strings are not guaranteed to remain backwards compatible between releases. You must not parse it.",
         "parameters": [
           {
             "description": "What to download and how.",
             "name": "options",
             "type": "object",
             "properties": {
               "url": {
                 "description": "The URL to download.",
-                "type": "string"
+                "type": "string",
+                "format": "url"
               },
               "filename": {
                 "description": "A file path relative to the Downloads directory to contain the downloaded file.",
                 "optional": true,
                 "type": "string"
               },
               "conflictAction": {
                 "$ref": "FilenameConflictAction",
                 "optional": true
               },
               "saveAs": {
+                "unsupported": true,
                 "description": "Use a file-chooser to allow the user to select a filename.",
                 "optional": true,
                 "type": "boolean"
               },
               "method": {
+                "unsupported": true,
                 "description": "The HTTP method to use if the URL uses the HTTP[S] protocol.",
                 "enum": [
                   "GET",
                   "POST"
                 ],
                 "optional": true,
                 "type": "string"
               },
               "headers": {
+                "unsupported": true,
                 "optional": true,
                 "type": "array",
                 "description": "Extra HTTP headers to send with the request if the URL uses the HTTP[s] protocol. Each header is represented as a dictionary containing the keys <code>name</code> and either <code>value</code> or <code>binaryValue</code>, restricted to those allowed by XMLHttpRequest.",
                 "items": {
                   "type": "object",
                   "properties": {
                     "name": {
                       "description": "Name of the HTTP header.",
@@ -263,16 +267,17 @@
                     "value": {
                       "description": "Value of the HTTP header.",
                       "type": "string"
                     }
                   }
                 }
               },
               "body": {
+                "unsupported": true,
                 "description": "Post body.",
                 "optional": true,
                 "type": "string"
               }
             }
           },
           {
             "name": "callback",
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/chrome.ini
@@ -0,0 +1,5 @@
+[DEFAULT]
+support-files =
+  file_download.txt
+
+[test_chrome_ext_downloads_download.html]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_download.txt
@@ -0,0 +1,1 @@
+This is a sample file used in download tests.
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_download.html
@@ -0,0 +1,221 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>WebExtension test</title>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SpawnTask.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+  <script type="text/javascript" src="head.js"></script>
+  <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const {
+  interfaces: Ci,
+  utils: Cu,
+} = Components;
+
+/* global OS */
+
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource://gre/modules/FileUtils.jsm");
+Cu.import("resource://gre/modules/Downloads.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+const WINDOWS = (AppConstants.platform == "win");
+
+const BASE = "http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest";
+const FILE_NAME = "file_download.txt";
+const FILE_URL = BASE + "/" + FILE_NAME;
+const FILE_NAME_UNIQUE = "file_download(1).txt";
+const FILE_LEN = 46;
+
+let downloadDir;
+
+function setup() {
+  downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+  downloadDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+  info(`Using download directory ${downloadDir.path}`);
+
+  Services.prefs.setIntPref("browser.download.folderList", 2);
+  Services.prefs.setComplexValue("browser.download.dir", Ci.nsIFile, downloadDir);
+
+  SimpleTest.registerCleanupFunction(() => {
+    Services.prefs.clearUserPref("browser.download.folderList");
+    Services.prefs.clearUserPref("browser.download.dir");
+  });
+}
+
+function backgroundScript() {
+  browser.test.onMessage.addListener(function(msg) {
+    if (msg == "download.request") {
+      // download() throws on bad arguments, we can remove the extra
+      // promise when bug 1250223 is fixed.
+      return Promise.resolve().then(() => browser.downloads.download(arguments[1]))
+             .then((id) => browser.test.sendMessage("download.done", {status: "success", id}))
+             .catch(error => browser.test.sendMessage("download.done", {status: "error", errmsg: error.message}));
+    }
+  });
+
+  browser.test.sendMessage("ready");
+}
+
+// This function is a bit of a sledgehammer, it looks at every download
+// the browser knows about and waits for all active downloads to complete.
+// But we only start one at a time and only do a handful in total, so
+// this lets us test download() without depending on anything else.
+function waitForDownloads() {
+  return Downloads.getList(Downloads.ALL)
+                  .then(list => list.getAll())
+                  .then(downloads => {
+                    let inprogress = downloads.filter(dl => !dl.stopped);
+                    return Promise.all(inprogress.map(dl => dl.whenSucceeded()));
+                  });
+}
+
+// Create a file in the downloads directory.
+function touch(filename) {
+  let file = downloadDir.clone();
+  file.append(filename);
+  file.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+}
+
+// Remove a file in the downloads directory.
+function remove(filename) {
+  let file = downloadDir.clone();
+  file.append(filename);
+  file.remove(false);
+}
+
+add_task(function* test_downloads() {
+  setup();
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: `(${backgroundScript})()`,
+    manifest: {
+      permissions: ["downloads"],
+    },
+  });
+
+  function download(options) {
+    extension.sendMessage("download.request", options);
+    return extension.awaitMessage("download.done");
+  }
+
+  function testDownload(options, localFile, expectedSize, description) {
+    return download(options).then(msg => {
+      is(msg.status, "success", `downloads.download() works with ${description}`);
+      return waitForDownloads();
+    }).then(() => {
+      let localPath = downloadDir.clone();
+      localPath.append(localFile);
+      is(localPath.fileSize, expectedSize, "Downloaded file has expected size");
+      localPath.remove(false);
+    });
+  }
+
+  yield extension.startup();
+  yield extension.awaitMessage("ready");
+  info("extension started");
+
+  // Call download() with just the url property.
+  yield testDownload({url: FILE_URL}, FILE_NAME, FILE_LEN, "just source");
+
+  // Call download() with a filename property.
+  yield testDownload({
+    url: FILE_URL,
+    filename: "newpath.txt",
+  }, "newpath.txt", FILE_LEN, "source and filename");
+
+  // Check conflictAction of "uniquify".
+  touch(FILE_NAME);
+  yield testDownload({
+    url: FILE_URL,
+    conflictAction: "uniquify",
+  }, FILE_NAME_UNIQUE, FILE_LEN, "conflictAction=uniquify");
+  // todo check that preexisting file was not modified?
+  remove(FILE_NAME);
+
+  // Check conflictAction of "overwrite".
+  touch(FILE_NAME);
+  yield testDownload({
+    url: FILE_URL,
+    conflictAction: "overwrite",
+  }, FILE_NAME, FILE_LEN, "conflictAction=overwrite");
+
+  // Try to download in invalid url
+  yield download({url: "this is not a valid URL"}).then(msg => {
+    is(msg.status, "error", "downloads.download() fails with invalid url");
+    ok(/not a valid URL/.test(msg.errmsg), "error message for invalid url is correct");
+  });
+
+  // Try to download to an empty path.
+  yield download({
+    url: FILE_URL,
+    filename: "",
+  }).then(msg => {
+    is(msg.status, "error", "downloads.download() fails with empty filename");
+    is(msg.errmsg, "filename must not be empty", "error message for empty filename is correct");
+  });
+
+  // Try to download to an absolute path.
+  const absolutePath = OS.Path.join(WINDOWS ? "\\tmp" : "/tmp", "file_download.txt");
+  yield download({
+    url: FILE_URL,
+    filename: absolutePath,
+  }).then(msg => {
+    is(msg.status, "error", "downloads.download() fails with absolute filename");
+    is(msg.errmsg, "filename must not be an absolute path", `error message for absolute path (${absolutePath}) is correct`);
+  });
+
+  if (WINDOWS) {
+    yield download({
+      url: FILE_URL,
+      filename: "C:\\file_download.txt",
+    }).then(msg => {
+      is(msg.status, "error", "downloads.download() fails with absolute filename");
+      is(msg.errmsg, "filename must not be an absolute path", "error message for absolute path with drive letter is correct");
+    });
+  }
+
+  // Try to download to a relative path containing ..
+  yield download({
+    url: FILE_URL,
+    filename: OS.Path.join("..", "file_download.txt"),
+  }).then(msg => {
+    is(msg.status, "error", "downloads.download() fails with back-references");
+    is(msg.errmsg, "filename must not contain back-references (..)", "error message for back-references is correct");
+  });
+
+  // Try to download to a long relative path containing ..
+  yield download({
+    url: FILE_URL,
+    filename: OS.Path.join("foo", "..", "..", "file_download.txt"),
+  }).then(msg => {
+    is(msg.status, "error", "downloads.download() fails with back-references");
+    is(msg.errmsg, "filename must not contain back-references (..)", "error message for back-references is correct");
+  });
+
+  yield extension.unload();
+});
+
+// check for leftover files in the download directory
+add_task(function*() {
+  let entries = downloadDir.directoryEntries;
+  while (entries.hasMoreElements()) {
+    let entry = entries.getNext().QueryInterface(Ci.nsIFile);
+    ok(false, `Leftover file ${entry.path} in download directory`);
+    entry.remove(false);
+  }
+
+  downloadDir.remove(false);
+});
+
+</script>
+
+</body>
+</html>