Bug 1245597 - implement the basics of chrome.downloads.download(). r=kmag
authorAndrew Swan <aswan@mozilla.com>
Wed, 24 Feb 2016 11:16:32 -0800
changeset 321923 5b53d1dcb00952e51a7f298c9b7cd77b7634e9d2
parent 321922 96732e2e7174c1085ed2f81dfc9fdaff10e0e712
child 321924 3dfcbfa56b0e1c182f74e761d70efbb707c1199d
push id5913
push userjlund@mozilla.com
push dateMon, 25 Apr 2016 16:57:49 +0000
treeherdermozilla-beta@dcaf0a6fa115 [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,6 @@
+[DEFAULT]
+skip-if = os == 'android'
+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>