gecko-dev/toolkit/components/extensions/parent/ext-downloads.js

1307 lines
38 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";
ChromeUtils.defineESModuleGetters(this, {
DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
Downloads: "resource://gre/modules/Downloads.sys.mjs",
FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
});
ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
ChromeUtils.defineModuleGetter(
this,
"DownloadLastDir",
"resource://gre/modules/DownloadLastDir.jsm"
);
var { EventEmitter, ignoreEvent } = ExtensionCommon;
const DOWNLOAD_ITEM_FIELDS = [
"id",
"url",
"referrer",
"filename",
"incognito",
"cookieStoreId",
"danger",
"mime",
"startTime",
"endTime",
"estimatedEndTime",
"state",
"paused",
"canResume",
"error",
"bytesReceived",
"totalBytes",
"fileSize",
"exists",
"byExtensionId",
"byExtensionName",
];
const DOWNLOAD_DATE_FIELDS = ["startTime", "endTime", "estimatedEndTime"];
// 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
// Since bug 1367626 we allow extensions to set REFERER.
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",
"TE",
"TRAILER",
"TRANSFER-ENCODING",
"UPGRADE",
"VIA",
];
const FORBIDDEN_PREFIXES = /^PROXY-|^SEC-/i;
const PROMPTLESS_DOWNLOAD_PREF = "browser.download.useDownloadDir";
// Lists of file extensions for each file picker filter taken from filepicker.properties
const FILTER_HTML_EXTENSIONS = ["html", "htm", "shtml", "xhtml"];
const FILTER_TEXT_EXTENSIONS = ["txt", "text"];
const FILTER_IMAGES_EXTENSIONS = [
"jpe",
"jpg",
"jpeg",
"gif",
"png",
"bmp",
"ico",
"svg",
"svgz",
"tif",
"tiff",
"ai",
"drw",
"pct",
"psp",
"xcf",
"psd",
"raw",
"webp",
];
const FILTER_XML_EXTENSIONS = ["xml"];
const FILTER_AUDIO_EXTENSIONS = [
"aac",
"aif",
"flac",
"iff",
"m4a",
"m4b",
"mid",
"midi",
"mp3",
"mpa",
"mpc",
"oga",
"ogg",
"ra",
"ram",
"snd",
"wav",
"wma",
];
const FILTER_VIDEO_EXTENSIONS = [
"avi",
"divx",
"flv",
"m4v",
"mkv",
"mov",
"mp4",
"mpeg",
"mpg",
"ogm",
"ogv",
"ogx",
"rm",
"rmvb",
"smil",
"webm",
"wmv",
"xvid",
];
class DownloadItem {
constructor(id, download, extension) {
this.id = id;
this.download = download;
this.extension = extension;
this.prechange = {};
this._error = null;
}
get url() {
return this.download.source.url;
}
get referrer() {
const uri = this.download.source.referrerInfo
? this.download.source.referrerInfo.originalReferrer
: null;
return uri && uri.spec;
}
get filename() {
return this.download.target.path;
}
get incognito() {
return this.download.source.isPrivate;
}
get cookieStoreId() {
if (this.download.source.isPrivate) {
return PRIVATE_STORE;
}
if (this.download.source.userContextId) {
return getCookieStoreIdForContainer(this.download.source.userContextId);
}
return DEFAULT_STORE;
}
get danger() {
return "safe";
} // TODO
get mime() {
return this.download.contentType;
}
get startTime() {
return this.download.startTime;
}
get endTime() {
return null;
} // TODO
get estimatedEndTime() {
// Based on the code in summarizeDownloads() in DownloadsCommon.sys.mjs
if (this.download.hasProgress && this.download.speed > 0) {
let sizeLeft = this.download.totalBytes - this.download.currentBytes;
let timeLeftInSeconds = sizeLeft / this.download.speed;
return new Date(Date.now() + timeLeftInSeconds * 1000);
}
}
get state() {
if (this.download.succeeded) {
return "complete";
}
if (this.download.canceled || this.error) {
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._error) {
return this._error;
}
if (
!this.download.startTime ||
!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";
}
set error(value) {
this._error = value && value.toString();
}
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];
}
for (let field of DOWNLOAD_DATE_FIELDS) {
if (obj[field]) {
obj[field] = obj[field].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.
_storePrechange() {
for (let field of DOWNLOAD_ITEM_CHANGE_FIELDS) {
this.prechange[field] = this[field];
}
}
}
// DownloadMap maps back and forth between the numeric identifiers used in
// the downloads WebExtension API and a Download object from the Downloads sys.mjs.
// TODO Bug 1247794: make id and extension info persistent
const DownloadMap = new (class extends EventEmitter {
constructor() {
super();
this.currentId = 0;
this.loadPromise = null;
// Maps numeric id -> DownloadItem
this.byId = new Map();
// Maps Download object -> DownloadItem
this.byDownload = new WeakMap();
}
lazyInit() {
if (this.loadPromise == null) {
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);
item._storePrechange();
},
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 {
self.emit("change", item);
item._storePrechange();
}
},
})
.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, privateAllowed = true) {
const download = this.byId.get(id);
if (!download || (!privateAllowed && download.incognito)) {
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) {
// TODO Bug 1255507: 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().
const 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 ExtensionCommon.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 !== null ? query.totalBytesGreater : -1;
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",
"incognito",
"cookieStoreId",
"bytesReceived",
"totalBytes",
"fileSize",
"exists",
];
for (let field of SIMPLE_ITEMS) {
if (query[field] != null && item[field] != query[field]) {
return false;
}
}
return true;
};
};
const 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;
});
};
this.downloads = class extends ExtensionAPIPersistent {
downloadEventRegistrar(event, listener) {
let { extension } = this;
return ({ fire }) => {
const handler = (what, item) => {
if (extension.privateBrowsingAllowed || !item.incognito) {
listener(fire, what, item);
}
};
let registerPromise = DownloadMap.getDownloadList().then(() => {
DownloadMap.on(event, handler);
});
return {
unregister() {
registerPromise.then(() => {
DownloadMap.off(event, handler);
});
},
convert(_fire) {
fire = _fire;
},
};
};
}
PERSISTENT_EVENTS = {
onChanged: this.downloadEventRegistrar("change", (fire, 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) {
changes.id = item.id;
fire.async(changes);
}
}),
onCreated: this.downloadEventRegistrar("create", (fire, what, item) => {
fire.async(item.serialize());
}),
onErased: this.downloadEventRegistrar("erase", (fire, what, item) => {
fire.async(item.id);
}),
};
getAPI(context) {
let { extension } = context;
return {
downloads: {
download(options) {
const isHandlingUserInput =
context.callContextData?.isHandlingUserInput;
let { filename } = options;
if (filename && AppConstants.platform === "win") {
// cross platform javascript code uses "/"
filename = filename.replace(/\//g, "\\");
}
if (filename != null) {
if (!filename.length) {
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 (
path.components.some(component => {
let sanitized = DownloadPaths.sanitize(component, {
compressWhitespaces: false,
});
return component != sanitized;
})
) {
return Promise.reject({
message: "filename must not contain illegal characters",
});
}
}
if (options.incognito && !context.privateBrowsingAllowed) {
return Promise.reject({
message: "private browsing access not allowed",
});
}
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",
});
}
}
}
let userContextId = null;
if (options.cookieStoreId != null) {
userContextId = getUserContextIdForCookieStoreId(
extension,
options.cookieStoreId,
options.incognito
);
}
// 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) {
if (name.toLowerCase() == "referer") {
// The referer header and referrerInfo object should always
// match. So if we want to set the header from privileged
// context, we should set referrerInfo. The referrer header
// will get set internally.
channel.setNewReferrerInfo(
value,
Ci.nsIReferrerInfo.UNSAFE_URL,
true
);
} else {
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 allowHttpStatus(download, status) {
const item = DownloadMap.byDownload.get(download);
if (item === null) {
return true;
}
let error = null;
switch (status) {
case 204: // No Content
case 205: // Reset Content
case 404: // Not Found
error = "SERVER_BAD_CONTENT";
break;
case 403: // Forbidden
error = "SERVER_FORBIDDEN";
break;
case 402: // Unauthorized
case 407: // Proxy authentication required
error = "SERVER_UNAUTHORIZED";
break;
default:
if (status >= 400) {
error = "SERVER_FAILED";
}
break;
}
if (error) {
item.error = error;
return false;
}
// No error, ergo allow the request.
return true;
}
async function createTarget(downloadsDir) {
if (!filename) {
let uri = Services.io.newURI(options.url);
if (uri instanceof Ci.nsIURL) {
filename = DownloadPaths.sanitize(
Services.textToSubURI.unEscapeURIForUI(
uri.fileName,
/* dontEscape = */ true
)
);
}
}
let target = OS.Path.join(downloadsDir, filename || "download");
let saveAs;
if (options.saveAs !== null) {
saveAs = options.saveAs;
} else {
// If options.saveAs was not specified, only show the file chooser
// if |browser.download.useDownloadDir == false|. That is to say,
// only show the file chooser if Firefox normally shows it when
// a file is downloaded.
saveAs = !Services.prefs.getBoolPref(
PROMPTLESS_DOWNLOAD_PREF,
true
);
}
// Create any needed subdirectories if required by filename.
const dir = OS.Path.dirname(target);
await OS.File.makeDir(dir, { from: downloadsDir });
if (await OS.File.exists(target)) {
// 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.
switch (options.conflictAction) {
case "uniquify":
default:
target = DownloadPaths.createNiceUniqueFile(
new FileUtils.File(target)
).path;
if (saveAs) {
// createNiceUniqueFile actually creates the file, which
// is premature if we need to show a SaveAs dialog.
await OS.File.remove(target);
}
break;
case "overwrite":
break;
}
}
if (!saveAs || AppConstants.platform === "android") {
return target;
}
if (!("windowTracker" in global)) {
return target;
}
// At this point we are committed to displaying the file picker.
const downloadLastDir = new DownloadLastDir(
null,
options.incognito
);
async function getLastDirectory() {
return new Promise(resolve => {
downloadLastDir.getFileAsync(extension.baseURI, file => {
resolve(file);
});
});
}
function appendFilterForFileExtension(picker, ext) {
if (FILTER_HTML_EXTENSIONS.includes(ext)) {
picker.appendFilters(Ci.nsIFilePicker.filterHTML);
} else if (FILTER_TEXT_EXTENSIONS.includes(ext)) {
picker.appendFilters(Ci.nsIFilePicker.filterText);
} else if (FILTER_IMAGES_EXTENSIONS.includes(ext)) {
picker.appendFilters(Ci.nsIFilePicker.filterImages);
} else if (FILTER_XML_EXTENSIONS.includes(ext)) {
picker.appendFilters(Ci.nsIFilePicker.filterXML);
} else if (FILTER_AUDIO_EXTENSIONS.includes(ext)) {
picker.appendFilters(Ci.nsIFilePicker.filterAudio);
} else if (FILTER_VIDEO_EXTENSIONS.includes(ext)) {
picker.appendFilters(Ci.nsIFilePicker.filterVideo);
}
}
function saveLastDirectory(lastDir) {
downloadLastDir.setFile(extension.baseURI, lastDir);
}
// Use windowTracker to find a window, rather than Services.wm,
// so that this doesn't break where navigator:browser isn't the
// main window (e.g. Thunderbird).
const window = global.windowTracker.getTopWindow().window;
const basename = OS.Path.basename(target);
const ext = basename.match(/\.([^.]+)$/)?.[1];
// If the filename passed in by the extension is a simple name
// and not a path, we open the file picker so it displays the
// last directory that was chosen by the user.
const pathSep = AppConstants.platform === "win" ? "\\" : "/";
const lastFilePickerDirectory =
!filename || !filename.includes(pathSep)
? await getLastDirectory()
: undefined;
// Setup the file picker Save As dialog.
const picker = Cc["@mozilla.org/filepicker;1"].createInstance(
Ci.nsIFilePicker
);
picker.init(window, null, Ci.nsIFilePicker.modeSave);
if (lastFilePickerDirectory) {
picker.displayDirectory = lastFilePickerDirectory;
} else {
picker.displayDirectory = new FileUtils.File(dir);
}
picker.defaultString = basename;
if (ext) {
// Configure a default file extension, used as fallback on Windows.
picker.defaultExtension = ext;
appendFilterForFileExtension(picker, ext);
}
picker.appendFilters(Ci.nsIFilePicker.filterAll);
// 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 {
saveLastDirectory(picker.file.parent);
resolve(picker.file.path);
}
});
});
}
let download;
return Downloads.getPreferredDownloadsDirectory()
.then(downloadsDir => createTarget(downloadsDir))
.then(target => {
let uri = Services.io.newURI(options.url);
let cookieJarSettings = Cc[
"@mozilla.org/cookieJarSettings;1"
].createInstance(Ci.nsICookieJarSettings);
cookieJarSettings.initWithURI(uri, options.incognito);
const source = {
url: options.url,
isPrivate: options.incognito,
// Use the extension's principal to allow extensions to observe
// their own downloads via the webRequest API.
loadingPrincipal: context.principal,
cookieJarSettings,
};
if (userContextId) {
source.userContextId = userContextId;
}
// blob:-URLs can only be loaded by the principal with which they
// are associated. This principal may have origin attributes.
// `context.principal` does sometimes not have these attributes
// due to bug 1653681. If `context.principal` were to be passed,
// the download request would be rejected because of mismatching
// principals (origin attributes).
// TODO bug 1653681: fix context.principal and remove this.
if (options.url.startsWith("blob:")) {
// To make sure that the blob:-URL can be loaded, fall back to
// the default (system) principal instead.
delete source.loadingPrincipal;
}
// Unless the API user explicitly wants errors ignored,
// set the allowHttpStatus callback, which will instruct
// DownloadCore to cancel downloads on HTTP errors.
if (!options.allowHttpErrors) {
source.allowHttpStatus = allowHttpStatus;
}
if (options.method || options.headers || options.body) {
source.adjustChannel = adjustChannel;
}
return Downloads.createDownload({
// Only open the download panel if the method has been called
// while handling user input (See Bug 1759231).
openDownloadsListOnStart: isHandlingUserInput,
source,
target: {
path: target,
partFilePath: target + ".part",
},
});
})
.then(dl => {
download = dl;
return DownloadMap.getDownloadList();
})
.then(list => {
const item = DownloadMap.newFromDownload(download, extension);
list.add(download);
// This is necessary to make pause/resume work.
download.tryToKeepPartialData = true;
// Do not handle errors.
// Extensions will use listeners to be informed about errors.
// Just ignore any errors from |start()| to avoid spamming the
// error console.
download.start().catch(e => {
if (e.name !== "DownloadError") {
Cu.reportError(e);
}
});
return item.id;
});
},
removeFile(id) {
return DownloadMap.lazyInit().then(() => {
let item;
try {
item = DownloadMap.fromId(id, context.privateBrowsingAllowed);
} 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 => {
if (err.becauseNoSuchFile) {
return Promise.reject({
message: `Could not remove download id ${item.id} because the file doesn't exist`,
});
}
// Unexpected other error. Throw the original error, so that it
// can bubble up to the global browser console, but keep it
// sanitized (i.e. not wrapped in ExtensionError) to avoid
// inadvertent disclosure of potentially sensitive information.
throw err;
}
);
});
},
search(query) {
if (!context.privateBrowsingAllowed) {
query.incognito = false;
}
return queryHelper(query).then(items =>
items.map(item => item.serialize())
);
},
pause(id) {
return DownloadMap.lazyInit().then(() => {
let item;
try {
item = DownloadMap.fromId(id, context.privateBrowsingAllowed);
} 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, context.privateBrowsingAllowed);
} catch (err) {
return Promise.reject({ message: `Invalid download id ${id}` });
}
if (!item.canResume) {
return Promise.reject({
message: `Download ${id} cannot be resumed`,
});
}
item.error = null;
return item.download.start();
});
},
cancel(id) {
return DownloadMap.lazyInit().then(() => {
let item;
try {
item = DownloadMap.fromId(id, context.privateBrowsingAllowed);
} 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) {
if (!context.privateBrowsingAllowed) {
query.incognito = false;
}
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,
context.privateBrowsingAllowed
).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,
context.privateBrowsingAllowed
);
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,
context.privateBrowsingAllowed
).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
);
let system = Services.scriptSecurityManager.getSystemPrincipal();
chromeWebNav.docShell.createAboutBlankContentViewer(
system,
system
);
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 EventManager({
context,
module: "downloads",
event: "onChanged",
extensionApi: this,
}).api(),
onCreated: new EventManager({
context,
module: "downloads",
event: "onCreated",
extensionApi: this,
}).api(),
onErased: new EventManager({
context,
module: "downloads",
event: "onErased",
extensionApi: this,
}).api(),
onDeterminingFilename: ignoreEvent(
context,
"downloads.onDeterminingFilename"
),
},
};
}
};