gecko-dev/dom/downloads/DownloadsAPI.js

518 lines
16 KiB
JavaScript

/* 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]);