From f4ea4e85aa0236b62d9c0d0c22c0eb009114b1d3 Mon Sep 17 00:00:00 2001 From: Andrew Sutherland Date: Tue, 24 Feb 2015 11:06:59 -0500 Subject: [PATCH] Bug 825318 - Implement adoptDownload for mozDownloadManager, r=aus, r=sicking Implement mozDownloadManager.adoptDownload as a certified-only API. This also fixes and re-enables many of the existing dom/downloads tests failures by virtue of cleanup and not running them on non-gonk toolkits where exceptions will be thrown and things will fail. This should resolve bug 979446 about re-enabling the tests. --- dom/downloads/DownloadsAPI.js | 105 +++++++-- dom/downloads/DownloadsAPI.jsm | 119 +++++++++- dom/downloads/DownloadsIPC.jsm | 29 +-- dom/downloads/tests/clear_all_done_helper.js | 67 ++++++ dom/downloads/tests/common_app.js | 19 ++ dom/downloads/tests/file_app.sjs | 55 +++++ dom/downloads/tests/file_app.template.webapp | 7 + dom/downloads/tests/mochitest.ini | 16 +- dom/downloads/tests/shim_app_as_test.js | 210 +++++++++++++++++ .../tests/shim_app_as_test_chrome.js | 178 ++++++++++++++ .../tests/test_downloads_adopt_download.html | 34 +++ dom/downloads/tests/test_downloads_basic.html | 7 +- dom/downloads/tests/test_downloads_large.html | 5 +- .../tests/test_downloads_pause_remove.html | 3 +- .../tests/test_downloads_pause_resume.html | 6 +- .../testapp_downloads_adopt_download.html | 14 ++ .../tests/testapp_downloads_adopt_download.js | 218 ++++++++++++++++++ .../testapp_downloads_adopt_download.manifest | 10 + dom/webidl/Downloads.webidl | 79 ++++++- 19 files changed, 1123 insertions(+), 58 deletions(-) create mode 100644 dom/downloads/tests/clear_all_done_helper.js create mode 100644 dom/downloads/tests/common_app.js create mode 100644 dom/downloads/tests/file_app.sjs create mode 100644 dom/downloads/tests/file_app.template.webapp create mode 100644 dom/downloads/tests/shim_app_as_test.js create mode 100644 dom/downloads/tests/shim_app_as_test_chrome.js create mode 100644 dom/downloads/tests/test_downloads_adopt_download.html create mode 100644 dom/downloads/tests/testapp_downloads_adopt_download.html create mode 100644 dom/downloads/tests/testapp_downloads_adopt_download.js create mode 100644 dom/downloads/tests/testapp_downloads_adopt_download.manifest diff --git a/dom/downloads/DownloadsAPI.js b/dom/downloads/DownloadsAPI.js index daf3c95ed0e5..48e3edcb72b1 100644 --- a/dom/downloads/DownloadsAPI.js +++ b/dom/downloads/DownloadsAPI.js @@ -21,6 +21,12 @@ XPCOMUtils.defineLazyServiceGetter(this, "volumeService", "@mozilla.org/telephony/volume-service;1", "nsIVolumeService"); +/** + * The content process implementations of navigator.mozDownloadManager and its + * DOMDownload download objects. Uses DownloadsIPC.jsm to communicate with + * DownloadsAPI.jsm in the parent process. + */ + function debug(aStr) { #ifdef MOZ_DEBUG dump("-*- DownloadsAPI.js : " + aStr + "\n"); @@ -40,6 +46,15 @@ DOMDownloadManagerImpl.prototype = { this.initDOMRequestHelper(aWindow, ["Downloads:Added", "Downloads:Removed"]); + + // Get the manifest URL if this is an installed app + let appsService = Cc["@mozilla.org/AppsService;1"] + .getService(Ci.nsIAppsService); + let principal = aWindow.document.nodePrincipal; + // This returns the empty string if we're not an installed app. Coerce to + // null. + this._manifestURL = appsService.getManifestURLByLocalId(principal.appId) || + null; }, uninit: function() { @@ -79,23 +94,8 @@ DOMDownloadManagerImpl.prototype = { clearAllDone: function() { debug("clearAllDone()"); - return this.createPromise(function (aResolve, aReject) { - DownloadsIPC.clearAllDone().then( - function(aDownloads) { - // Turn the list of download objects into DOM objects and - // send them. - let array = new this._window.Array(); - for (let id in aDownloads) { - let dom = createDOMDownloadObject(this._window, aDownloads[id]); - array.push(this._prepareForContent(dom)); - } - aResolve(array); - }.bind(this), - function() { - aReject("ClearAllDoneError"); - } - ); - }.bind(this)); + // This is a void function; we just kick it off. No promises, etc. + DownloadsIPC.clearAllDone(); }, remove: function(aDownload) { @@ -121,6 +121,67 @@ DOMDownloadManagerImpl.prototype = { }.bind(this)); }, + adoptDownload: function(aAdoptDownloadDict) { + // Our AdoptDownloadDict only includes simple types, which WebIDL enforces. + // We have no object/any types so we do not need to worry about invoking + // JSON.stringify (and it inheriting our security privileges). + debug("adoptDownload"); + return this.createPromise(function (aResolve, aReject) { + if (!aAdoptDownloadDict) { + debug("Download dictionary is required!"); + aReject("InvalidDownload"); + return; + } + if (!aAdoptDownloadDict.storageName || !aAdoptDownloadDict.storagePath || + !aAdoptDownloadDict.contentType) { + debug("Missing one of: storageName, storagePath, contentType"); + aReject("InvalidDownload"); + return; + } + + // Convert storageName/storagePath to a local filesystem path. + let volume; + // getVolumeByName throws if you give it something it doesn't like + // because XPConnect converts the NS_ERROR_NOT_AVAILABLE to an + // exception. So catch it. + try { + volume = volumeService.getVolumeByName(aAdoptDownloadDict.storageName); + } catch (ex) {} + if (!volume) { + debug("Invalid storage name: " + aAdoptDownloadDict.storageName); + aReject("InvalidDownload"); + return; + } + let computedPath = volume.mountPoint + '/' + + aAdoptDownloadDict.storagePath; + // We validate that there is actually a file at the given path in the + // parent process in DownloadsAPI.js because that's where the file + // access would actually occur either way. + + // Create a DownloadsAPI.jsm 'jsonDownload' style representation. + let jsonDownload = { + url: aAdoptDownloadDict.url, + path: computedPath, + contentType: aAdoptDownloadDict.contentType, + startTime: aAdoptDownloadDict.startTime.valueOf() || Date.now(), + sourceAppManifestURL: this._manifestURL + }; + + DownloadsIPC.adoptDownload(jsonDownload).then( + function(aResult) { + let domDownload = createDOMDownloadObject(this._window, aResult); + aResolve(this._prepareForContent(domDownload)); + }.bind(this), + function(aResult) { + // This will be one of: AdoptError (generic catch-all), + // AdoptNoSuchFile, AdoptFileIsDirectory + aReject(aResult.error); + } + ); + }.bind(this)); + }, + + /** * Turns a chrome download object into a content accessible one. * When we have __DOM_IMPL__ available we just use that, otherwise @@ -295,6 +356,11 @@ DOMDownloadImpl.prototype = { } }, + /** + * Initialize a DOMDownload instance for the given window using the + * 'jsonDownload' serialized format of the download encoded by + * DownloadsAPI.jsm. + */ _init: function(aWindow, aDownload) { this._window = aWindow; this.id = aDownload.id; @@ -314,12 +380,13 @@ DOMDownloadImpl.prototype = { } let props = ["totalBytes", "currentBytes", "url", "path", "storageName", - "storagePath", "state", "contentType", "startTime"]; + "storagePath", "state", "contentType", "startTime", + "sourceAppManifestURL"]; let changed = false; let changedProps = {}; props.forEach((prop) => { - if (aDownload[prop] && (aDownload[prop] != this[prop])) { + if (prop in aDownload && (aDownload[prop] != this[prop])) { this[prop] = aDownload[prop]; changedProps[prop] = changed = true; } diff --git a/dom/downloads/DownloadsAPI.jsm b/dom/downloads/DownloadsAPI.jsm index 41a6db26bdda..511ff92c919d 100644 --- a/dom/downloads/DownloadsAPI.jsm +++ b/dom/downloads/DownloadsAPI.jsm @@ -13,11 +13,19 @@ this.EXPORTED_SYMBOLS = []; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Downloads.jsm"); Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); XPCOMUtils.defineLazyServiceGetter(this, "ppmm", "@mozilla.org/parentprocessmessagemanager;1", "nsIMessageBroadcaster"); +/** + * Parent process logic that services download API requests from the + * DownloadAPI.js instances in content processeses. The actual work of managing + * downloads is done by Toolkit's Downloads.jsm. This module is loaded by B2G's + * shell.js + */ + function debug(aStr) { #ifdef MOZ_DEBUG dump("-*- DownloadsAPI.jsm : " + aStr + "\n"); @@ -49,7 +57,8 @@ let DownloadsAPI = { "Downloads:ClearAllDone", "Downloads:Remove", "Downloads:Pause", - "Downloads:Resume"].forEach((msgName) => { + "Downloads:Resume", + "Downloads:Adopt"].forEach((msgName) => { ppmm.addMessageListener(msgName, this); }); @@ -92,7 +101,9 @@ let DownloadsAPI = { url: aDownload.source.url, path: aDownload.target.path, contentType: aDownload.contentType, - startTime: aDownload.startTime.getTime() + startTime: aDownload.startTime.getTime(), + sourceAppManifestURL: aDownload._unknownProperties && + aDownload._unknownProperties.sourceAppManifestURL }; if (aDownload.error) { @@ -165,6 +176,9 @@ let DownloadsAPI = { case "Downloads:Resume": this.resume(aMessage.data, aMessage.target); break; + case "Downloads:Adopt": + this.adoptDownload(aMessage.data, aMessage.target); + break; default: debug("Invalid message: " + aMessage.name); } @@ -186,17 +200,9 @@ let DownloadsAPI = { clearAllDone: function(aData, aMm) { debug("clearAllDone called!"); - let self = this; Task.spawn(function () { let list = yield Downloads.getList(Downloads.ALL); - yield list.removeFinished(); - list = yield Downloads.getList(Downloads.ALL); - let downloads = yield list.getAll(); - let res = []; - downloads.forEach((aDownload) => { - res.push(self.jsonDownload(aDownload)); - }); - aMm.sendAsyncMessage("Downloads:ClearAllDone:Return", res); + list.removeFinished(); }).then(null, Components.utils.reportError); }, @@ -262,6 +268,97 @@ let DownloadsAPI = { aData, "ResumeError"); } ); + }, + + /** + * Receive a download to adopt in the same representation we produce from + * our "jsonDownload" normalizer and add it to the list of downloads. + */ + adoptDownload: function(aData, aMm) { + let adoptJsonRep = aData.jsonDownload; + debug("adoptDownload " + uneval(adoptJsonRep)); + + Task.spawn(function* () { + // Verify that the file exists on disk. This will result in a rejection + // if the file does not exist. We will also use this information for the + // file size to avoid weird inconsistencies. We ignore the filesystem + // timestamp in favor of whatever the caller is telling us. + let fileInfo = yield OS.File.stat(adoptJsonRep.path); + + // We also require that the file is not a directory. + if (fileInfo.isDir) { + throw new Error("AdoptFileIsDirectory"); + } + + // We need to create a Download instance to add to the list. Create a + // serialized representation and then from there the instance. + let serializedRep = { + // explicit initializations in toSerializable + source: { + url: adoptJsonRep.url + // This is where isPrivate would go if adoption supported private + // browsing. + }, + target: { + path: adoptJsonRep.path, + }, + startTime: adoptJsonRep.startTime, + // kPlainSerializableDownloadProperties propagations + succeeded: true, // (all adopted downloads are required to be completed) + totalBytes: fileInfo.size, + contentType: adoptJsonRep.contentType, + // unknown properties added/used by the DownloadsAPI + currentBytes: fileInfo.size, + sourceAppManifestURL: adoptJsonRep.sourceAppManifestURL + }; + + let download = yield Downloads.createDownload(serializedRep); + + // The ALL list is a DownloadCombinedList instance that combines the + // PUBLIC (persisted to disk) and PRIVATE (ephemeral) download lists.. + // When we call add on it, it dispatches to the appropriate list based on + // the 'isPrivate' field of the source. (Which we don't initialize and + // defaults to false.) + let allDownloadList = yield Downloads.getList(Downloads.ALL); + + // This add will automatically notify all views of the added download, + // including DownloadsAPI instances and the DownloadAutoSaveView that's + // subscribed to the PUBLIC list and will save the download. + yield allDownloadList.add(download); + + debug("download adopted"); + // The notification above occurred synchronously, and so we will have + // already dispatched an added notification for our download to the child + // process in question. As such, we only need to relay the download id + // since the download will already have been cached. + return download; + }.bind(this)).then( + (download) => { + sendPromiseMessage(aMm, "Downloads:Adopt:Return", + { + id: this.downloadId(download), + promiseId: aData.promiseId + }); + }, + (ex) => { + let reportAs = "AdoptError"; + // Provide better error codes for expected errors. + if (ex instanceof OS.File.Error && ex.becauseNoSuchFile) { + reportAs = "AdoptNoSuchFile"; + } else if (ex.message === "AdoptFileIsDirectory") { + reportAs = ex.message; + } else { + // Anything else is unexpected and should be reported to help track + // down what's going wrong. + debug("unexpected download error: " + ex); + Cu.reportError(ex); + } + sendPromiseMessage(aMm, "Downloads:Adopt:Return", + { + promiseId: aData.promiseId + }, + reportAs); + }); } }; diff --git a/dom/downloads/DownloadsIPC.jsm b/dom/downloads/DownloadsIPC.jsm index 723e83de6601..0e290abf412f 100644 --- a/dom/downloads/DownloadsIPC.jsm +++ b/dom/downloads/DownloadsIPC.jsm @@ -36,10 +36,10 @@ const ipcMessages = ["Downloads:Added", "Downloads:Removed", "Downloads:Changed", "Downloads:GetList:Return", - "Downloads:ClearAllDone:Return", "Downloads:Remove:Return", "Downloads:Pause:Return", - "Downloads:Resume:Return"]; + "Downloads:Resume:Return", + "Downloads:Adopt:Return"]; this.DownloadsIPC = { downloads: {}, @@ -54,7 +54,6 @@ this.DownloadsIPC = { // We need to get the list of current downloads. this.ready = false; this.getListPromises = []; - this.clearAllPromises = []; this.downloadPromises = {}; cpmm.sendAsyncMessage("Downloads:GetList", {}); this._promiseId = 0; @@ -93,12 +92,6 @@ this.DownloadsIPC = { } this.ready = true; break; - case "Downloads:ClearAllDone:Return": - this._updateDownloadsArray(download); - this.clearAllPromises.forEach(aPromise => - aPromise.resolve(this.downloads)); - this.clearAllPromises.length = 0; - break; case "Downloads:Added": this.downloads[download.id] = download; this.notifyChanges(download.id); @@ -139,6 +132,7 @@ this.DownloadsIPC = { case "Downloads:Remove:Return": case "Downloads:Pause:Return": case "Downloads:Resume:Return": + case "Downloads:Adopt:Return": if (this.downloadPromises[download.promiseId]) { if (!download.error) { this.downloadPromises[download.promiseId].resolve(download); @@ -167,14 +161,11 @@ this.DownloadsIPC = { }, /** - * Returns a promise that is resolved with the list of current downloads. - */ + * Void function to trigger removal of completed downloads. + */ clearAllDone: function() { debug("clearAllDone"); - let deferred = Promise.defer(); - this.clearAllPromises.push(deferred); cpmm.sendAsyncMessage("Downloads:ClearAllDone", {}); - return deferred.promise; }, promiseId: function() { @@ -211,6 +202,16 @@ this.DownloadsIPC = { return deferred.promise; }, + adoptDownload: function(aJsonDownload) { + debug("adoptDownload"); + let deferred = Promise.defer(); + let pId = this.promiseId(); + this.downloadPromises[pId] = deferred; + cpmm.sendAsyncMessage("Downloads:Adopt", + { jsonDownload: aJsonDownload, promiseId: pId }); + return deferred.promise; + }, + observe: function(aSubject, aTopic, aData) { if (aTopic == "xpcom-shutdown") { ipcMessages.forEach((aMessage) => { diff --git a/dom/downloads/tests/clear_all_done_helper.js b/dom/downloads/tests/clear_all_done_helper.js new file mode 100644 index 000000000000..62fa1a2f30d3 --- /dev/null +++ b/dom/downloads/tests/clear_all_done_helper.js @@ -0,0 +1,67 @@ +/** + * A helper to clear out the existing downloads known to the mozDownloadManager + * / downloads.js. + * + * It exists because previously mozDownloadManager.clearAllDone() thought that + * when it returned that all the completed downloads would be cleared out. It + * was wrong and this led to various intermittent test failurse. In discussion + * on https://bugzil.la/979446#c13 and onwards, it was decided that + * clearAllDone() was in the wrong and that the jsdownloads API it depends on + * was not going to change to make it be in the right. + * + * The existing uses of clearAllDone() in tests seemed to be about: + * - Exploding if there was somehow still a download in progress + * - Clearing out the download list at the start of a test so that calls to + * getDownloads() wouldn't have to worry about existing downloads, etc. + * + * From discussion, the right way to handle clearing is to wait for the expected + * removal events to occur for the existing downloads. So that's what we do. + * We still generate a test failure if there are any in-progress downloads. + * + * @param {Boolean} [getDownloads=false] + * If true, invoke getDownloads after clearing the download list and return + * its value. + */ +function clearAllDoneHelper(getDownloads) { + var clearedPromise = new Promise(function(resolve, reject) { + function gotDownloads(downloads) { + // If there are no downloads, we're already done. + if (downloads.length === 0) { + resolve(); + return; + } + + // Track the set of expected downloads that will be finalized. + var expectedIds = new Set(); + function changeHandler(evt) { + var download = evt.download; + if (download.state === "finalized") { + expectedIds.delete(download.id); + if (expectedIds.size === 0) { + resolve(); + } + } + } + downloads.forEach(function(download) { + if (download.state === "downloading") { + ok(false, "A download is still active: " + download.path); + reject("Active download"); + } + download.onstatechange = changeHandler; + expectedIds.add(download.id); + }); + navigator.mozDownloadManager.clearAllDone(); + } + function gotBadNews(err) { + ok(false, "Problem clearing all downloads: " + err); + reject(err); + } + navigator.mozDownloadManager.getDownloads().then(gotDownloads, gotBadNews); + }); + if (!getDownloads) { + return clearedPromise; + } + return clearedPromise.then(function() { + return navigator.mozDownloadManager.getDownloads(); + }); +} diff --git a/dom/downloads/tests/common_app.js b/dom/downloads/tests/common_app.js new file mode 100644 index 000000000000..f66b154d5e30 --- /dev/null +++ b/dom/downloads/tests/common_app.js @@ -0,0 +1,19 @@ +function is(a, b, msg) { + alert((a === b ? 'OK' : 'KO') + ' ' + a + ' should equal ' + b + ': ' + msg); +} + +function ok(a, msg) { + alert((a ? 'OK' : 'KO')+ ' ' + msg); +} + +function info(msg) { + alert('INFO ' + msg); +} + +function cbError() { + alert('KO error'); +} + +function finish() { + alert('DONE'); +} diff --git a/dom/downloads/tests/file_app.sjs b/dom/downloads/tests/file_app.sjs new file mode 100644 index 000000000000..2ce4f7d6947f --- /dev/null +++ b/dom/downloads/tests/file_app.sjs @@ -0,0 +1,55 @@ +var gBasePath = "tests/dom/downloads/tests/"; +var gTemplate = "file_app.template.webapp"; + +function handleRequest(request, response) { + var query = getQuery(request); + + var testToken = query.testToken || ''; + var appType = query.appType || 'web'; + + var template = gBasePath + gTemplate; + response.setHeader("Content-Type", "application/x-web-app-manifest+json", false); + var body = readTemplate(template) + .replace(/TESTTOKEN/g, testToken) + .replace(/APPTYPE/g, appType); + response.write(); +} + +// Copy-pasted incantations. There ought to be a better way to synchronously read +// a file into a string, but I guess we're trying to discourage that. +function readTemplate(path) { + var file = Components.classes["@mozilla.org/file/directory_service;1"]. + getService(Components.interfaces.nsIProperties). + get("CurWorkD", Components.interfaces.nsILocalFile); + var fis = Components.classes['@mozilla.org/network/file-input-stream;1']. + createInstance(Components.interfaces.nsIFileInputStream); + var cis = Components.classes["@mozilla.org/intl/converter-input-stream;1"]. + createInstance(Components.interfaces.nsIConverterInputStream); + var split = path.split("/"); + for(var i = 0; i < split.length; ++i) { + file.append(split[i]); + } + fis.init(file, -1, -1, false); + cis.init(fis, "UTF-8", 0, 0); + + var data = ""; + let (str = {}) { + let read = 0; + do { + read = cis.readString(0xffffffff, str); // read as much as we can and put it in str.value + data += str.value; + } while (read != 0); + } + cis.close(); + return data; +} + +function getQuery(request) { + var query = {}; + request.queryString.split('&').forEach(function (val) { + var [name, value] = val.split('='); + query[name] = unescape(value); + }); + return query; +} + diff --git a/dom/downloads/tests/file_app.template.webapp b/dom/downloads/tests/file_app.template.webapp new file mode 100644 index 000000000000..598a9410b288 --- /dev/null +++ b/dom/downloads/tests/file_app.template.webapp @@ -0,0 +1,7 @@ +{ + "name": "Really Rapid Release (hosted)", + "description": "Updated even faster than Firefox, just to annoy slashdotters.", + "type": "APPTYPE", + "launch_path": "/tests/dom/downloads/tests/TESTTOKEN", + "icons": { "128": "default_icon" } +} diff --git a/dom/downloads/tests/mochitest.ini b/dom/downloads/tests/mochitest.ini index aee62b639434..3c85d2839899 100644 --- a/dom/downloads/tests/mochitest.ini +++ b/dom/downloads/tests/mochitest.ini @@ -1,12 +1,24 @@ [DEFAULT] -skip-if = buildapp == 'mulet' || buildapp == 'b2g' # bug 979446, frequent failures +# The actual requirement for mozDownloadManager is MOZ_GONK because of +# the nsIVolumeService dependency. Until https://bugzil.la/1130264 is +# addressed, there is no way for mulet to run these tests. +run-if = toolkit == 'gonk' support-files = serve_file.sjs + clear_all_done_helper.js + file_app.template.webapp + file_app.sjs + common_app.js + shim_app_as_test.js + shim_app_as_test_chrome.js + testapp_downloads_adopt_download.html + testapp_downloads_adopt_download.js + testapp_downloads_adopt_download.manifest [test_downloads_navigator_object.html] [test_downloads_basic.html] [test_downloads_large.html] +[test_downloads_adopt_download.html] [test_downloads_bad_file.html] [test_downloads_pause_remove.html] [test_downloads_pause_resume.html] -skip-if = toolkit=='gonk' # b2g(bug 947167) b2g-debug(bug 947167) diff --git a/dom/downloads/tests/shim_app_as_test.js b/dom/downloads/tests/shim_app_as_test.js new file mode 100644 index 000000000000..18846fa45a86 --- /dev/null +++ b/dom/downloads/tests/shim_app_as_test.js @@ -0,0 +1,210 @@ +/** + * Support logic to run a test file as an installed app. This file is derived + * from dom/requestsync/tests/test_basic_app.html but uses + * DOMApplicationRegistry in a chrome script (shim_app_as_test_chrome.js) to + * directly install the apps instead of mozApps.install because mozApps.install + * can't install privileged/certified apps. (This is the same mechanism used by + * the Firefox OS Gaia email app's backend test runner.) + * + * You really only want to do this if your test cares about the app's origin + * or you REALLY want to double-check AvailableIn and other WebIDL-provided + * security mechanisms. + * + * If you trust WebIDL, your life may be made significantly easier by just + * setting the pref "dom.ignore_webidl_scope_checks" to true, which makes + * BindingUtils.cpp's IsInPrivilegedApp and IsInCertifiedApp return true no + * matter what *on the main thread*. You are potentially out of luck on + * workers since at the time of writing this since the values stored on + * WorkerPrivateParent are based on the app status and ignore the pref. + * + * TO USE THIS: + * + * Make sure you have the usual header boilerplate: + * + * + * + * You also want to add this file! + * + * + * In your script body, issue a call like so: + * runAppTest({ + * appFile: 'testapp_downloads_adopt_download.html', + * appManifest: 'testapp_downloads_adopt_download.manifest', + * appType: 'certified', + * extraPrefs: { + * set: [["dom.mozDownloads.enabled", true]] + * } + * }); + * + * You shouldn't be adding other stuff to that file. Instead, you want + * everything in your testapp_*.html file. And you probably just want to copy + * and paste from an existing one of those... + */ + + var gManifestURL; + var gApp; + var gOptions; + + // Load the chrome script. + var gChromeHelper = SpecialPowers.loadChromeScript( + SimpleTest.getTestFileURL('shim_app_as_test_chrome.js')); + + function installApp() { + info("installing app"); + var useOrigin = document.location.origin; + gChromeHelper.sendAsyncMessage( + 'install', + { + origin: useOrigin, + manifestURL: SimpleTest.getTestFileURL(gOptions.appManifest), + }); + } + + function installedApp(appInfo) { + gApp = appInfo; + ok(!!appInfo, 'installed app'); + runTests(); + } + gChromeHelper.addMessageListener('installed', installedApp); + + function uninstallApp() { + info('uninstalling app'); + gChromeHelper.sendAsyncMessage('uninstall', gApp); + } + + function uninstalledApp(success) { + ok(success, 'uninstalled app'); + runTests(); + } + gChromeHelper.addMessageListener('uninstalled', uninstalledApp); + + function testApp() { + var cleanupFrame; + var handleTestMessage = function(message) { + if (/^OK/.exec(message)) { + ok(true, "Message from app: " + message); + } else if (/^KO/.exec(message)) { + ok(false, "Message from app: " + message); + } else if (/^INFO/.exec(message)) { + info("Message from app: " + message.substring(5)); + } else if (/^DONE$/.exec(message)) { + ok(true, "Messaging from app complete"); + cleanupFrame(); + runTests(); + } + }; + + // Bug 1097479 means that embed-webapps does not work if you are already + // OOP, as we are for b2g. So we need to have the chrome script run our + // app in a sibling iframe to the one we're living in. When that bug is + // fixed or we are run in a non-b2g context, we can set this value to false + // or otherwise conditionalize based on behaviour. + var needSiblingIframeHack = true; + + if (needSiblingIframeHack) { + gChromeHelper.sendAsyncMessage('run', gApp); + + gChromeHelper.addMessageListener('appMessage', handleTestMessage); + gChromeHelper.addMessageListener('appError', function(data) { + ok(false, "Error in app frame: " + data.message); + }); + + cleanupFrame = function() { + gChromeHelper.sendAsyncMessage('close', {}); + }; + } else { + var ifr = document.createElement('iframe'); + ifr.setAttribute('mozbrowser', 'true'); + ifr.setAttribute('mozapp', gApp.manifestURL); + + cleanupFrame = function() { + ifr.removeEventListener('mozbrowsershowmodalprompt', listener); + domParent.removeChild(ifr); + }; + + // Set us up to listen for messages from the app. + var listener = function(e) { + var message = e.detail.message; // e.detail.message; + handleTestMessage(message); + }; + + // This event is triggered when the app calls "alert". + ifr.addEventListener('mozbrowsershowmodalprompt', listener, false); + ifr.addEventListener('mozbrowsererror', function(evt) { + ok(false, "Error in app frame: " + evt.detail); + }); + + ifr.setAttribute('src', gApp.manifest.launch_path); + var domParent = document.getElementById('content'); + if (!domParent) { + document.createElement('div'); + document.body.insertBefore(domParent, document.body.firstChild); + } + domParent.appendChild(ifr); + } + } + + var tests = [ + // Permissions + function() { + info("pushing permissions"); + SpecialPowers.pushPermissions( + [{ "type": "browser", "allow": 1, "context": document }, + { "type": "embed-apps", "allow": 1, "context": document }, + { "type": "webapps-manage", "allow": 1, "context": document } + ], + runTests); + }, + + // Preferences + function() { + info("pushing preferences: " + gOptions.extraPrefs.set); + SpecialPowers.pushPrefEnv({ + "set": gOptions.extraPrefs.set + }, runTests); + }, + + function() { + info("enabling use of mozbrowser"); + //SpecialPowers.setAllAppsLaunchable(true); + SpecialPowers.setBoolPref("dom.mozBrowserFramesEnabled", true); + runTests(); + }, + + // No confirmation needed when an app is installed + function() { + SpecialPowers.autoConfirmAppInstall(function() { + SpecialPowers.autoConfirmAppUninstall(runTests); + }); + }, + + // Installing the app + installApp, + + // Run tests in app + testApp, + + // Uninstall the app + uninstallApp, + ]; + + function runTests() { + if (!tests.length) { + ok(true, 'DONE!'); + SimpleTest.finish(); + return; + } + + var test = tests.shift(); + test(); + } + + SimpleTest.waitForExplicitFinish(); + + function runAppTest(options) { + gOptions = options; + var href = document.location.href; + gManifestURL = href.substring(0, href.lastIndexOf('/') + 1) + + options.appManifest; + runTests(); + } diff --git a/dom/downloads/tests/shim_app_as_test_chrome.js b/dom/downloads/tests/shim_app_as_test_chrome.js new file mode 100644 index 000000000000..4d1f5bc9e1c9 --- /dev/null +++ b/dom/downloads/tests/shim_app_as_test_chrome.js @@ -0,0 +1,178 @@ +/** + * This is the chrome helper for shim_app_as_test.js. Its load is triggered by + * shim_app_as_test.js by a call to SpecialPowers.loadChromeScript and runs + * in the parent process in a sandbox created with the system principal. (Which + * seems like it can never get collected because it's reachable via the + * apparently singleton SpecialPowersObserverAPI instance and there's no logic + * to support reaping. Wuh-oh.) + * + * It exists to help install fake privileged/certified applications. It needs + * to exist because: + * - We need to poke at DOMApplicationRegistry directly. + * - By using SpecialPowers.loadChromeScript we are able to ensure this file + * is run in the parent process. This is important because + * DOMApplicationRegistry only lives in the parent process! + * - By running entirely in a chrome privileged compartment, we avoid crazy + * wrapper problems that we would otherwise face with our shenanigans of + * directly meddling with DOMApplicationRegistry. (And hopefully save + * anyone changing DOMApplicationRegistry from frustration/hating us if + * things were just barely working.) + * - Bug 1097479 means that embed-webapps doesn't work when the content process + * that is telling us to do things is itself OOP. So it falls upon us to + * handle the running of the app by creating a sibling mozbrowser/mozapp + * iframe to the one running the mochitests. + * + * Note that in this file we try to do *only* those things that can't otherwise + * be cleanly done using SpecialPowers. + * + * Want to better understand our execution context? Check out + * SpecialPowersObserverAPI.js and search on SPLoadChromeScript. + */ + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const CC = Components.Constructor; + +Cu.import('resource://gre/modules/Webapps.jsm'); // for DOMApplicationRegistry +Cu.import('resource://gre/modules/AppsUtils.jsm'); // for AppUtils +Cu.import('resource://gre/modules/Services.jsm'); // for AppUtils + +// Yes, you would think there was something like this already exposed easily +// in a JSM somewhere. No. +function fetchManifest(manifestURL) { + return new Promise(function(resolve, reject) { + let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"] + .createInstance(Ci.nsIXMLHttpRequest); + xhr.open("GET", manifestURL, true); + xhr.responseType = "json"; + + xhr.addEventListener("load", function() { + if (xhr.status == 200) { + resolve(xhr.response); + } else { + reject(); + } + }); + + xhr.addEventListener("error", function() { + reject(); + }); + + xhr.send(null); + }); +} + +/** + * Install an app using confirmInstall using pre-chewed data. This avoids the + * check in the normal installApp flow that gets all judgemental about the + * installation of privileged and certified apps. + */ +function installApp(req) { + fetchManifest(req.manifestURL).then(function(manifestObj) { + var data = { + // cloneAppObj normalizes the representation for us + app: AppsUtils.cloneAppObject({ + installOrigin: req.origin, + origin: req.origin, + manifestURL: req.manifestURL, + appStatus: AppsUtils.getAppManifestStatus(manifestObj), + receipts: [], + categories: [] + }), + + from: req.origin, // unused? + oid: 0, // unused? + requestID: 0, // unused-ish + appId: 0, // unused + isBrowser: false, + isPackage: false, // used + // magic to auto-ack... don't think we care about this... + forceSuccessAck: false + // stuff that probably doesn't matter: 'mm', 'apkInstall', + }; + // cloneAppObject does not propagate the manifest + data.app.manifest = manifestObj; + + return DOMApplicationRegistry.confirmInstall(data).then( + function() { + var appId = + DOMApplicationRegistry.getAppLocalIdByManifestURL(req.manifestURL); + // act like this is a privileged app having all of its permissions + // authorized at first run. + DOMApplicationRegistry.updatePermissionsForApp( + appId, + /* preinstalled */ true, + /* system update? */ true); + + sendAsyncMessage( + 'installed', + { + appId: appId, + manifestURL: req.manifestURL, + manifest: manifestObj + }); + }, + function(err) { + sendAsyncMessage('installed', false); + }); + }); +} + +function uninstallApp(appInfo) { + DOMApplicationRegistry.uninstall(appInfo.manifestURL).then( + function() { + sendAsyncMessage('uninstalled', true); + }, + function() { + sendAsyncMessage('uninstalled', false); + }); +} + +var activeIframe = null; + +/** + * Run our app in a sibling mozbrowser/mozapp iframe to the mochitest iframe. + * This is needed because we can't nest mozbrowser/mozapp iframes inside our + * already-OOP iframe until bug 1097479 is resolved. + */ +function runApp(appInfo) { + let shellDomWindow = Services.wm.getMostRecentWindow('navigator:browser'); + let sysAppFrame = shellDomWindow.document.body.querySelector('#systemapp'); + let sysAppDoc = sysAppFrame.contentDocument; + + let siblingFrame = sysAppDoc.body.querySelector('#test-container'); + + let ifr = activeIframe = sysAppDoc.createElement('iframe'); + ifr.setAttribute('mozbrowser', 'true'); + ifr.setAttribute('remote', 'true'); + ifr.setAttribute('mozapp', appInfo.manifestURL); + + ifr.addEventListener('mozbrowsershowmodalprompt', function(evt) { + var message = evt.detail.message; + // only send the message as long as we haven't been told to clean up. + if (activeIframe) { + sendAsyncMessage('appMessage', message); + } + }, false); + ifr.addEventListener('mozbrowsererror', function(evt) { + if (activeIframe) { + sendAsyncMessage('appError', { message: '' + evt.detail }); + } + }); + + ifr.setAttribute('src', appInfo.manifest.launch_path); + siblingFrame.parentElement.appendChild(ifr); +} + +function closeApp() { + if (activeIframe) { + activeIframe.parentElement.removeChild(activeIframe); + activeIframe = null; + } +} + +addMessageListener('install', installApp); +addMessageListener('uninstall', uninstallApp); +addMessageListener('run', runApp); +addMessageListener('close', closeApp); diff --git a/dom/downloads/tests/test_downloads_adopt_download.html b/dom/downloads/tests/test_downloads_adopt_download.html new file mode 100644 index 000000000000..07633be16b39 --- /dev/null +++ b/dom/downloads/tests/test_downloads_adopt_download.html @@ -0,0 +1,34 @@ + + + + + Test for Bug 825318 mozDownloadManager.adoptDownload + + + + + + +Mozilla Bug 825318 +

+
+
+
+
+
+ + + diff --git a/dom/downloads/tests/test_downloads_basic.html b/dom/downloads/tests/test_downloads_basic.html index 0120c7926095..051a1faa1bae 100644 --- a/dom/downloads/tests/test_downloads_basic.html +++ b/dom/downloads/tests/test_downloads_basic.html @@ -72,6 +72,10 @@ function downloadChange(evt) { is(download.currentBytes, 1024, "Download current size is 1024 bytes"); SimpleTest.finish(); } else if (download.state === "downloading") { + // Note that this case may or may not trigger, depending on whether the + // download is initially reported with 0 bytes (we should happen) or with + // 1024 bytes (we should not happen). If we do happen, an additional 8 + // TEST-PASS events should be logged. ok(download.currentBytes > lastKnownCurrentBytes, "Download current size is larger than last download change event"); lastKnownCurrentBytes = download.currentBytes; @@ -84,7 +88,8 @@ function downloadStart(evt) { var download = evt.download; checkConsistentDownloadAttributes(download); - is(download.currentBytes, 0, "Download current size is zero"); + // We used to check that the currentBytes was 0. This was incorrect. It + // is very common to first hear about the download already at 1024 bytes. is(download.state, "downloading", "Download state is downloading"); download.onstatechange = downloadChange; diff --git a/dom/downloads/tests/test_downloads_large.html b/dom/downloads/tests/test_downloads_large.html index 66e491d6e595..9f7f73c19920 100644 --- a/dom/downloads/tests/test_downloads_large.html +++ b/dom/downloads/tests/test_downloads_large.html @@ -7,6 +7,7 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=938023 Test for Bug 938023 Downloads API + @@ -46,7 +47,7 @@ function error() { function getDownloads(downloads) { ok(downloads.length == 1, "One downloads after getDownloads"); - navigator.mozDownloadManager.clearAllDone().then(clearAllDone, error); + clearAllDoneHelper(true).then(clearAllDone, error); } function clearAllDone(downloads) { @@ -76,7 +77,7 @@ var steps = [ SpecialPowers.pushPermissions([ {type: "downloads", allow: true, context: document} ], function() { - navigator.mozDownloadManager.clearAllDone().then(next, error); + clearAllDoneHelper(true).then(next, error); }); }, diff --git a/dom/downloads/tests/test_downloads_pause_remove.html b/dom/downloads/tests/test_downloads_pause_remove.html index 864f4fc5a2b1..3b410a667135 100644 --- a/dom/downloads/tests/test_downloads_pause_remove.html +++ b/dom/downloads/tests/test_downloads_pause_remove.html @@ -7,6 +7,7 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=938023 Test for Bug 938023 Downloads API + @@ -83,7 +84,7 @@ var steps = [ SpecialPowers.pushPermissions([ {type: "downloads", allow: true, context: document} ], function() { - navigator.mozDownloadManager.clearAllDone().then(next, error); + clearAllDoneHelper(true).then(next, error); }); }, diff --git a/dom/downloads/tests/test_downloads_pause_resume.html b/dom/downloads/tests/test_downloads_pause_resume.html index 911c6d39f101..c547267a1eaa 100644 --- a/dom/downloads/tests/test_downloads_pause_resume.html +++ b/dom/downloads/tests/test_downloads_pause_resume.html @@ -7,6 +7,7 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=938023 Test for Bug 938023 Downloads API + @@ -54,8 +55,7 @@ function checkDownloadList(downloads) { function checkResumeSucceeded(download) { ok(download.state == "succeeded", "Download resumed successfully."); - navigator.mozDownloadManager.clearAllDone() - .then(checkDownloadList, error); + clearAllDoneHelper(true).then(checkDownloadList, error); } function downloadChange(evt) { @@ -85,7 +85,7 @@ var steps = [ SpecialPowers.pushPermissions([ {type: "downloads", allow: true, context: document} ], function() { - navigator.mozDownloadManager.clearAllDone().then(next, error); + clearAllDoneHelper(true).then(next, error); }); }, diff --git a/dom/downloads/tests/testapp_downloads_adopt_download.html b/dom/downloads/tests/testapp_downloads_adopt_download.html new file mode 100644 index 000000000000..f7b32da33b5a --- /dev/null +++ b/dom/downloads/tests/testapp_downloads_adopt_download.html @@ -0,0 +1,14 @@ + + + + + + + +
initial text
+
+
+
+
+ + diff --git a/dom/downloads/tests/testapp_downloads_adopt_download.js b/dom/downloads/tests/testapp_downloads_adopt_download.js new file mode 100644 index 000000000000..955a3b047ed6 --- /dev/null +++ b/dom/downloads/tests/testapp_downloads_adopt_download.js @@ -0,0 +1,218 @@ +/** + * Test the adoptDownload API. Specifically, we expect that when we call + * adoptDownload with a valid payload that: + * - The method will be resolved with a valid, fully populated DOMDownload + * instance, including an id. + * - An ondownloadstart notification will be generated and the DOMDownload + * instance it receives will be logically equivalent. + * + * We also explicitly verify that invalid adoptDownload payloads result in a + * rejection and that no download is added. + * + * This test explicitly does not test that the download is correctly persisted + * to the database. This is done because Downloads.jsm does not provide a means + * of safely restarting itself, so Firefox would need to be restarted. Because + * the adoptDownload code is using the Downloads API in a straightforward + * manner, it's not considered likely this would regress, and certainly not + * considered worth the automated testing overhead of a restart. + */ + +function checkInvalidResult(dict, expectedErr, explanation) { + navigator.mozDownloadManager.ondownloadstart = function() { + ok(false, "No download should have been added!"); + }; + navigator.mozDownloadManager.adoptDownload(dict).then( + function() { + ok(false, "Invalid adoptDownload did not reject!"); + runTests(); + }, + function(rejectedWith) { + is(rejectedWith, expectedErr, explanation + " rejection value"); + runTests(); + }); +} + +// Pick a date that Date.now() could not possibly return by picking a date in +// the past. (We want to make sure the date we provide works.) +var arbitraryDate = new Date(Date.now() - 60000); + +var blobContents = new Uint8Array(256); +var memBlob = new Blob([blobContents], { type: 'application/octet-stream' }); +var blobStorageName; +var blobStoragePath = 'blobby.blob'; + +function checkAdoptedDownload(download, validPayload) { + is(download.totalBytes, memBlob.size, 'size'); + is(download.url, validPayload.url, 'url'); + // The filesystem path is not practical to check since we can't hard-code it + // and the only way to check is to effectively duplicate the logic in + // DownloadsAPI.js. The good news, however, is that the value is + // round-tripped from storageName/storagePath to path and back again, and we + // also verify the file exists on disk, so we can be reasonably confident this + // is correct. We output it to aid in debugging if things should break, + // of course. + info('path (not checked): ' + download.path); + is(download.storageName, validPayload.storageName, 'storageName'); + is(download.storagePath, validPayload.storagePath, 'storagePath'); + is(download.state, 'succeeded', 'state'); + is(download.contentType, validPayload.contentType, 'contentType'); + is(download.startTime.valueOf(), arbitraryDate.valueOf(), 'startTime'); + is(download.sourceAppManifestURL, + 'http://mochi.test:8888/' + + 'tests/dom/downloads/tests/testapp_downloads_adopt_download.manifest', + 'app manifest'); +}; + +var tests = [ + function saveBlobToDeviceStorage() { + // Only sdcard can handle arbitrary MIME types and is guaranteed to be a + // thing. + var storage = navigator.getDeviceStorage('sdcard'); + // We used the non-array helper, so the name we get may be different than + // what we asked for. + blobStorageName = storage.storageName; + ok(!!storage, 'have storage'); + var req = storage.addNamed(memBlob, blobStoragePath); + req.onerror = function() { + ok(false, 'problem saving blob to storage: ' + req.error.name); + }; + req.onsuccess = function(evt) { + ok(true, 'saved blob: ' + evt.target.result); + runTests(); + }; + }, + function addValid() { + var validPayload = { + // All currently expected consumers are unable to provide a valid URL, and + // as a result need to provide an empty string. + url: "", + storageName: blobStorageName, + storagePath: blobStoragePath, + contentType: memBlob.type, + startTime: arbitraryDate + }; + // Wrap the notification in a check so we can force our logic to be + // consistently ordered in the test even if it's not in reality. + var notifiedPromise = new Promise(function(resolve, reject) { + navigator.mozDownloadManager.ondownloadstart = function(evt) { + resolve(evt.download); + }; + }); + + // Start the download + navigator.mozDownloadManager.adoptDownload(validPayload).then( + function(apiDownload) { + checkAdoptedDownload(apiDownload, validPayload); + ok(!!apiDownload.id, "Need a download id!"); + notifiedPromise.then(function(notifiedDownload) { + checkAdoptedDownload(notifiedDownload, validPayload); + is(apiDownload.id, notifiedDownload.id, + "Notification should be for the download we adopted"); + runTests(); + }); + }, + function() { + ok(false, "adoptDownload should not have rejected"); + runTests(); + }); + }, + + function dictionaryNotProvided() { + checkInvalidResult(undefined, "InvalidDownload"); + }, + // Missing fields immediately result in rejection with InvalidDownload + function missingStorageName() { + checkInvalidResult({ + url: "", + // no storageName + storagePath: "relpath/filename.txt", + contentType: "text/plain", + startTime: arbitraryDate + }, "InvalidDownload", "missing storage name"); + }, + function nullStorageName() { + checkInvalidResult({ + url: "", + storageName: null, + storagePath: "relpath/filename.txt", + contentType: "text/plain", + startTime: arbitraryDate + }, "InvalidDownload", "null storage name"); + }, + function missingStoragePath() { + checkInvalidResult({ + url: "", + storageName: blobStorageName, + // no storagePath + contentType: "text/plain", + startTime: arbitraryDate + }, "InvalidDownload", "missing storage path"); + }, + function nullStoragePath() { + checkInvalidResult({ + url: "", + storageName: blobStorageName, + storagePath: null, + contentType: "text/plain", + startTime: arbitraryDate + }, "InvalidDownload", "null storage path"); + }, + function missingContentType() { + checkInvalidResult({ + url: "", + storageName: "sdcard", + storagePath: "relpath/filename.txt", + // no contentType + startTime: arbitraryDate + }, "InvalidDownload", "missing content type"); + }, + function nullContentType() { + checkInvalidResult({ + url: "", + storageName: "sdcard", + storagePath: "relpath/filename.txt", + contentType: null, + startTime: arbitraryDate + }, "InvalidDownload", "null content type"); + }, + // Incorrect storage names are likewise immediately invalidated + function invalidStorageName() { + checkInvalidResult({ + url: "", + storageName: "ALMOST CERTAINLY DOES NOT EXIST", + storagePath: "relpath/filename.txt", + contentType: "text/plain", + startTime: arbitraryDate + }, "InvalidDownload", "invalid storage name"); + }, + // The existence of the file is validated in the parent process + function legitStorageInvalidPath() { + checkInvalidResult({ + url: "", + storageName: blobStorageName, + storagePath: "ALMOST CERTAINLY DOES NOT EXIST", + contentType: "text/plain", + startTime: arbitraryDate + }, "AdoptNoSuchFile", "invalid path"); + }, + function allDone() { + // Just in case, make sure no other mochitest could mess with us after we've + // finished. + navigator.mozDownloadManager.ondownloadstart = null; + runTests(); + } +]; + +function runTests() { + if (!tests.length) { + finish(); + return; + } + + var test = tests.shift(); + if (test.name) { + info('starting test: ' + test.name); + } + test(); +} +runTests(); diff --git a/dom/downloads/tests/testapp_downloads_adopt_download.manifest b/dom/downloads/tests/testapp_downloads_adopt_download.manifest new file mode 100644 index 000000000000..b68e10e2866a --- /dev/null +++ b/dom/downloads/tests/testapp_downloads_adopt_download.manifest @@ -0,0 +1,10 @@ +{ + "name": "Downloads certified test fake app", + "description": "Test", + "launch_path": "http://mochi.test:8888/tests/dom/downloads/tests/testapp_downloads_adopt_download.html", + "type": "certified", + "permissions": { + "device-storage:sdcard":{ "access": "readcreate" }, + "downloads": {} + } +} diff --git a/dom/webidl/Downloads.webidl b/dom/webidl/Downloads.webidl index 2d981c983535..eb77131bd6a8 100644 --- a/dom/webidl/Downloads.webidl +++ b/dom/webidl/Downloads.webidl @@ -36,10 +36,32 @@ interface DOMDownloadManager : EventTarget { [UnsafeInPrerendering] Promise remove(DOMDownload download); - // Removes all the completed downloads from the set. Returns an - // array of the completed downloads that were removed. + // Removes all completed downloads. This kicks off an asynchronous process + // that will eventually complete, but will not have completed by the time this + // method returns. If you care about the side-effects of this method, know + // that each existing download will have its onstatechange method invoked and + // will have a new state of "finalized". (After the download is finalized, no + // further events will be generated on it.) [UnsafeInPrerendering] - Promise> clearAllDone(); + void clearAllDone(); + + // Add completed downloads from applications that must perform the download + // process themselves. For example, email. The method is resolved with a + // fully populated DOMDownload instance on success, or rejected in the + // event all required options were not provided. + // + // The adopted download will also be reported via the ondownloadstart event + // handler. + // + // Applications must currently be certified to use this, but it could be + // widened at a later time. + // + // Note that "download" is not actually optional, but WebIDL requires that it + // be marked as such because it is not followed by a required argument. The + // promise will be rejected if the dictionary is omitted or the specified + // file does not exist on disk. + [AvailableIn=CertifiedApps] + Promise adoptDownload(optional AdoptDownloadDict download); // Fires when a new download starts. attribute EventHandler ondownloadstart; @@ -59,7 +81,9 @@ interface DOMDownload : EventTarget { readonly attribute DOMString url; // The full path in local storage where the file will end up once the download - // is complete. + // is complete. This is equivalent to the concatenation of the 'storagePath' + // to the 'mountPoint' of the nsIVolume associated with the 'storageName' + // (with delimiter). readonly attribute DOMString path; // The DeviceStorage volume name on which the file is being downloaded. @@ -69,7 +93,10 @@ interface DOMDownload : EventTarget { // downloaded. readonly attribute DOMString storagePath; - // The state of the download. + // The state of the download. One of: downloading, stopped, succeeded, or + // finalized. A finalized download is a download that has been removed / + // cleared and is no longer tracked by the download manager and will not + // receive any further onstatechange updates. readonly attribute DownloadState state; // The mime type for this resource. @@ -82,6 +109,10 @@ interface DOMDownload : EventTarget { // download (eg. in different windows) will have the same id. readonly attribute DOMString id; + // The manifestURL of the application that added this download. Only used for + // downloads added via the adoptDownload API call. + readonly attribute DOMString? sourceAppManifestURL; + // A DOM error object, that will be not null when a download is stopped // because something failed. readonly attribute DOMError? error; @@ -100,3 +131,41 @@ interface DOMDownload : EventTarget { // - when the state and/or error attributes change. attribute EventHandler onstatechange; }; + +// Used to initialize the DOMDownload object for adopted downloads. +// fields directly maps to the DOMDownload fields. +dictionary AdoptDownloadDict { + // The URL of this resource if there is one available. An empty string if + // the download is not accessible via URL. An empty string is chosen over + // null so that existinc code does not need to null-check but the value is + // still falsey. (Note: If you do have a usable URL, you should probably not + // be using the adoptDownload API and instead be initiating downloads the + // normal way.) + DOMString url; + + // The storageName of the DeviceStorage instance the file was saved to. + // Required but marked as optional so the bindings don't auto-coerce the value + // null to "null". + DOMString? storageName; + // The path of the file within the DeviceStorage instance named by + // 'storageName'. This is used to automatically compute the 'path' of the + // download. Note that when DeviceStorage gives you a path to a file, the + // first path segment is the name of the specific device storage and you do + // *not* want to include this. For example, if DeviceStorage tells you the + // file has a path of '/sdcard1/actual/path/file.ext', then the storageName + // should be 'sdcard1' and the storagePath should be 'actual/path/file.ext'. + // + // The existence of the file will be validated will be validated with stat() + // and the size the file-system tells us will be what we use. + // + // Required but marked as optional so the bindings don't auto-coerce the value + // null to "null". + DOMString? storagePath; + + // The mime type for this resource. Required, but marked as optional because + // WebIDL otherwise auto-coerces the value null to "null". + DOMString? contentType; + + // The time the download was started. If omitted, the current time is used. + Date? startTime; +};