diff --git a/b2g/app/b2g.js b/b2g/app/b2g.js index 1fce5e971cf0..c684168d1527 100644 --- a/b2g/app/b2g.js +++ b/b2g/app/b2g.js @@ -860,6 +860,10 @@ pref("b2g.neterror.url", "app://system.gaiamobile.org/net_error.html"); // Enable Web Speech synthesis API pref("media.webspeech.synth.enabled", true); +// Downloads API +pref("dom.mozDownloads.enabled", true); +pref("dom.downloads.max_retention_days", 7); + // Downloads API pref("dom.mozDownloads.enabled", true); diff --git a/b2g/chrome/content/shell.js b/b2g/chrome/content/shell.js index d83ed30c3de1..be0456240e58 100644 --- a/b2g/chrome/content/shell.js +++ b/b2g/chrome/content/shell.js @@ -26,6 +26,8 @@ Cu.import('resource://gre/modules/SignInToWebsite.jsm'); SignInToWebsiteController.init(); Cu.import('resource://gre/modules/FxAccountsMgmtService.jsm'); +Cu.import('resource://gre/modules/DownloadsAPI.jsm'); + XPCOMUtils.defineLazyServiceGetter(Services, 'env', '@mozilla.org/process/environment;1', 'nsIEnvironment'); @@ -1495,3 +1497,21 @@ Services.obs.addObserver(function resetProfile(subject, topic, data) { .getService(Ci.nsIAppStartup); appStartup.quit(Ci.nsIAppStartup.eForceQuit); }, 'b2g-reset-profile', false); + +/** + * CID of our implementation of nsIDownloadManagerUI. + */ +const kTransferCid = Components.ID("{1b4c85df-cbdd-4bb6-b04e-613caece083c}"); + +/** + * Contract ID of the service implementing nsITransfer. + */ +const kTransferContractId = "@mozilla.org/transfer;1"; + +// Override Toolkit's nsITransfer implementation with the one from the +// JavaScript API for downloads. This will eventually be removed when +// nsIDownloadManager will not be available anymore (bug 851471). The +// old code in this module will be removed in bug 899110. +Components.manager.QueryInterface(Ci.nsIComponentRegistrar) + .registerFactory(kTransferCid, "", + kTransferContractId, null); diff --git a/b2g/confvars.sh b/b2g/confvars.sh index e633a4ba3807..16ecbc1fac1c 100644 --- a/b2g/confvars.sh +++ b/b2g/confvars.sh @@ -60,3 +60,5 @@ if test "$OS_TARGET" = "Android"; then MOZ_NUWA_PROCESS= fi MOZ_FOLD_LIBS=1 + +MOZ_JSDOWNLOADS=1 diff --git a/b2g/installer/package-manifest.in b/b2g/installer/package-manifest.in index 1d72a7f21abc..23243a990137 100644 --- a/b2g/installer/package-manifest.in +++ b/b2g/installer/package-manifest.in @@ -393,6 +393,8 @@ @BINPATH@/components/jsconsole-clhandler.js @BINPATH@/components/nsDownloadManagerUI.manifest @BINPATH@/components/nsDownloadManagerUI.js +@BINPATH@/components/Downloads.manifest +@BINPATH@/components/DownloadLegacy.js @BINPATH@/components/nsSidebar.manifest @BINPATH@/components/nsSidebar.js @@ -562,6 +564,9 @@ @BINPATH@/components/PaymentRequestInfo.js @BINPATH@/components/Payment.manifest +@BINPATH@/components/DownloadsAPI.js +@BINPATH@/components/DownloadsAPI.manifest + ; InputMethod API @BINPATH@/components/MozKeyboard.js @BINPATH@/components/InputMethod.manifest @@ -786,6 +791,7 @@ bin/components/@DLL_PREFIX@nkgnomevfs@DLL_SUFFIX@ @BINPATH@/components/FilePicker.js @BINPATH@/components/FxAccountsUIGlue.js @BINPATH@/components/HelperAppDialog.js +@BINPATH@/components/DownloadsUI.js @BINPATH@/components/DataStore.manifest @BINPATH@/components/DataStoreService.js diff --git a/content/events/test/test_all_synthetic_events.html b/content/events/test/test_all_synthetic_events.html index 169e49edbfce..fbef23faf409 100644 --- a/content/events/test/test_all_synthetic_events.html +++ b/content/events/test/test_all_synthetic_events.html @@ -128,6 +128,10 @@ const kEventConstructors = { return new DeviceStorageChangeEvent(aName, aProps); }, }, + DownloadEvent: { create: function (aName, aProps) { + return new DownloadEvent(aName, aProps); + }, + }, DOMTransactionEvent: { create: function (aName, aProps) { return new DOMTransactionEvent(aName, aProps); }, diff --git a/dom/apps/src/PermissionsTable.jsm b/dom/apps/src/PermissionsTable.jsm index 2c5acb717bfc..516c7c8f8005 100644 --- a/dom/apps/src/PermissionsTable.jsm +++ b/dom/apps/src/PermissionsTable.jsm @@ -318,6 +318,11 @@ this.PermissionsTable = { geolocation: { privileged: ALLOW_ACTION, certified: ALLOW_ACTION }, + "downloads": { + app: DENY_ACTION, + privileged: DENY_ACTION, + certified: ALLOW_ACTION + }, }; /** diff --git a/dom/base/DOMRequestHelper.jsm b/dom/base/DOMRequestHelper.jsm index 2069ebafe8fa..61994cbf018d 100644 --- a/dom/base/DOMRequestHelper.jsm +++ b/dom/base/DOMRequestHelper.jsm @@ -184,12 +184,13 @@ DOMRequestIpcHelper.prototype = { this._listeners = null; this._requests = null; - this._window = null; // Objects inheriting from DOMRequestIPCHelper may have an uninit function. if (this.uninit) { this.uninit(); } + + this._window = null; }, observe: function(aSubject, aTopic, aData) { diff --git a/dom/base/Navigator.cpp b/dom/base/Navigator.cpp index e48b47b5e072..403ae203b7df 100644 --- a/dom/base/Navigator.cpp +++ b/dom/base/Navigator.cpp @@ -1539,6 +1539,13 @@ Navigator::DoNewResolve(JSContext* aCx, JS::Handle aObject, } } + if (name.EqualsLiteral("mozDownloadManager")) { + if (!CheckPermission("downloads")) { + aValue.setNull(); + return true; + } + } + domObject = construct(aCx, naviObj); if (!domObject) { return Throw(aCx, NS_ERROR_FAILURE); diff --git a/dom/downloads/moz.build b/dom/downloads/moz.build new file mode 100644 index 000000000000..6f1f71f35dae --- /dev/null +++ b/dom/downloads/moz.build @@ -0,0 +1,10 @@ +# -*- Mode: python; c-basic-offset: 4; 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"]: + TEST_DIRS += ['tests'] + +PARALLEL_DIRS += ['src'] diff --git a/dom/downloads/src/DownloadsAPI.js b/dom/downloads/src/DownloadsAPI.js new file mode 100644 index 000000000000..56586bb99698 --- /dev/null +++ b/dom/downloads/src/DownloadsAPI.js @@ -0,0 +1,320 @@ +/* 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; + +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"); + +function debug(aStr) { + dump("-*- DownloadsAPI.js : " + aStr + "\n"); +} + +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"]); + }, + + 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 = Cu.createArrayIn(this._window); + 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()"); + 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 = Cu.createArrayIn(this._window); + 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)); + }, + + 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)); + }, + + /** + * 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. + */ +let 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.state = "stopped"; + this.contentType = null; + this.startTime = Date.now(); + this.error = null; + + /* 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"); + }, + + _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", "state", + "contentType", "startTime"]; + let changed = false; + + props.forEach((prop) => { + if (aDownload[prop] && (aDownload[prop] != this[prop])) { + this[prop] = aDownload[prop]; + changed = true; + } + }); + + if (aDownload.error) { + this.error = new this._window.DOMError("DownloadError", aDownload.error); + } else { + this.error = null; + } + + // The visible state has not changed, so no need to fire an event. + if (!changed) { + return; + } + + // __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/src/DownloadsAPI.jsm b/dom/downloads/src/DownloadsAPI.jsm new file mode 100644 index 000000000000..7956d5339deb --- /dev/null +++ b/dom/downloads/src/DownloadsAPI.jsm @@ -0,0 +1,255 @@ +/* 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"); + +XPCOMUtils.defineLazyServiceGetter(this, "ppmm", + "@mozilla.org/parentprocessmessagemanager;1", + "nsIMessageBroadcaster"); + +function debug(aStr) { + dump("-*- DownloadsAPI.jsm : " + aStr + "\n"); +} + +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); +} + +let 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"].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() + }; + + if (aDownload.error) { + res.error = aDownload.error.name; + } + + 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); + // Removing 'Downloads:' and turning first letter to lower case to + // build the function name from the message name. + let c = aMessage.name[10].toLowerCase(); + let methodName = c + aMessage.name.substring(11); + if (this[methodName] && typeof this[methodName] === "function") { + this[methodName](aMessage.data, aMessage.target); + } else { + debug("Unimplemented method: " + methodName); + } + }, + + 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!"); + 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); + }).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"); + } + ); + } +}; + +DownloadsAPI.init(); diff --git a/dom/downloads/src/DownloadsAPI.manifest b/dom/downloads/src/DownloadsAPI.manifest new file mode 100644 index 000000000000..8d6dc9396bc9 --- /dev/null +++ b/dom/downloads/src/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/src/DownloadsIPC.jsm b/dom/downloads/src/DownloadsIPC.jsm new file mode 100644 index 000000000000..9fa6e14126ef --- /dev/null +++ b/dom/downloads/src/DownloadsIPC.jsm @@ -0,0 +1,221 @@ +/* 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) { + dump("-*- DownloadsIPC.jsm : " + aStr + "\n"); +} + +const ipcMessages = ["Downloads:Added", + "Downloads:Removed", + "Downloads:Changed", + "Downloads:GetList:Return", + "Downloads:ClearAllDone:Return", + "Downloads:Remove:Return", + "Downloads:Pause:Return", + "Downloads:Resume: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.clearAllPromises = []; + 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 + " " + download.id); + 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: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); + 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": + 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; + }, + + /** + * Returns a promise that is resolved with the list of current downloads. + */ + clearAllDone: function() { + debug("clearAllDone"); + let deferred = Promise.defer(); + this.clearAllPromises.push(deferred); + cpmm.sendAsyncMessage("Downloads:ClearAllDone", {}); + return deferred.promise; + }, + + 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; + }, + + observe: function(aSubject, aTopic, aData) { + if (aTopic == "xpcom-shutdown") { + ipcMessages.forEach((aMessage) => { + cpmm.removeMessageListener(aMessage, this); + }); + } + } +}; + +DownloadsIPC.init(); diff --git a/dom/downloads/src/moz.build b/dom/downloads/src/moz.build new file mode 100644 index 000000000000..2e3b55cacfab --- /dev/null +++ b/dom/downloads/src/moz.build @@ -0,0 +1,15 @@ +# -*- Mode: python; c-basic-offset: 4; 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/. + +EXTRA_COMPONENTS += [ + 'DownloadsAPI.js', + 'DownloadsAPI.manifest', +] + +EXTRA_JS_MODULES += [ + 'DownloadsAPI.jsm', + 'DownloadsIPC.jsm', +] diff --git a/dom/downloads/tests/mochitest.ini b/dom/downloads/tests/mochitest.ini new file mode 100644 index 000000000000..06a038f9b9ce --- /dev/null +++ b/dom/downloads/tests/mochitest.ini @@ -0,0 +1,9 @@ +[DEFAULT] +support-files = + serve_file.sjs + +[test_downloads_navigator_object.html] +[test_downloads_basic.html] +[test_downloads_large.html] +[test_downloads_pause_remove.html] +[test_downloads_pause_resume.html] diff --git a/dom/downloads/tests/moz.build b/dom/downloads/tests/moz.build new file mode 100644 index 000000000000..8421b15157a7 --- /dev/null +++ b/dom/downloads/tests/moz.build @@ -0,0 +1,7 @@ +# -*- Mode: python; c-basic-offset: 4; 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/. + +MOCHITEST_MANIFESTS += ['mochitest.ini'] diff --git a/dom/downloads/tests/serve_file.sjs b/dom/downloads/tests/serve_file.sjs new file mode 100644 index 000000000000..9a46e5955eae --- /dev/null +++ b/dom/downloads/tests/serve_file.sjs @@ -0,0 +1,107 @@ +// 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; +} + +// Timer used to handle the request response. +var timer = null; + +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++) { + this.response.write("0"); + } + + // 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. + timer.cancel(); +} + +function handleRequest(request, response) { + var query = getQuery(request); + + // Default values for content type, size and rate. + var contentType = "text/plain"; + 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 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 + } + }; + + // The notify implementation for the timer. + context.notify = handleResponse.bind(context); + + timer = + Components.classes["@mozilla.org/timer;1"] + .createInstance(Components.interfaces.nsITimer); + + // sending at a specific rate requires our response to be asynchronous so + // we handle all requests asynchronously. See handleResponse(). + response.processAsync(); + + // generate the content. + response.setHeader("Content-Type", contentType, false); + response.setHeader("Content-Length", size.toString(), false); + + // initialize the timer and start writing out the response. + timer.initWithCallback(context, + 1000, + Components.interfaces.nsITimer.TYPE_REPEATING_SLACK); + +} diff --git a/dom/downloads/tests/test_downloads_basic.html b/dom/downloads/tests/test_downloads_basic.html new file mode 100644 index 000000000000..7efb1ed87460 --- /dev/null +++ b/dom/downloads/tests/test_downloads_basic.html @@ -0,0 +1,84 @@ + + + + + Test for Bug 938023 Downloads API + + + + + + +Mozilla Bug 938023 +

+ +Download #1 +
+
+
+ + diff --git a/dom/downloads/tests/test_downloads_large.html b/dom/downloads/tests/test_downloads_large.html new file mode 100644 index 000000000000..66e491d6e595 --- /dev/null +++ b/dom/downloads/tests/test_downloads_large.html @@ -0,0 +1,109 @@ + + + + + Test for Bug 938023 Downloads API + + + + + + +Mozilla Bug 938023 +

+ +Large Download +
+
+
+ + 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 000000000000..62e6a9573df7 --- /dev/null +++ b/dom/downloads/tests/test_downloads_navigator_object.html @@ -0,0 +1,75 @@ + + + + + Test for Bug 938023 Downloads API + + + + + + +Mozilla Bug 938023 +

+ +
+
+
+ + 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 000000000000..864f4fc5a2b1 --- /dev/null +++ b/dom/downloads/tests/test_downloads_pause_remove.html @@ -0,0 +1,116 @@ + + + + + Test for Bug 938023 Downloads API + + + + + + +Mozilla Bug 938023 +

+ +Large Download +
+
+
+ + 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 000000000000..a39b5f45e7fd --- /dev/null +++ b/dom/downloads/tests/test_downloads_pause_resume.html @@ -0,0 +1,119 @@ + + + + + Test for Bug 938023 Downloads API + + + + + + +Mozilla Bug 938023 +

+ +Large Download +
+
+
+ + diff --git a/dom/moz.build b/dom/moz.build index a9e41ff46e16..60aa95f2991f 100644 --- a/dom/moz.build +++ b/dom/moz.build @@ -104,6 +104,9 @@ if CONFIG['MOZ_GAMEPAD']: if CONFIG['MOZ_NFC']: PARALLEL_DIRS += ['nfc'] +if CONFIG['MOZ_B2G']: + PARALLEL_DIRS += ['downloads'] + TEST_DIRS += [ 'tests', 'imptests', diff --git a/dom/tests/mochitest/general/test_interfaces.html b/dom/tests/mochitest/general/test_interfaces.html index f6c6217451be..b80df7316a6a 100644 --- a/dom/tests/mochitest/general/test_interfaces.html +++ b/dom/tests/mochitest/general/test_interfaces.html @@ -220,6 +220,9 @@ var interfaceNamesInGlobalScope = "DOMStringMap", "DOMTokenList", "DOMTransactionEvent", + {name: "DOMDownload", b2g: true, pref: "dom.mozDownloads.enabled"}, + {name: "DOMDownloadManager", b2g: true, pref: "dom.mozDownloads.enabled"}, + {name: "DownloadEvent", b2g: true, pref: "dom.mozDownloads.enabled"}, "DragEvent", "DynamicsCompressorNode", "Element", diff --git a/dom/webidl/DownloadEvent.webidl b/dom/webidl/DownloadEvent.webidl new file mode 100644 index 000000000000..6cf7a4fa8fe4 --- /dev/null +++ b/dom/webidl/DownloadEvent.webidl @@ -0,0 +1,17 @@ +/* -*- Mode: IDL; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. + */ + +[Constructor(DOMString type, optional DownloadEventInit eventInitDict), + Pref="dom.mozDownloads.enabled"] +interface DownloadEvent : Event +{ + readonly attribute DOMDownload? download; +}; + +dictionary DownloadEventInit : EventInit +{ + DOMDownload? download = null; +}; diff --git a/dom/webidl/Downloads.webidl b/dom/webidl/Downloads.webidl new file mode 100644 index 000000000000..e547868991b3 --- /dev/null +++ b/dom/webidl/Downloads.webidl @@ -0,0 +1,75 @@ +/* -*- Mode: IDL; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. + */ + +[NavigatorProperty="mozDownloadManager", + JSImplementation="@mozilla.org/downloads/manager;1", + Pref="dom.mozDownloads.enabled"] +interface DOMDownloadManager : EventTarget { + // This promise returns an array of downloads with all the current + // download objects. + Promise getDownloads(); + + // Removes one download from the downloads set. Returns a promise resolved + // with the finalized download. + Promise remove(DOMDownload download); + + // Removes all the completed downloads from the set. + Promise clearAllDone(); + + // Fires when a new download starts. + attribute EventHandler ondownloadstart; +}; + +[JSImplementation="@mozilla.org/downloads/download;1", + Pref="dom.mozDownloads.enabled"] +interface DOMDownload : EventTarget { + // The full size of the resource. + readonly attribute long totalBytes; + + // The number of bytes that we have currently downloaded. + readonly attribute long currentBytes; + + // The url of the resource. + readonly attribute DOMString url; + + // The path in local storage where the file will end up once the download + // is complete. + readonly attribute DOMString path; + + // The state of the download. Can be any of: + // "downloading": The resource is actively transfering. + // "stopped" : No network tranfer is happening. + // "succeeded" : The resource has been downloaded successfully. + // "finalized" : We won't try to download this resource, but the DOM + // object is still alive. + readonly attribute DOMString state; + + // The mime type for this resource. + readonly attribute DOMString contentType; + + // The timestamp this download started. + readonly attribute Date startTime; + + // An opaque identifier for this download. All instances of the same + // download (eg. in different windows) will have the same id. + readonly attribute DOMString id; + + // A DOM error object, that will be not null when a download is stopped + // because something failed. + readonly attribute DOMError error; + + // Pauses the download. + Promise pause(); + + // Resumes the download. This resolves only once the download has + // succeeded. + Promise resume(); + + // This event is triggered anytime a property of the object changes: + // - when the transfer progresses, updating currentBytes. + // - when the state and/or error attributes change. + attribute EventHandler onstatechange; +}; diff --git a/dom/webidl/moz.build b/dom/webidl/moz.build index 2a0d1bee0c2f..4eeb5d9bcaac 100644 --- a/dom/webidl/moz.build +++ b/dom/webidl/moz.build @@ -86,6 +86,7 @@ WEBIDL_FILES = [ 'DOMStringMap.webidl', 'DOMTokenList.webidl', 'DOMTransaction.webidl', + 'Downloads.webidl', 'DragEvent.webidl', 'DummyBinding.webidl', 'DynamicsCompressorNode.webidl', @@ -559,6 +560,7 @@ GENERATED_EVENTS_WEBIDL_FILES = [ 'DataStoreChangeEvent.webidl', 'DeviceLightEvent.webidl', 'DeviceProximityEvent.webidl', + 'DownloadEvent.webidl', 'ErrorEvent.webidl', 'IccChangeEvent.webidl', 'MediaStreamEvent.webidl', diff --git a/toolkit/components/jsdownloads/src/DownloadIntegration.jsm b/toolkit/components/jsdownloads/src/DownloadIntegration.jsm index 49521f74dcae..0f8287549c82 100644 --- a/toolkit/components/jsdownloads/src/DownloadIntegration.jsm +++ b/toolkit/components/jsdownloads/src/DownloadIntegration.jsm @@ -308,7 +308,16 @@ this.DownloadIntegration = { // progress, as well as stopped downloads for which we retained partially // downloaded data. Stopped downloads for which we don't need to track the // presence of a ".part" file are only retained in the browser history. + // On b2g, we keep a few days of history. +#ifdef MOZ_B2G + let maxTime = Date.now() - + Services.prefs.getIntPref("dom.downloads.max_retention_days") * 24 * 60 * 60 * 1000; + return (aDownload.startTime > maxTime) || + aDownload.hasPartialData || + !aDownload.stopped; +#else return aDownload.hasPartialData || !aDownload.stopped; +#endif }, /** diff --git a/toolkit/components/jsdownloads/src/DownloadUIHelper.jsm b/toolkit/components/jsdownloads/src/DownloadUIHelper.jsm index 60756ef2d3b5..e6f268910585 100644 --- a/toolkit/components/jsdownloads/src/DownloadUIHelper.jsm +++ b/toolkit/components/jsdownloads/src/DownloadUIHelper.jsm @@ -108,7 +108,7 @@ XPCOMUtils.defineLazyGetter(DownloadUIHelper, "strings", function () { */ this.DownloadPrompter = function (aParent) { -#ifdef MOZ_WIDGET_GONK +#ifdef MOZ_B2G // On B2G there is no prompter implementation. this._prompter = null; #else