Mypal/toolkit/components/webextensions/ext-downloads.js

800 lines
26 KiB
JavaScript

"use strict";
var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Downloads",
"resource://gre/modules/Downloads.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "DownloadPaths",
"resource://gre/modules/DownloadPaths.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS",
"resource://gre/modules/osfile.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
"resource://gre/modules/FileUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
"resource://gre/modules/NetUtil.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter",
"resource://devtools/shared/event-emitter.js");
Cu.import("resource://gre/modules/ExtensionUtils.jsm");
const {
ignoreEvent,
normalizeTime,
runSafeSync,
SingletonEventManager,
PlatformInfo,
} = ExtensionUtils;
const DOWNLOAD_ITEM_FIELDS = ["id", "url", "referrer", "filename", "incognito",
"danger", "mime", "startTime", "endTime",
"estimatedEndTime", "state",
"paused", "canResume", "error",
"bytesReceived", "totalBytes",
"fileSize", "exists",
"byExtensionId", "byExtensionName"];
// Fields that we generate onChanged events for.
const DOWNLOAD_ITEM_CHANGE_FIELDS = ["endTime", "state", "paused", "canResume",
"error", "exists"];
// From https://fetch.spec.whatwg.org/#forbidden-header-name
const FORBIDDEN_HEADERS = ["ACCEPT-CHARSET", "ACCEPT-ENCODING",
"ACCESS-CONTROL-REQUEST-HEADERS", "ACCESS-CONTROL-REQUEST-METHOD",
"CONNECTION", "CONTENT-LENGTH", "COOKIE", "COOKIE2", "DATE", "DNT",
"EXPECT", "HOST", "KEEP-ALIVE", "ORIGIN", "REFERER", "TE", "TRAILER",
"TRANSFER-ENCODING", "UPGRADE", "VIA"];
const FORBIDDEN_PREFIXES = /^PROXY-|^SEC-/i;
class DownloadItem {
constructor(id, download, extension) {
this.id = id;
this.download = download;
this.extension = extension;
this.prechange = {};
}
get url() { return this.download.source.url; }
get referrer() { return this.download.source.referrer; }
get filename() { return this.download.target.path; }
get incognito() { return this.download.source.isPrivate; }
get danger() { return "safe"; } // TODO
get mime() { return this.download.contentType; }
get startTime() { return this.download.startTime; }
get endTime() { return null; } // TODO
get estimatedEndTime() { return null; } // TODO
get state() {
if (this.download.succeeded) {
return "complete";
}
if (this.download.canceled) {
return "interrupted";
}
return "in_progress";
}
get paused() {
return this.download.canceled && this.download.hasPartialData && !this.download.error;
}
get canResume() {
return (this.download.stopped || this.download.canceled) &&
this.download.hasPartialData && !this.download.error;
}
get error() {
if (!this.download.stopped || this.download.succeeded) {
return null;
}
// TODO store this instead of calculating it
if (this.download.error) {
if (this.download.error.becauseSourceFailed) {
return "NETWORK_FAILED"; // TODO
}
if (this.download.error.becauseTargetFailed) {
return "FILE_FAILED"; // TODO
}
return "CRASH";
}
return "USER_CANCELED";
}
get bytesReceived() {
return this.download.currentBytes;
}
get totalBytes() {
return this.download.hasProgress ? this.download.totalBytes : -1;
}
get fileSize() {
// todo: this is supposed to be post-compression
return this.download.succeeded ? this.download.target.size : -1;
}
get exists() { return this.download.target.exists; }
get byExtensionId() { return this.extension ? this.extension.id : undefined; }
get byExtensionName() { return this.extension ? this.extension.name : undefined; }
/**
* Create a cloneable version of this object by pulling all the
* fields into simple properties (instead of getters).
*
* @returns {object} A DownloadItem with flat properties,
* suitable for cloning.
*/
serialize() {
let obj = {};
for (let field of DOWNLOAD_ITEM_FIELDS) {
obj[field] = this[field];
}
if (obj.startTime) {
obj.startTime = obj.startTime.toISOString();
}
return obj;
}
// When a change event fires, handlers can look at how an individual
// field changed by comparing item.fieldname with item.prechange.fieldname.
// After all handlers have been invoked, this gets called to store the
// current values of all fields ahead of the next event.
_change() {
for (let field of DOWNLOAD_ITEM_CHANGE_FIELDS) {
this.prechange[field] = this[field];
}
}
}
// DownloadMap maps back and forth betwen the numeric identifiers used in
// the downloads WebExtension API and a Download object from the Downloads jsm.
// todo: make id and extension info persistent (bug 1247794)
const DownloadMap = {
currentId: 0,
loadPromise: null,
// Maps numeric id -> DownloadItem
byId: new Map(),
// Maps Download object -> DownloadItem
byDownload: new WeakMap(),
lazyInit() {
if (this.loadPromise == null) {
EventEmitter.decorate(this);
this.loadPromise = Downloads.getList(Downloads.ALL).then(list => {
let self = this;
return list.addView({
onDownloadAdded(download) {
const item = self.newFromDownload(download, null);
self.emit("create", item);
},
onDownloadRemoved(download) {
const item = self.byDownload.get(download);
if (item != null) {
self.emit("erase", item);
self.byDownload.delete(download);
self.byId.delete(item.id);
}
},
onDownloadChanged(download) {
const item = self.byDownload.get(download);
if (item == null) {
Cu.reportError("Got onDownloadChanged for unknown download object");
} else {
// We get the first one of these when the download is started.
// In this case, don't emit anything, just initialize prechange.
if (Object.keys(item.prechange).length > 0) {
self.emit("change", item);
}
item._change();
}
},
}).then(() => list.getAll())
.then(downloads => {
downloads.forEach(download => {
this.newFromDownload(download, null);
});
})
.then(() => list);
});
}
return this.loadPromise;
},
getDownloadList() {
return this.lazyInit();
},
getAll() {
return this.lazyInit().then(() => this.byId.values());
},
fromId(id) {
const download = this.byId.get(id);
if (!download) {
throw new Error(`Invalid download id ${id}`);
}
return download;
},
newFromDownload(download, extension) {
if (this.byDownload.has(download)) {
return this.byDownload.get(download);
}
const id = ++this.currentId;
let item = new DownloadItem(id, download, extension);
this.byId.set(id, item);
this.byDownload.set(download, item);
return item;
},
erase(item) {
// This will need to get more complicated for bug 1255507 but for now we
// only work with downloads in the DownloadList from getAll()
return this.getDownloadList().then(list => {
list.remove(item.download);
});
},
};
// Create a callable function that filters a DownloadItem based on a
// query object of the type passed to search() or erase().
function downloadQuery(query) {
let queryTerms = [];
let queryNegativeTerms = [];
if (query.query != null) {
for (let term of query.query) {
if (term[0] == "-") {
queryNegativeTerms.push(term.slice(1).toLowerCase());
} else {
queryTerms.push(term.toLowerCase());
}
}
}
function normalizeDownloadTime(arg, before) {
if (arg == null) {
return before ? Number.MAX_VALUE : 0;
}
return normalizeTime(arg).getTime();
}
const startedBefore = normalizeDownloadTime(query.startedBefore, true);
const startedAfter = normalizeDownloadTime(query.startedAfter, false);
// const endedBefore = normalizeDownloadTime(query.endedBefore, true);
// const endedAfter = normalizeDownloadTime(query.endedAfter, false);
const totalBytesGreater = query.totalBytesGreater || 0;
const totalBytesLess = (query.totalBytesLess != null)
? query.totalBytesLess : Number.MAX_VALUE;
// Handle options for which we can have a regular expression and/or
// an explicit value to match.
function makeMatch(regex, value, field) {
if (value == null && regex == null) {
return input => true;
}
let re;
try {
re = new RegExp(regex || "", "i");
} catch (err) {
throw new Error(`Invalid ${field}Regex: ${err.message}`);
}
if (value == null) {
return input => re.test(input);
}
value = value.toLowerCase();
if (re.test(value)) {
return input => (value == input);
}
return input => false;
}
const matchFilename = makeMatch(query.filenameRegex, query.filename, "filename");
const matchUrl = makeMatch(query.urlRegex, query.url, "url");
return function(item) {
const url = item.url.toLowerCase();
const filename = item.filename.toLowerCase();
if (!queryTerms.every(term => url.includes(term) || filename.includes(term))) {
return false;
}
if (queryNegativeTerms.some(term => url.includes(term) || filename.includes(term))) {
return false;
}
if (!matchFilename(filename) || !matchUrl(url)) {
return false;
}
if (!item.startTime) {
if (query.startedBefore != null || query.startedAfter != null) {
return false;
}
} else if (item.startTime > startedBefore || item.startTime < startedAfter) {
return false;
}
// todo endedBefore, endedAfter
if (item.totalBytes == -1) {
if (query.totalBytesGreater != null || query.totalBytesLess != null) {
return false;
}
} else if (item.totalBytes <= totalBytesGreater || item.totalBytes >= totalBytesLess) {
return false;
}
// todo: include danger
const SIMPLE_ITEMS = ["id", "mime", "startTime", "endTime", "state",
"paused", "error",
"bytesReceived", "totalBytes", "fileSize", "exists"];
for (let field of SIMPLE_ITEMS) {
if (query[field] != null && item[field] != query[field]) {
return false;
}
}
return true;
};
}
function queryHelper(query) {
let matchFn;
try {
matchFn = downloadQuery(query);
} catch (err) {
return Promise.reject({message: err.message});
}
let compareFn;
if (query.orderBy != null) {
const fields = query.orderBy.map(field => field[0] == "-"
? {reverse: true, name: field.slice(1)}
: {reverse: false, name: field});
for (let field of fields) {
if (!DOWNLOAD_ITEM_FIELDS.includes(field.name)) {
return Promise.reject({message: `Invalid orderBy field ${field.name}`});
}
}
compareFn = (dl1, dl2) => {
for (let field of fields) {
const val1 = dl1[field.name];
const val2 = dl2[field.name];
if (val1 < val2) {
return field.reverse ? 1 : -1;
} else if (val1 > val2) {
return field.reverse ? -1 : 1;
}
}
return 0;
};
}
return DownloadMap.getAll().then(downloads => {
if (compareFn) {
downloads = Array.from(downloads);
downloads.sort(compareFn);
}
let results = [];
for (let download of downloads) {
if (query.limit && results.length >= query.limit) {
break;
}
if (matchFn(download)) {
results.push(download);
}
}
return results;
});
}
extensions.registerSchemaAPI("downloads", "addon_parent", context => {
let {extension} = context;
return {
downloads: {
download(options) {
let {filename} = options;
if (filename && PlatformInfo.os === "win") {
// cross platform javascript code uses "/"
filename = filename.replace(/\//g, "\\");
}
if (filename != null) {
if (filename.length == 0) {
return Promise.reject({message: "filename must not be empty"});
}
let path = OS.Path.split(filename);
if (path.absolute) {
return Promise.reject({message: "filename must not be an absolute path"});
}
if (path.components.some(component => component == "..")) {
return Promise.reject({message: "filename must not contain back-references (..)"});
}
}
if (options.conflictAction == "prompt") {
// TODO
return Promise.reject({message: "conflictAction prompt not yet implemented"});
}
if (options.headers) {
for (let {name} of options.headers) {
if (FORBIDDEN_HEADERS.includes(name.toUpperCase()) || name.match(FORBIDDEN_PREFIXES)) {
return Promise.reject({message: "Forbidden request header name"});
}
}
}
// Handle method, headers and body options.
function adjustChannel(channel) {
if (channel instanceof Ci.nsIHttpChannel) {
const method = options.method || "GET";
channel.requestMethod = method;
if (options.headers) {
for (let {name, value} of options.headers) {
channel.setRequestHeader(name, value, false);
}
}
if (options.body != null) {
const stream = Cc["@mozilla.org/io/string-input-stream;1"]
.createInstance(Ci.nsIStringInputStream);
stream.setData(options.body, options.body.length);
channel.QueryInterface(Ci.nsIUploadChannel2);
channel.explicitSetUploadStream(stream, null, -1, method, false);
}
}
return Promise.resolve();
}
function createTarget(downloadsDir) {
let target;
if (filename) {
target = OS.Path.join(downloadsDir, filename);
} else {
let uri = NetUtil.newURI(options.url);
let remote = "download";
if (uri instanceof Ci.nsIURL) {
remote = uri.fileName;
}
target = OS.Path.join(downloadsDir, remote);
}
// Create any needed subdirectories if required by filename.
const dir = OS.Path.dirname(target);
return OS.File.makeDir(dir, {from: downloadsDir}).then(() => {
return OS.File.exists(target);
}).then(exists => {
// This has a race, something else could come along and create
// the file between this test and them time the download code
// creates the target file. But we can't easily fix it without
// modifying DownloadCore so we live with it for now.
if (exists) {
switch (options.conflictAction) {
case "uniquify":
default:
target = DownloadPaths.createNiceUniqueFile(new FileUtils.File(target)).path;
break;
case "overwrite":
break;
}
}
}).then(() => {
if (!options.saveAs) {
return Promise.resolve(target);
}
// Setup the file picker Save As dialog.
const picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
const window = Services.wm.getMostRecentWindow("navigator:browser");
picker.init(window, null, Ci.nsIFilePicker.modeSave);
picker.displayDirectory = new FileUtils.File(dir);
picker.appendFilters(Ci.nsIFilePicker.filterAll);
picker.defaultString = OS.Path.basename(target);
// Open the dialog and resolve/reject with the result.
return new Promise((resolve, reject) => {
picker.open(result => {
if (result === Ci.nsIFilePicker.returnCancel) {
reject({message: "Download canceled by the user"});
} else {
resolve(picker.file.path);
}
});
});
});
}
let download;
return Downloads.getPreferredDownloadsDirectory()
.then(downloadsDir => createTarget(downloadsDir))
.then(target => {
const source = {
url: options.url,
};
if (options.method || options.headers || options.body) {
source.adjustChannel = adjustChannel;
}
return Downloads.createDownload({
source,
target: {
path: target,
partFilePath: target + ".part",
},
});
}).then(dl => {
download = dl;
return DownloadMap.getDownloadList();
}).then(list => {
list.add(download);
// This is necessary to make pause/resume work.
download.tryToKeepPartialData = true;
download.start();
const item = DownloadMap.newFromDownload(download, extension);
return item.id;
});
},
removeFile(id) {
return DownloadMap.lazyInit().then(() => {
let item;
try {
item = DownloadMap.fromId(id);
} catch (err) {
return Promise.reject({message: `Invalid download id ${id}`});
}
if (item.state !== "complete") {
return Promise.reject({message: `Cannot remove incomplete download id ${id}`});
}
return OS.File.remove(item.filename, {ignoreAbsent: false}).catch((err) => {
return Promise.reject({message: `Could not remove download id ${item.id} because the file doesn't exist`});
});
});
},
search(query) {
return queryHelper(query)
.then(items => items.map(item => item.serialize()));
},
pause(id) {
return DownloadMap.lazyInit().then(() => {
let item;
try {
item = DownloadMap.fromId(id);
} catch (err) {
return Promise.reject({message: `Invalid download id ${id}`});
}
if (item.state != "in_progress") {
return Promise.reject({message: `Download ${id} cannot be paused since it is in state ${item.state}`});
}
return item.download.cancel();
});
},
resume(id) {
return DownloadMap.lazyInit().then(() => {
let item;
try {
item = DownloadMap.fromId(id);
} catch (err) {
return Promise.reject({message: `Invalid download id ${id}`});
}
if (!item.canResume) {
return Promise.reject({message: `Download ${id} cannot be resumed`});
}
return item.download.start();
});
},
cancel(id) {
return DownloadMap.lazyInit().then(() => {
let item;
try {
item = DownloadMap.fromId(id);
} catch (err) {
return Promise.reject({message: `Invalid download id ${id}`});
}
if (item.download.succeeded) {
return Promise.reject({message: `Download ${id} is already complete`});
}
return item.download.finalize(true);
});
},
showDefaultFolder() {
Downloads.getPreferredDownloadsDirectory().then(dir => {
let dirobj = new FileUtils.File(dir);
if (dirobj.isDirectory()) {
dirobj.launch();
} else {
throw new Error(`Download directory ${dirobj.path} is not actually a directory`);
}
}).catch(Cu.reportError);
},
erase(query) {
return queryHelper(query).then(items => {
let results = [];
let promises = [];
for (let item of items) {
promises.push(DownloadMap.erase(item));
results.push(item.id);
}
return Promise.all(promises).then(() => results);
});
},
open(downloadId) {
return DownloadMap.lazyInit().then(() => {
let download = DownloadMap.fromId(downloadId).download;
if (download.succeeded) {
return download.launch();
}
return Promise.reject({message: "Download has not completed."});
}).catch((error) => {
return Promise.reject({message: error.message});
});
},
show(downloadId) {
return DownloadMap.lazyInit().then(() => {
let download = DownloadMap.fromId(downloadId);
return download.download.showContainingDirectory();
}).then(() => {
return true;
}).catch(error => {
return Promise.reject({message: error.message});
});
},
getFileIcon(downloadId, options) {
return DownloadMap.lazyInit().then(() => {
let size = options && options.size ? options.size : 32;
let download = DownloadMap.fromId(downloadId).download;
let pathPrefix = "";
let path;
if (download.succeeded) {
let file = FileUtils.File(download.target.path);
path = Services.io.newFileURI(file).spec;
} else {
path = OS.Path.basename(download.target.path);
pathPrefix = "//";
}
return new Promise((resolve, reject) => {
let chromeWebNav = Services.appShell.createWindowlessBrowser(true);
chromeWebNav
.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDocShell)
.createAboutBlankContentViewer(Services.scriptSecurityManager.getSystemPrincipal());
let img = chromeWebNav.document.createElement("img");
img.width = size;
img.height = size;
let handleLoad;
let handleError;
const cleanup = () => {
img.removeEventListener("load", handleLoad);
img.removeEventListener("error", handleError);
chromeWebNav.close();
chromeWebNav = null;
};
handleLoad = () => {
let canvas = chromeWebNav.document.createElement("canvas");
canvas.width = size;
canvas.height = size;
let context = canvas.getContext("2d");
context.drawImage(img, 0, 0, size, size);
let dataURL = canvas.toDataURL("image/png");
cleanup();
resolve(dataURL);
};
handleError = (error) => {
Cu.reportError(error);
cleanup();
reject(new Error("An unexpected error occurred"));
};
img.addEventListener("load", handleLoad);
img.addEventListener("error", handleError);
img.src = `moz-icon:${pathPrefix}${path}?size=${size}`;
});
}).catch((error) => {
return Promise.reject({message: error.message});
});
},
// When we do setShelfEnabled(), check for additional "downloads.shelf" permission.
// i.e.:
// setShelfEnabled(enabled) {
// if (!extension.hasPermission("downloads.shelf")) {
// throw new context.cloneScope.Error("Permission denied because 'downloads.shelf' permission is missing.");
// }
// ...
// }
onChanged: new SingletonEventManager(context, "downloads.onChanged", fire => {
const handler = (what, item) => {
let changes = {};
const noundef = val => (val === undefined) ? null : val;
DOWNLOAD_ITEM_CHANGE_FIELDS.forEach(fld => {
if (item[fld] != item.prechange[fld]) {
changes[fld] = {
previous: noundef(item.prechange[fld]),
current: noundef(item[fld]),
};
}
});
if (Object.keys(changes).length > 0) {
changes.id = item.id;
runSafeSync(context, fire, changes);
}
};
let registerPromise = DownloadMap.getDownloadList().then(() => {
DownloadMap.on("change", handler);
});
return () => {
registerPromise.then(() => {
DownloadMap.off("change", handler);
});
};
}).api(),
onCreated: new SingletonEventManager(context, "downloads.onCreated", fire => {
const handler = (what, item) => {
runSafeSync(context, fire, item.serialize());
};
let registerPromise = DownloadMap.getDownloadList().then(() => {
DownloadMap.on("create", handler);
});
return () => {
registerPromise.then(() => {
DownloadMap.off("create", handler);
});
};
}).api(),
onErased: new SingletonEventManager(context, "downloads.onErased", fire => {
const handler = (what, item) => {
runSafeSync(context, fire, item.id);
};
let registerPromise = DownloadMap.getDownloadList().then(() => {
DownloadMap.on("erase", handler);
});
return () => {
registerPromise.then(() => {
DownloadMap.off("erase", handler);
});
};
}).api(),
onDeterminingFilename: ignoreEvent(context, "downloads.onDeterminingFilename"),
},
};
});