diff options
Diffstat (limited to 'dom/downloads')
-rw-r--r-- | dom/downloads/DownloadsAPI.js | 517 | ||||
-rw-r--r-- | dom/downloads/DownloadsAPI.jsm | 365 | ||||
-rw-r--r-- | dom/downloads/DownloadsAPI.manifest | 6 | ||||
-rw-r--r-- | dom/downloads/DownloadsIPC.jsm | 224 | ||||
-rw-r--r-- | dom/downloads/moz.build | 21 | ||||
-rw-r--r-- | dom/downloads/tests/clear_all_done_helper.js | 67 | ||||
-rw-r--r-- | dom/downloads/tests/mochitest.ini | 15 | ||||
-rw-r--r-- | dom/downloads/tests/serve_file.sjs | 170 | ||||
-rw-r--r-- | dom/downloads/tests/test_downloads_bad_file.html | 93 | ||||
-rw-r--r-- | dom/downloads/tests/test_downloads_basic.html | 128 | ||||
-rw-r--r-- | dom/downloads/tests/test_downloads_large.html | 110 | ||||
-rw-r--r-- | dom/downloads/tests/test_downloads_navigator_object.html | 75 | ||||
-rw-r--r-- | dom/downloads/tests/test_downloads_pause_remove.html | 117 | ||||
-rw-r--r-- | dom/downloads/tests/test_downloads_pause_resume.html | 121 |
14 files changed, 2029 insertions, 0 deletions
diff --git a/dom/downloads/DownloadsAPI.js b/dom/downloads/DownloadsAPI.js new file mode 100644 index 000000000..8294e2a3e --- /dev/null +++ b/dom/downloads/DownloadsAPI.js @@ -0,0 +1,517 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/DOMRequestHelper.jsm"); +Cu.import("resource://gre/modules/DownloadsIPC.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "cpmm", + "@mozilla.org/childprocessmessagemanager;1", + "nsIMessageSender"); +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"); +#endif +} + +function DOMDownloadManagerImpl() { + debug("DOMDownloadManagerImpl constructor"); +} + +DOMDownloadManagerImpl.prototype = { + __proto__: DOMRequestIpcHelper.prototype, + + // nsIDOMGlobalPropertyInitializer implementation + init: function(aWindow) { + debug("DownloadsManager init"); + 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() { + debug("uninit"); + downloadsCache.evict(this._window); + }, + + set ondownloadstart(aHandler) { + this.__DOM_IMPL__.setEventHandler("ondownloadstart", aHandler); + }, + + get ondownloadstart() { + return this.__DOM_IMPL__.getEventHandler("ondownloadstart"); + }, + + getDownloads: function() { + debug("getDownloads()"); + + return this.createPromise(function (aResolve, aReject) { + DownloadsIPC.getDownloads().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("GetDownloadsError"); + } + ); + }.bind(this)); + }, + + clearAllDone: function() { + debug("clearAllDone()"); + // This is a void function; we just kick it off. No promises, etc. + DownloadsIPC.clearAllDone(); + }, + + remove: function(aDownload) { + debug("remove " + aDownload.url + " " + aDownload.id); + return this.createPromise(function (aResolve, aReject) { + if (!downloadsCache.has(this._window, aDownload.id)) { + debug("no download " + aDownload.id); + aReject("InvalidDownload"); + return; + } + + DownloadsIPC.remove(aDownload.id).then( + function(aResult) { + let dom = createDOMDownloadObject(this._window, aResult); + // Change the state right away to not race against the update message. + dom.wrappedJSObject.state = "finalized"; + aResolve(this._prepareForContent(dom)); + }.bind(this), + function() { + aReject("RemoveError"); + } + ); + }.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 + * we run _create() with the wrapped js object. + */ + _prepareForContent: function(aChromeObject) { + if (aChromeObject.__DOM_IMPL__) { + return aChromeObject.__DOM_IMPL__; + } + let res = this._window.DOMDownload._create(this._window, + aChromeObject.wrappedJSObject); + return res; + }, + + receiveMessage: function(aMessage) { + let data = aMessage.data; + switch(aMessage.name) { + case "Downloads:Added": + debug("Adding " + uneval(data)); + let event = new this._window.DownloadEvent("downloadstart", { + download: + this._prepareForContent(createDOMDownloadObject(this._window, data)) + }); + this.__DOM_IMPL__.dispatchEvent(event); + break; + } + }, + + classID: Components.ID("{c6587afa-0696-469f-9eff-9dac0dd727fe}"), + QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, + Ci.nsISupportsWeakReference, + Ci.nsIObserver, + Ci.nsIDOMGlobalPropertyInitializer]), + +}; + +/** + * Keep track of download objects per window. + */ +var downloadsCache = { + init: function() { + this.cache = new WeakMap(); + }, + + has: function(aWindow, aId) { + let downloads = this.cache.get(aWindow); + return !!(downloads && downloads[aId]); + }, + + get: function(aWindow, aDownload) { + let downloads = this.cache.get(aWindow); + if (!(downloads && downloads[aDownload.id])) { + debug("Adding download " + aDownload.id + " to cache."); + if (!downloads) { + this.cache.set(aWindow, {}); + downloads = this.cache.get(aWindow); + } + // Create the object and add it to the cache. + let impl = Cc["@mozilla.org/downloads/download;1"] + .createInstance(Ci.nsISupports); + impl.wrappedJSObject._init(aWindow, aDownload); + downloads[aDownload.id] = impl; + } + return downloads[aDownload.id]; + }, + + evict: function(aWindow) { + this.cache.delete(aWindow); + } +}; + +downloadsCache.init(); + +/** + * The DOM facade of a download object. + */ + +function createDOMDownloadObject(aWindow, aDownload) { + return downloadsCache.get(aWindow, aDownload); +} + +function DOMDownloadImpl() { + debug("DOMDownloadImpl constructor "); + + this.wrappedJSObject = this; + this.totalBytes = 0; + this.currentBytes = 0; + this.url = null; + this.path = null; + this.storageName = null; + this.storagePath = null; + this.contentType = null; + + /* fields that require getters/setters */ + this._error = null; + this._startTime = new Date(); + this._state = "stopped"; + + /* private fields */ + this.id = null; +} + +DOMDownloadImpl.prototype = { + + createPromise: function(aPromiseInit) { + return new this._window.Promise(aPromiseInit); + }, + + pause: function() { + debug("DOMDownloadImpl pause"); + let id = this.id; + // We need to wrap the Promise.jsm promise in a "real" DOM promise... + return this.createPromise(function(aResolve, aReject) { + DownloadsIPC.pause(id).then(aResolve, aReject); + }); + }, + + resume: function() { + debug("DOMDownloadImpl resume"); + let id = this.id; + // We need to wrap the Promise.jsm promise in a "real" DOM promise... + return this.createPromise(function(aResolve, aReject) { + DownloadsIPC.resume(id).then(aResolve, aReject); + }); + }, + + set onstatechange(aHandler) { + this.__DOM_IMPL__.setEventHandler("onstatechange", aHandler); + }, + + get onstatechange() { + return this.__DOM_IMPL__.getEventHandler("onstatechange"); + }, + + get error() { + return this._error; + }, + + set error(aError) { + this._error = aError; + }, + + get startTime() { + return this._startTime; + }, + + set startTime(aStartTime) { + if (aStartTime instanceof Date) { + this._startTime = aStartTime; + } + else { + this._startTime = new Date(aStartTime); + } + }, + + get state() { + return this._state; + }, + + // We require a setter here to simplify the internals of the Download Manager + // since we actually pass dummy JSON objects to the child process and update + // them. This is the case for all other setters for read-only attributes + // implemented in this object. + set state(aState) { + // We need to ensure that XPCOM consumers of this API respect the enum + // values as well. + if (["downloading", + "stopped", + "succeeded", + "finalized"].indexOf(aState) != -1) { + this._state = aState; + } + }, + + /** + * 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; + this._update(aDownload); + Services.obs.addObserver(this, "downloads-state-change-" + this.id, + /* ownsWeak */ true); + debug("observer set for " + this.id); + }, + + /** + * Updates the state of the object and fires the statechange event. + */ + _update: function(aDownload) { + debug("update " + uneval(aDownload)); + if (this.id != aDownload.id) { + return; + } + + let props = ["totalBytes", "currentBytes", "url", "path", "storageName", + "storagePath", "state", "contentType", "startTime", + "sourceAppManifestURL"]; + let changed = false; + let changedProps = {}; + + props.forEach((prop) => { + if (prop in aDownload && (aDownload[prop] != this[prop])) { + this[prop] = aDownload[prop]; + changedProps[prop] = changed = true; + } + }); + + // When the path changes, we should update the storage name and + // storage path used for our downloaded file in case our download + // was re-targetted to a different storage and/or filename. + if (changedProps["path"]) { + let storages = this._window.navigator.getDeviceStorages("sdcard"); + let preferredStorageName; + // Use the first one or the default storage. Just like jsdownloads picks + // the default / preferred download directory. + storages.forEach((aStorage) => { + if (aStorage.default || !preferredStorageName) { + preferredStorageName = aStorage.storageName; + } + }); + // Now get the path for this storage area. + let volume; + if (preferredStorageName) { + let volume = volumeService.getVolumeByName(preferredStorageName); + if (volume) { + // Finally, create the relative path of the file that can be used + // later on to retrieve the file via DeviceStorage. Our path + // needs to omit the starting '/'. + this.storageName = preferredStorageName; + this.storagePath = + this.path.substring(this.path.indexOf(volume.mountPoint) + + volume.mountPoint.length + 1); + } + } + } + + if (aDownload.error) { + // + // When we get a generic error failure back from the js downloads api + // we will verify the status of device storage to see if we can't provide + // a better error result value. + // + // XXX If these checks expand further, consider moving them into their + // own function. + // + let result = aDownload.error.result; + let storage = this._window.navigator.getDeviceStorage("sdcard"); + + // If we don't have access to device storage we'll opt out of these + // extra checks as they are all dependent on the state of the storage. + if (result == Cr.NS_ERROR_FAILURE && storage) { + // We will delay sending the notification until we've inferred which + // error is really happening. + changed = false; + debug("Attempting to infer error via device storage sanity checks."); + // Get device storage and request availability status. + let available = storage.available(); + available.onsuccess = (function() { + debug("Storage Status = '" + available.result + "'"); + let inferredError = result; + switch (available.result) { + case "unavailable": + inferredError = Cr.NS_ERROR_FILE_NOT_FOUND; + break; + case "shared": + inferredError = Cr.NS_ERROR_FILE_ACCESS_DENIED; + break; + } + this._updateWithError(aDownload, inferredError); + }).bind(this); + available.onerror = (function() { + this._updateWithError(aDownload, result); + }).bind(this); + } + + this.error = + new this._window.DOMError("DownloadError", result); + } else { + this.error = null; + } + + // The visible state has not changed, so no need to fire an event. + if (!changed) { + return; + } + + this._sendStateChange(); + }, + + _updateWithError: function(aDownload, aError) { + this.error = + new this._window.DOMError("DownloadError", aError); + this._sendStateChange(); + }, + + _sendStateChange: function() { + // __DOM_IMPL__ may not be available at first update. + if (this.__DOM_IMPL__) { + let event = new this._window.DownloadEvent("statechange", { + download: this.__DOM_IMPL__ + }); + debug("Dispatching statechange event. state=" + this.state); + this.__DOM_IMPL__.dispatchEvent(event); + } + }, + + observe: function(aSubject, aTopic, aData) { + debug("DOMDownloadImpl observe " + aTopic); + if (aTopic !== "downloads-state-change-" + this.id) { + return; + } + + try { + let download = JSON.parse(aData); + // We get the start time as milliseconds, not as a Date object. + if (download.startTime) { + download.startTime = new Date(download.startTime); + } + this._update(download); + } catch(e) {} + }, + + classID: Components.ID("{96b81b99-aa96-439d-8c59-92eeed34705f}"), + QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, + Ci.nsIObserver, + Ci.nsISupportsWeakReference]) +}; + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([DOMDownloadManagerImpl, + DOMDownloadImpl]); diff --git a/dom/downloads/DownloadsAPI.jsm b/dom/downloads/DownloadsAPI.jsm new file mode 100644 index 000000000..dfb8286fe --- /dev/null +++ b/dom/downloads/DownloadsAPI.jsm @@ -0,0 +1,365 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; + +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"); +#endif +} + +function sendPromiseMessage(aMm, aMessageName, aData, aError) { + debug("sendPromiseMessage " + aMessageName); + let msg = { + id: aData.id, + promiseId: aData.promiseId + }; + + if (aError) { + msg.error = aError; + } + + aMm.sendAsyncMessage(aMessageName, msg); +} + +var DownloadsAPI = { + init: function() { + debug("init"); + + this._ids = new WeakMap(); // Maps toolkit download objects to ids. + this._index = {}; // Maps ids to downloads. + + ["Downloads:GetList", + "Downloads:ClearAllDone", + "Downloads:Remove", + "Downloads:Pause", + "Downloads:Resume", + "Downloads:Adopt"].forEach((msgName) => { + ppmm.addMessageListener(msgName, this); + }); + + let self = this; + Task.spawn(function () { + let list = yield Downloads.getList(Downloads.ALL); + yield list.addView(self); + + debug("view added to download list."); + }).then(null, Components.utils.reportError); + + this._currentId = 0; + }, + + /** + * Returns a unique id for each download, hashing the url and the path. + */ + downloadId: function(aDownload) { + let id = this._ids.get(aDownload, null); + if (!id) { + id = "download-" + this._currentId++; + this._ids.set(aDownload, id); + this._index[id] = aDownload; + } + return id; + }, + + getDownloadById: function(aId) { + return this._index[aId]; + }, + + /** + * Converts a download object into a plain json object that we'll + * send to the DOM side. + */ + jsonDownload: function(aDownload) { + let res = { + totalBytes: aDownload.totalBytes, + currentBytes: aDownload.currentBytes, + url: aDownload.source.url, + path: aDownload.target.path, + contentType: aDownload.contentType, + startTime: aDownload.startTime.getTime(), + sourceAppManifestURL: aDownload._unknownProperties && + aDownload._unknownProperties.sourceAppManifestURL + }; + + if (aDownload.error) { + res.error = aDownload.error; + } + + res.id = this.downloadId(aDownload); + + // The state of the download. Can be any of "downloading", "stopped", + // "succeeded", finalized". + + // Default to "stopped" + res.state = "stopped"; + if (!aDownload.stopped && + !aDownload.canceled && + !aDownload.succeeded && + !aDownload.DownloadError) { + res.state = "downloading"; + } else if (aDownload.succeeded) { + res.state = "succeeded"; + } + return res; + }, + + /** + * download view methods. + */ + onDownloadAdded: function(aDownload) { + let download = this.jsonDownload(aDownload); + debug("onDownloadAdded " + uneval(download)); + ppmm.broadcastAsyncMessage("Downloads:Added", download); + }, + + onDownloadRemoved: function(aDownload) { + let download = this.jsonDownload(aDownload); + download.state = "finalized"; + debug("onDownloadRemoved " + uneval(download)); + ppmm.broadcastAsyncMessage("Downloads:Removed", download); + this._index[this._ids.get(aDownload)] = null; + this._ids.delete(aDownload); + }, + + onDownloadChanged: function(aDownload) { + let download = this.jsonDownload(aDownload); + debug("onDownloadChanged " + uneval(download)); + ppmm.broadcastAsyncMessage("Downloads:Changed", download); + }, + + receiveMessage: function(aMessage) { + if (!aMessage.target.assertPermission("downloads")) { + debug("No 'downloads' permission!"); + return; + } + + debug("message: " + aMessage.name); + + switch (aMessage.name) { + case "Downloads:GetList": + this.getList(aMessage.data, aMessage.target); + break; + case "Downloads:ClearAllDone": + this.clearAllDone(aMessage.data, aMessage.target); + break; + case "Downloads:Remove": + this.remove(aMessage.data, aMessage.target); + break; + case "Downloads:Pause": + this.pause(aMessage.data, aMessage.target); + break; + 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); + } + }, + + getList: function(aData, aMm) { + debug("getList called!"); + let self = this; + Task.spawn(function () { + let list = yield Downloads.getList(Downloads.ALL); + let downloads = yield list.getAll(); + let res = []; + downloads.forEach((aDownload) => { + res.push(self.jsonDownload(aDownload)); + }); + aMm.sendAsyncMessage("Downloads:GetList:Return", res); + }).then(null, Components.utils.reportError); + }, + + clearAllDone: function(aData, aMm) { + debug("clearAllDone called!"); + Task.spawn(function () { + let list = yield Downloads.getList(Downloads.ALL); + list.removeFinished(); + }).then(null, Components.utils.reportError); + }, + + remove: function(aData, aMm) { + debug("remove id " + aData.id); + let download = this.getDownloadById(aData.id); + if (!download) { + sendPromiseMessage(aMm, "Downloads:Remove:Return", + aData, "NoSuchDownload"); + return; + } + + Task.spawn(function() { + yield download.finalize(true); + let list = yield Downloads.getList(Downloads.ALL); + yield list.remove(download); + }).then( + function() { + sendPromiseMessage(aMm, "Downloads:Remove:Return", aData); + }, + function() { + sendPromiseMessage(aMm, "Downloads:Remove:Return", + aData, "RemoveError"); + } + ); + }, + + pause: function(aData, aMm) { + debug("pause id " + aData.id); + let download = this.getDownloadById(aData.id); + if (!download) { + sendPromiseMessage(aMm, "Downloads:Pause:Return", + aData, "NoSuchDownload"); + return; + } + + download.cancel().then( + function() { + sendPromiseMessage(aMm, "Downloads:Pause:Return", aData); + }, + function() { + sendPromiseMessage(aMm, "Downloads:Pause:Return", + aData, "PauseError"); + } + ); + }, + + resume: function(aData, aMm) { + debug("resume id " + aData.id); + let download = this.getDownloadById(aData.id); + if (!download) { + sendPromiseMessage(aMm, "Downloads:Resume:Return", + aData, "NoSuchDownload"); + return; + } + + download.start().then( + function() { + sendPromiseMessage(aMm, "Downloads:Resume:Return", aData); + }, + function() { + sendPromiseMessage(aMm, "Downloads:Resume:Return", + 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); + }); + } +}; + +DownloadsAPI.init(); diff --git a/dom/downloads/DownloadsAPI.manifest b/dom/downloads/DownloadsAPI.manifest new file mode 100644 index 000000000..8d6dc9396 --- /dev/null +++ b/dom/downloads/DownloadsAPI.manifest @@ -0,0 +1,6 @@ +# DownloadsAPI.js +component {c6587afa-0696-469f-9eff-9dac0dd727fe} DownloadsAPI.js +contract @mozilla.org/downloads/manager;1 {c6587afa-0696-469f-9eff-9dac0dd727fe} + +component {96b81b99-aa96-439d-8c59-92eeed34705f} DownloadsAPI.js +contract @mozilla.org/downloads/download;1 {96b81b99-aa96-439d-8c59-92eeed34705f} diff --git a/dom/downloads/DownloadsIPC.jsm b/dom/downloads/DownloadsIPC.jsm new file mode 100644 index 000000000..0e290abf4 --- /dev/null +++ b/dom/downloads/DownloadsIPC.jsm @@ -0,0 +1,224 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; + +this.EXPORTED_SYMBOLS = ["DownloadsIPC"]; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "cpmm", + "@mozilla.org/childprocessmessagemanager;1", + "nsIMessageSender"); + +/** + * This module lives in the child process and receives the ipc messages + * from the parent. It saves the download's state and redispatch changes + * to DOM objects using an observer notification. + * + * This module needs to be loaded once and only once per process. + */ + +function debug(aStr) { +#ifdef MOZ_DEBUG + dump("-*- DownloadsIPC.jsm : " + aStr + "\n"); +#endif +} + +const ipcMessages = ["Downloads:Added", + "Downloads:Removed", + "Downloads:Changed", + "Downloads:GetList:Return", + "Downloads:Remove:Return", + "Downloads:Pause:Return", + "Downloads:Resume:Return", + "Downloads:Adopt:Return"]; + +this.DownloadsIPC = { + downloads: {}, + + init: function() { + debug("init"); + Services.obs.addObserver(this, "xpcom-shutdown", false); + ipcMessages.forEach((aMessage) => { + cpmm.addMessageListener(aMessage, this); + }); + + // We need to get the list of current downloads. + this.ready = false; + this.getListPromises = []; + this.downloadPromises = {}; + cpmm.sendAsyncMessage("Downloads:GetList", {}); + this._promiseId = 0; + }, + + notifyChanges: function(aId) { + // TODO: use the subject instead of stringifying. + if (this.downloads[aId]) { + debug("notifyChanges notifying changes for " + aId); + Services.obs.notifyObservers(null, "downloads-state-change-" + aId, + JSON.stringify(this.downloads[aId])); + } else { + debug("notifyChanges failed for " + aId) + } + }, + + _updateDownloadsArray: function(aDownloads) { + this.downloads = []; + // We actually have an array of downloads. + aDownloads.forEach((aDownload) => { + this.downloads[aDownload.id] = aDownload; + }); + }, + + receiveMessage: function(aMessage) { + let download = aMessage.data; + debug("message: " + aMessage.name); + switch(aMessage.name) { + case "Downloads:GetList:Return": + this._updateDownloadsArray(download); + + if (!this.ready) { + this.getListPromises.forEach(aPromise => + aPromise.resolve(this.downloads)); + this.getListPromises.length = 0; + } + this.ready = true; + break; + case "Downloads:Added": + this.downloads[download.id] = download; + this.notifyChanges(download.id); + break; + case "Downloads:Removed": + if (this.downloads[download.id]) { + this.downloads[download.id] = download; + this.notifyChanges(download.id); + delete this.downloads[download.id]; + } + break; + case "Downloads:Changed": + // Only update properties that actually changed. + let cached = this.downloads[download.id]; + if (!cached) { + debug("No download found for " + download.id); + return; + } + let props = ["totalBytes", "currentBytes", "url", "path", "state", + "contentType", "startTime"]; + let changed = false; + + props.forEach((aProp) => { + if (download[aProp] && (download[aProp] != cached[aProp])) { + cached[aProp] = download[aProp]; + changed = true; + } + }); + + // Updating the error property. We always get a 'state' change as + // well. + cached.error = download.error; + + if (changed) { + this.notifyChanges(download.id); + } + break; + 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); + } else { + this.downloadPromises[download.promiseId].reject(download); + } + delete this.downloadPromises[download.promiseId]; + } + break; + } + }, + + /** + * Returns a promise that is resolved with the list of current downloads. + */ + getDownloads: function() { + debug("getDownloads()"); + let deferred = Promise.defer(); + if (this.ready) { + debug("Returning existing list."); + deferred.resolve(this.downloads); + } else { + this.getListPromises.push(deferred); + } + return deferred.promise; + }, + + /** + * Void function to trigger removal of completed downloads. + */ + clearAllDone: function() { + debug("clearAllDone"); + cpmm.sendAsyncMessage("Downloads:ClearAllDone", {}); + }, + + promiseId: function() { + return this._promiseId++; + }, + + remove: function(aId) { + debug("remove " + aId); + let deferred = Promise.defer(); + let pId = this.promiseId(); + this.downloadPromises[pId] = deferred; + cpmm.sendAsyncMessage("Downloads:Remove", + { id: aId, promiseId: pId }); + return deferred.promise; + }, + + pause: function(aId) { + debug("pause " + aId); + let deferred = Promise.defer(); + let pId = this.promiseId(); + this.downloadPromises[pId] = deferred; + cpmm.sendAsyncMessage("Downloads:Pause", + { id: aId, promiseId: pId }); + return deferred.promise; + }, + + resume: function(aId) { + debug("resume " + aId); + let deferred = Promise.defer(); + let pId = this.promiseId(); + this.downloadPromises[pId] = deferred; + cpmm.sendAsyncMessage("Downloads:Resume", + { id: aId, promiseId: pId }); + 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) => { + cpmm.removeMessageListener(aMessage, this); + }); + } + } +}; + +DownloadsIPC.init(); diff --git a/dom/downloads/moz.build b/dom/downloads/moz.build new file mode 100644 index 000000000..07bda1c4f --- /dev/null +++ b/dom/downloads/moz.build @@ -0,0 +1,21 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +if CONFIG["MOZ_B2G"]: + MOCHITEST_MANIFESTS += ['tests/mochitest.ini'] + +EXTRA_COMPONENTS += [ + 'DownloadsAPI.manifest', +] + +EXTRA_PP_COMPONENTS += [ + 'DownloadsAPI.js', +] + +EXTRA_PP_JS_MODULES += [ + 'DownloadsAPI.jsm', + 'DownloadsIPC.jsm', +] 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 000000000..62fa1a2f3 --- /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/mochitest.ini b/dom/downloads/tests/mochitest.ini new file mode 100644 index 000000000..e13e4d887 --- /dev/null +++ b/dom/downloads/tests/mochitest.ini @@ -0,0 +1,15 @@ +[DEFAULT] +# 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 + +[test_downloads_navigator_object.html] +[test_downloads_basic.html] +[test_downloads_large.html] +[test_downloads_bad_file.html] +[test_downloads_pause_remove.html] +[test_downloads_pause_resume.html] diff --git a/dom/downloads/tests/serve_file.sjs b/dom/downloads/tests/serve_file.sjs new file mode 100644 index 000000000..d0171d7ca --- /dev/null +++ b/dom/downloads/tests/serve_file.sjs @@ -0,0 +1,170 @@ +// Serves a file with a given mime type and size at an optionally given rate. + +function getQuery(request) { + var query = {}; + request.queryString.split('&').forEach(function (val) { + var [name, value] = val.split('='); + query[name] = unescape(value); + }); + return query; +} + +function handleResponse() { + // Is this a rate limited response? + if (this.state.rate > 0) { + // Calculate how many bytes we have left to send. + var bytesToWrite = this.state.totalBytes - this.state.sentBytes; + + // Do we have any bytes left to send? If not we'll just fall thru and + // cancel our repeating timer and finalize the response. + if (bytesToWrite > 0) { + // Figure out how many bytes to send, based on the rate limit. + bytesToWrite = + (bytesToWrite > this.state.rate) ? this.state.rate : bytesToWrite; + + for (let i = 0; i < bytesToWrite; i++) { + try { + this.response.bodyOutputStream.write("0", 1); + } catch (e) { + // Connection was closed by client. + if (e == Components.results.NS_ERROR_NOT_AVAILABLE) { + // There's no harm in calling this multiple times. + this.response.finish(); + + // It's possible that our timer wasn't cancelled in time + // and we'll be called again. + if (this.timer) { + this.timer.cancel(); + this.timer = null; + } + + return; + } + } + } + + // Update the number of bytes we've sent to the client. + this.state.sentBytes += bytesToWrite; + + // Wait until the next call to do anything else. + return; + } + } + else { + // Not rate limited, write it all out. + for (let i = 0; i < this.state.totalBytes; i++) { + this.response.write("0"); + } + } + + // Finalize the response. + this.response.finish(); + + // All done sending, go ahead and cancel our repeating timer. + this.timer.cancel(); + + // Clear the timer. + this.timer = null; +} + +function handleRequest(request, response) { + var query = getQuery(request); + + // sending at a specific rate requires our response to be asynchronous so + // we handle all requests asynchronously. See handleResponse(). + response.processAsync(); + + // Default status when responding. + var version = "1.1"; + var statusCode = 200; + var description = "OK"; + + // Default values for content type, size and rate. + var contentType = "text/plain"; + var contentRange = null; + var size = 1024; + var rate = 0; + + // optional content type to be used by our response. + if ("contentType" in query) { + contentType = query["contentType"]; + } + + // optional size (in bytes) for generated file. + if ("size" in query) { + size = parseInt(query["size"]); + } + + // optional range request check. + if (request.hasHeader("range")) { + version = "1.1"; + statusCode = 206; + description = "Partial Content"; + + // We'll only support simple range byte style requests. + var [offset, total] = request.getHeader("range").slice("bytes=".length).split("-"); + // Enforce valid Number values. + offset = parseInt(offset); + offset = isNaN(offset) ? 0 : offset; + // Same. + total = parseInt(total); + total = isNaN(total) ? 0 : total; + + // We'll need to original total size as part of the Content-Range header + // value in our response. + var originalSize = size; + + // If we have a total size requested, we must make sure to send that number + // of bytes only (minus the start offset). + if (total && total < size) { + size = total - offset; + } else if (offset) { + // Looks like we just have a byte offset to deal with. + size = size - offset; + } + + // We specifically need to add a Content-Range header to all responses for + // requests that include a range request header. + contentRange = "bytes " + offset + "-" + (size - 1) + "/" + originalSize; + } + + // optional rate (in bytes/s) at which to send the file. + if ("rate" in query) { + rate = parseInt(query["rate"]); + } + + // The context for the responseHandler. + var context = { + response: response, + state: { + contentType: contentType, + totalBytes: size, + sentBytes: 0, + rate: rate + }, + timer: null + }; + + // The notify implementation for the timer. + context.notify = handleResponse.bind(context); + + context.timer = + Components.classes["@mozilla.org/timer;1"] + .createInstance(Components.interfaces.nsITimer); + + // generate the content. + response.setStatusLine(version, statusCode, description); + response.setHeader("Content-Type", contentType, false); + if (contentRange) { + response.setHeader("Content-Range", contentRange, false); + } + response.setHeader("Content-Length", size.toString(), false); + + // initialize the timer and start writing out the response. + context.timer.initWithCallback( + context, + 1000, + Components.interfaces.nsITimer.TYPE_REPEATING_SLACK + ); + +} diff --git a/dom/downloads/tests/test_downloads_bad_file.html b/dom/downloads/tests/test_downloads_bad_file.html new file mode 100644 index 000000000..a2b3992e6 --- /dev/null +++ b/dom/downloads/tests/test_downloads_bad_file.html @@ -0,0 +1,93 @@ +<!DOCTYPE html> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=960749 +--> +<head> + <title>Test for Bug 960749 Downloads API</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=960749">Mozilla Bug 960749</a> +<p id="display"></p> +<div id="content" style="display: none"> +</div> +<a href="serve_file.sjs?contentType=application/octet-stream&size=1024" download=".<.EVIL.>\ / : * ? " |file.bin" id="download1">Download #1</a> +<pre id="test"> +<script class="testbody" type="text/javascript;version=1.7"> + +// Testing a simple download, waiting for it to be done. + +SimpleTest.waitForExplicitFinish(); + +var index = -1; +var expected = "_.EVIL.__ _ _ _ _ _ _file.bin"; + +function next() { + index += 1; + if (index >= steps.length) { + ok(false, "Shouldn't get here!"); + return; + } + try { + steps[index](); + } catch(ex) { + ok(false, "Caught exception", ex); + } +} + +function checkTargetFilename(download) { + ok(download.path.endsWith(expected), + "Download path leaf name '" + download.path + + "' should match '" + expected + "' filename."); + + SimpleTest.finish(); +} + +function downloadChange(evt) { + var download = evt.download; + + if (download.state === "succeeded") { + checkTargetFilename(download); + } +} + +function downloadStart(evt) { + var download = evt.download; + download.onstatechange = downloadChange; +} + +var steps = [ + // Start by setting the pref to true. + function() { + SpecialPowers.pushPrefEnv({ + set: [["dom.mozDownloads.enabled", true]] + }, next); + }, + + // Setup the event listeners. + function() { + SpecialPowers.pushPermissions([ + {type: "downloads", allow: true, context: document} + ], function() { + navigator.mozDownloadManager.ondownloadstart = downloadStart; + next(); + }); + }, + + // Click on the <a download> to start the download. + function() { + document.getElementById("download1").click(); + } +]; + +next(); + +</script> +</pre> +</body> +</html> + diff --git a/dom/downloads/tests/test_downloads_basic.html b/dom/downloads/tests/test_downloads_basic.html new file mode 100644 index 000000000..051a1faa1 --- /dev/null +++ b/dom/downloads/tests/test_downloads_basic.html @@ -0,0 +1,128 @@ +<!DOCTYPE html> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=938023 +--> +<head> + <title>Test for Bug 938023 Downloads API</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=938023">Mozilla Bug 938023</a> +<p id="display"></p> +<div id="content" style="display: none"> +</div> +<a href="serve_file.sjs?contentType=application/octet-stream&size=1024" download="test.bin" id="download1">Download #1</a> +<pre id="test"> +<script class="testbody" type="text/javascript;version=1.7"> + +// Testing a simple download, waiting for it to be done. + +SimpleTest.waitForExplicitFinish(); + +var index = -1; +var todayDate = new Date(); +var baseServeURL = "http://mochi.test:8888/tests/dom/downloads/tests/"; +var lastKnownCurrentBytes = 0; + +function next() { + index += 1; + if (index >= steps.length) { + ok(false, "Shouldn't get here!"); + return; + } + try { + steps[index](); + } catch(ex) { + ok(false, "Caught exception", ex); + } +} + +function checkConsistentDownloadAttributes(download) { + var href = document.getElementById("download1").getAttribute("href"); + var expectedServeURL = baseServeURL + href; + var destinationRegEx = /test\(?[0-9]*\)?\.bin$/; + + // bug 945323: Download path isn't honoring download attribute + ok(destinationRegEx.test(download.path), + "Download path '" + download.path + + "' should match '" + destinationRegEx + "' regexp."); + + ok(download.startTime >= todayDate, + "Download start time should be greater than or equal to today"); + + is(download.error, null, "Download does not have an error"); + + is(download.url, expectedServeURL, + "Download URL = " + expectedServeURL); + ok(download.id !== null, "Download id is defined"); + is(download.contentType, "application/octet-stream", + "Download content type is application/octet-stream"); +} + +function downloadChange(evt) { + var download = evt.download; + checkConsistentDownloadAttributes(download); + is(download.totalBytes, 1024, "Download total size is 1024 bytes"); + + if (download.state === "succeeded") { + 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; + } else { + ok(false, "Unexpected download state = " + download.state); + } +} + +function downloadStart(evt) { + var download = evt.download; + checkConsistentDownloadAttributes(download); + + // 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; +} + +var steps = [ + // Start by setting the pref to true. + function() { + SpecialPowers.pushPrefEnv({ + set: [["dom.mozDownloads.enabled", true]] + }, next); + }, + + // Setup the event listeners. + function() { + SpecialPowers.pushPermissions([ + {type: "downloads", allow: true, context: document} + ], function() { + navigator.mozDownloadManager.ondownloadstart = downloadStart; + next(); + }); + }, + + // Click on the <a download> to start the download. + function() { + document.getElementById("download1").click(); + } +]; + +next(); + +</script> +</pre> +</body> +</html> + diff --git a/dom/downloads/tests/test_downloads_large.html b/dom/downloads/tests/test_downloads_large.html new file mode 100644 index 000000000..9f7f73c19 --- /dev/null +++ b/dom/downloads/tests/test_downloads_large.html @@ -0,0 +1,110 @@ +<!DOCTYPE html> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=938023 +--> +<head> + <title>Test for Bug 938023 Downloads API</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="clear_all_done_helper.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=938023">Mozilla Bug 938023</a> +<p id="display"></p> +<div id="content" style="display: none"> +</div> +<a href="serve_file.sjs?contentType=application/octet-stream&size=102400" download="test.bin" id="download1">Large Download</a> +<pre id="test"> +<script class="testbody" type="text/javascript;version=1.7"> + +// Testing downloading a file, then checking getDownloads() and clearAllDone(). + +SimpleTest.waitForExplicitFinish(); + +var index = -1; + +function next(args) { + index += 1; + if (index >= steps.length) { + ok(false, "Shouldn't get here!"); + return; + } + try { + steps[index](args); + } catch(ex) { + ok(false, "Caught exception", ex); + } +} + +// Catch all error function. +function error() { + ok(false, "API failure"); + SimpleTest.finish(); +} + +function getDownloads(downloads) { + ok(downloads.length == 1, "One downloads after getDownloads"); + clearAllDoneHelper(true).then(clearAllDone, error); +} + +function clearAllDone(downloads) { + ok(downloads.length == 0, "No downloads after clearAllDone"); + SimpleTest.finish(); +} + +function downloadChange(evt) { + var download = evt.download; + + if (download.state == "succeeded") { + ok(download.totalBytes == 102400, "Download size is 100k bytes."); + navigator.mozDownloadManager.getDownloads().then(getDownloads, error); + } +} + +var steps = [ + // Start by setting the pref to true. + function() { + SpecialPowers.pushPrefEnv({ + set: [["dom.mozDownloads.enabled", true]] + }, next); + }, + + // Setup permission and clear current list. + function() { + SpecialPowers.pushPermissions([ + {type: "downloads", allow: true, context: document} + ], function() { + clearAllDoneHelper(true).then(next, error); + }); + }, + + function(downloads) { + ok(downloads.length == 0, "Start with an empty download list."); + next(); + }, + + // Setup the event listeners. + function() { + navigator.mozDownloadManager.ondownloadstart = + function(evt) { + ok(true, "Download started"); + evt.download.addEventListener("statechange", downloadChange); + } + next(); + }, + + // Click on the <a download> to start the download. + function() { + document.getElementById("download1").click(); + } +]; + +next(); + +</script> +</pre> +</body> +</html> diff --git a/dom/downloads/tests/test_downloads_navigator_object.html b/dom/downloads/tests/test_downloads_navigator_object.html new file mode 100644 index 000000000..1c38388b7 --- /dev/null +++ b/dom/downloads/tests/test_downloads_navigator_object.html @@ -0,0 +1,75 @@ +<!DOCTYPE html> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=938023 +--> +<head> + <title>Test for Bug 938023 Downloads API</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=938023">Mozilla Bug 938023</a> +<p id="display"></p> +<div id="content" style="display: none"> +<iframe></iframe> +</div> +<pre id="test"> +<script class="testbody" type="text/javascript;version=1.7"> + +SimpleTest.waitForExplicitFinish(); + +var index = -1; + +function next() { + index += 1; + if (index >= steps.length) { + ok(false, "Shouldn't get here!"); + return; + } + try { + steps[index](); + } catch(ex) { + ok(false, "Caught exception", ex); + } +} + +var steps = [ + // Start by setting the pref to true. + function() { + SpecialPowers.pushPrefEnv({ + set: [["dom.mozDownloads.enabled", true]] + }, next); + }, + + function() { + SpecialPowers.pushPermissions([ + {type: "downloads", allow: 0, context: document} + ], function() { + is(frames[0].navigator.mozDownloadManager, null, "navigator.mozDownloadManager is null when the page doesn't have permissions"); + next(); + }); + }, + + function() { + SpecialPowers.pushPrefEnv({ + set: [["dom.mozDownloads.enabled", false]] + }, function() { + is(navigator.mozDownloadManager, undefined, "navigator.mozDownloadManager is undefined"); + next(); + }); + }, + + function() { + SimpleTest.finish(); + } +]; + +next(); + +</script> +</pre> +</body> +</html> diff --git a/dom/downloads/tests/test_downloads_pause_remove.html b/dom/downloads/tests/test_downloads_pause_remove.html new file mode 100644 index 000000000..3b410a667 --- /dev/null +++ b/dom/downloads/tests/test_downloads_pause_remove.html @@ -0,0 +1,117 @@ +<!DOCTYPE html> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=938023 +--> +<head> + <title>Test for Bug 938023 Downloads API</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="clear_all_done_helper.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=938023">Mozilla Bug 938023</a> +<p id="display"></p> +<div id="content" style="display: none"> +</div> +<a href="serve_file.sjs?contentType=application/octet-stream&size=102400&rate=1024" download="test.bin" id="download1">Large Download</a> +<pre id="test"> +<script class="testbody" type="text/javascript;version=1.7"> + +// Testing pausing a download and then removing it. + +SimpleTest.waitForExplicitFinish(); + +var index = -1; + +function next(args) { + index += 1; + if (index >= steps.length) { + ok(false, "Shouldn't get here!"); + return; + } + try { + steps[index](args); + } catch(ex) { + ok(false, "Caught exception", ex); + } +} + +var pausing = false; + +// Catch all error function. +function error() { + ok(false, "API failure"); + SimpleTest.finish(); +} + +function checkDownloadList(downloads) { + ok(downloads.length == 0, "No downloads left"); + SimpleTest.finish(); +} + +function checkRemoved(download) { + ok(download.state == "finalized", "Download removed."); + navigator.mozDownloadManager.getDownloads() + .then(checkDownloadList, error); +} + +function downloadChange(evt) { + var download = evt.download; + + if (download.state == "downloading" && !pausing) { + pausing = true; + download.pause(); + } else if (download.state == "stopped") { + ok(pausing, "Download stopped by pause()"); + navigator.mozDownloadManager.remove(download) + .then(checkRemoved, error); + } +} + +var steps = [ + // Start by setting the pref to true. + function() { + SpecialPowers.pushPrefEnv({ + set: [["dom.mozDownloads.enabled", true]] + }, next); + }, + + // Setup permission and clear current list. + function() { + SpecialPowers.pushPermissions([ + {type: "downloads", allow: true, context: document} + ], function() { + clearAllDoneHelper(true).then(next, error); + }); + }, + + function(downloads) { + ok(downloads.length == 0, "Start with an empty download list."); + next(); + }, + + // Setup the event listeners. + function() { + navigator.mozDownloadManager.ondownloadstart = + function(evt) { + ok(true, "Download started"); + evt.download.addEventListener("statechange", downloadChange); + } + next(); + }, + + // Click on the <a download> to start the download. + function() { + document.getElementById("download1").click(); + } +]; + +next(); + +</script> +</pre> +</body> +</html> diff --git a/dom/downloads/tests/test_downloads_pause_resume.html b/dom/downloads/tests/test_downloads_pause_resume.html new file mode 100644 index 000000000..76e249e5a --- /dev/null +++ b/dom/downloads/tests/test_downloads_pause_resume.html @@ -0,0 +1,121 @@ +<!DOCTYPE html> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=938023 +--> +<head> + <title>Test for Bug 938023 Downloads API</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="clear_all_done_helper.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=938023">Mozilla Bug 938023</a> +<p id="display"></p> +<div id="content" style="display: none"> +</div> +<a href="serve_file.sjs?contentType=application/octet-stream&size=102400&rate=1024" download="test.bin" id="download1">Large Download</a> +<pre id="test"> +<script class="testbody" type="text/javascript;version=1.7"> + +// Testing pausing a download and then resuming it. + +SimpleTest.waitForExplicitFinish(); + +var index = -1; + +function next(args) { + index += 1; + if (index >= steps.length) { + ok(false, "Shouldn't get here!"); + return; + } + try { + steps[index](args); + } catch(ex) { + ok(false, "Caught exception", ex); + } +} + +var pausing = false; +var resuming = false; + +// Catch all error function. +function error() { + ok(false, "API failure"); + SimpleTest.finish(); +} + +function checkDownloadList(downloads) { + ok(downloads.length == 0, "No downloads left"); + SimpleTest.finish(); +} + +function checkResumeSucceeded(download) { + ok(download.state == "succeeded", "Download resumed successfully."); + clearAllDoneHelper(true).then(checkDownloadList, error); +} + +function downloadChange(evt) { + var download = evt.download; + + info("got download event, state: " + download.state + + " current bytes: " + download.currentBytes + + " pausing?: " + pausing + " resuming?: " + resuming); + if (download.state == "downloading" && !pausing) { + pausing = true; + download.pause(); + } else if (download.state == "stopped" && !resuming) { + resuming = true; + ok(pausing, "Download stopped by pause()"); + download.resume() + .then(function() { checkResumeSucceeded(download); }, error); + } +} + +var steps = [ + // Start by setting the pref to true. + function() { + SpecialPowers.pushPrefEnv({ + set: [["dom.mozDownloads.enabled", true]] + }, next); + }, + + // Setup permission and clear current list. + function() { + SpecialPowers.pushPermissions([ + {type: "downloads", allow: true, context: document} + ], function() { + clearAllDoneHelper(true).then(next, error); + }); + }, + + function(downloads) { + ok(downloads.length == 0, "Start with an empty download list."); + next(); + }, + + // Setup the event listeners. + function() { + navigator.mozDownloadManager.ondownloadstart = + function(evt) { + ok(true, "Download started"); + evt.download.addEventListener("statechange", downloadChange); + } + next(); + }, + + // Click on the <a download> to start the download. + function() { + document.getElementById("download1").click(); + } +]; + +next(); + +</script> +</pre> +</body> +</html> |