1270 lines
35 KiB
JavaScript
1270 lines
35 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, {
|
|
DownloadLastDir: "resource://gre/modules/DownloadLastDir.sys.mjs",
|
|
DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
|
|
Downloads: "resource://gre/modules/Downloads.sys.mjs",
|
|
FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
|
|
});
|
|
|
|
var { EventEmitter, ignoreEvent } = ExtensionCommon;
|
|
var { ExtensionError } = ExtensionUtils;
|
|
|
|
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",
|
|
"heic",
|
|
];
|
|
|
|
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?.originalReferrer;
|
|
|
|
return 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() {
|
|
// TODO
|
|
return "safe";
|
|
}
|
|
|
|
get mime() {
|
|
return this.download.contentType;
|
|
}
|
|
|
|
get startTime() {
|
|
return this.download.startTime;
|
|
}
|
|
|
|
get endTime() {
|
|
// TODO bug 1256269: implement endTime.
|
|
return null;
|
|
}
|
|
|
|
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);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
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 || this.download.error.becauseSourceFailed)
|
|
);
|
|
}
|
|
|
|
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?.id;
|
|
}
|
|
|
|
get byExtensionName() {
|
|
return this.extension?.name;
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
this.loadPromise = (async () => {
|
|
const list = await Downloads.getList(Downloads.ALL);
|
|
|
|
await list.addView({
|
|
onDownloadAdded: download => {
|
|
const item = this.newFromDownload(download, null);
|
|
this.emit("create", item);
|
|
item._storePrechange();
|
|
},
|
|
onDownloadRemoved: download => {
|
|
const item = this.byDownload.get(download);
|
|
if (item) {
|
|
this.emit("erase", item);
|
|
this.byDownload.delete(download);
|
|
this.byId.delete(item.id);
|
|
}
|
|
},
|
|
onDownloadChanged: download => {
|
|
const item = this.byDownload.get(download);
|
|
if (item) {
|
|
this.emit("change", item);
|
|
item._storePrechange();
|
|
} else {
|
|
Cu.reportError(
|
|
"Got onDownloadChanged for unknown download object"
|
|
);
|
|
}
|
|
},
|
|
});
|
|
|
|
const downloads = await list.getAll();
|
|
|
|
for (let download of downloads) {
|
|
this.newFromDownload(download, null);
|
|
}
|
|
|
|
return list;
|
|
})();
|
|
}
|
|
|
|
return this.loadPromise;
|
|
}
|
|
|
|
getDownloadList() {
|
|
return this.lazyInit();
|
|
}
|
|
|
|
async getAll() {
|
|
await this.lazyInit();
|
|
return this.byId.values();
|
|
}
|
|
|
|
fromId(id, privateAllowed = true) {
|
|
const download = this.byId.get(id);
|
|
if (!download || (!privateAllowed && download.incognito)) {
|
|
throw new ExtensionError(`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;
|
|
}
|
|
|
|
async erase(item) {
|
|
// TODO Bug 1255507: for now we only work with downloads in the DownloadList
|
|
// from getAll()
|
|
const list = await this.getDownloadList();
|
|
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);
|
|
|
|
// TODO bug 1727510: Implement endedBefore/endedAfter
|
|
// const endedBefore = normalizeDownloadTime(query.endedBefore, true);
|
|
// const endedAfter = normalizeDownloadTime(query.endedAfter, false);
|
|
|
|
const totalBytesGreater = query.totalBytesGreater ?? -1;
|
|
const totalBytesLess = 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 () => true;
|
|
}
|
|
|
|
let re;
|
|
try {
|
|
re = new RegExp(regex || "", "i");
|
|
} catch (err) {
|
|
throw new ExtensionError(`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 () => 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 = async query => {
|
|
let matchFn = downloadQuery(query);
|
|
let compareFn;
|
|
|
|
if (query.orderBy) {
|
|
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)) {
|
|
throw new ExtensionError(`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;
|
|
};
|
|
}
|
|
|
|
let downloads = await DownloadMap.getAll();
|
|
|
|
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: {
|
|
async 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) {
|
|
throw new ExtensionError("filename must not be empty");
|
|
}
|
|
|
|
if (PathUtils.isAbsolute(filename)) {
|
|
throw new ExtensionError("filename must not be an absolute path");
|
|
}
|
|
|
|
// % is not permitted but relatively common.
|
|
filename = filename.replaceAll("%", "_");
|
|
|
|
const pathComponents = PathUtils.splitRelative(filename, {
|
|
allowEmpty: true,
|
|
allowCurrentDir: true,
|
|
allowParentDir: true,
|
|
});
|
|
|
|
if (pathComponents.some(component => component == "..")) {
|
|
throw new ExtensionError(
|
|
"filename must not contain back-references (..)"
|
|
);
|
|
}
|
|
|
|
if (
|
|
pathComponents.some((component, i) => {
|
|
let sanitized = DownloadPaths.sanitize(component, {
|
|
compressWhitespaces: false,
|
|
allowDirectoryNames: i < pathComponents.length - 1,
|
|
});
|
|
return component != sanitized;
|
|
})
|
|
) {
|
|
throw new ExtensionError(
|
|
"filename must not contain illegal characters"
|
|
);
|
|
}
|
|
}
|
|
|
|
if (options.incognito && !context.privateBrowsingAllowed) {
|
|
throw new ExtensionError("private browsing access not allowed");
|
|
}
|
|
|
|
if (options.conflictAction == "prompt") {
|
|
// TODO
|
|
throw new ExtensionError(
|
|
"conflictAction prompt not yet implemented"
|
|
);
|
|
}
|
|
|
|
if (options.headers) {
|
|
for (let { name } of options.headers) {
|
|
if (
|
|
FORBIDDEN_HEADERS.includes(name.toUpperCase()) ||
|
|
name.match(FORBIDDEN_PREFIXES)
|
|
) {
|
|
throw new ExtensionError("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.setByteStringData(options.body);
|
|
|
|
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 = PathUtils.joinRelative(
|
|
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 = PathUtils.parent(target);
|
|
await IOUtils.makeDirectory(dir);
|
|
|
|
if (await IOUtils.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 IOUtils.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 downloadLastDir.getFileAsync(extension.baseURI);
|
|
}
|
|
|
|
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 = PathUtils.filename(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.browsingContext,
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
const downloadsDir = await Downloads.getPreferredDownloadsDirectory();
|
|
const target = await createTarget(downloadsDir);
|
|
const uri = Services.io.newURI(options.url);
|
|
const 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;
|
|
}
|
|
|
|
const download = await 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`,
|
|
},
|
|
});
|
|
|
|
const list = await DownloadMap.getDownloadList();
|
|
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(err => {
|
|
if (err.name !== "DownloadError") {
|
|
Cu.reportError(err);
|
|
}
|
|
});
|
|
|
|
return item.id;
|
|
},
|
|
|
|
async removeFile(id) {
|
|
await DownloadMap.lazyInit();
|
|
|
|
let item = DownloadMap.fromId(id, context.privateBrowsingAllowed);
|
|
|
|
if (item.state !== "complete") {
|
|
throw new ExtensionError(
|
|
`Cannot remove incomplete download id ${id}`
|
|
);
|
|
}
|
|
|
|
try {
|
|
await IOUtils.remove(item.filename, { ignoreAbsent: false });
|
|
} catch (err) {
|
|
if (DOMException.isInstance(err) && err.name === "NotFoundError") {
|
|
throw new ExtensionError(
|
|
`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;
|
|
}
|
|
},
|
|
|
|
async search(query) {
|
|
if (!context.privateBrowsingAllowed) {
|
|
query.incognito = false;
|
|
}
|
|
|
|
const items = await queryHelper(query);
|
|
return items.map(item => item.serialize());
|
|
},
|
|
|
|
async pause(id) {
|
|
await DownloadMap.lazyInit();
|
|
|
|
let item = DownloadMap.fromId(id, context.privateBrowsingAllowed);
|
|
|
|
if (item.state !== "in_progress") {
|
|
throw new ExtensionError(
|
|
`Download ${id} cannot be paused since it is in state ${item.state}`
|
|
);
|
|
}
|
|
|
|
return item.download.cancel();
|
|
},
|
|
|
|
async resume(id) {
|
|
await DownloadMap.lazyInit();
|
|
|
|
let item = DownloadMap.fromId(id, context.privateBrowsingAllowed);
|
|
|
|
if (!item.canResume) {
|
|
throw new ExtensionError(`Download ${id} cannot be resumed`);
|
|
}
|
|
|
|
item.error = null;
|
|
return item.download.start();
|
|
},
|
|
|
|
async cancel(id) {
|
|
await DownloadMap.lazyInit();
|
|
|
|
let item = DownloadMap.fromId(id, context.privateBrowsingAllowed);
|
|
|
|
if (item.download.succeeded) {
|
|
throw new ExtensionError(`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);
|
|
},
|
|
|
|
async erase(query) {
|
|
if (!context.privateBrowsingAllowed) {
|
|
query.incognito = false;
|
|
}
|
|
|
|
const items = await queryHelper(query);
|
|
let results = [];
|
|
let promises = [];
|
|
|
|
for (let item of items) {
|
|
promises.push(DownloadMap.erase(item));
|
|
results.push(item.id);
|
|
}
|
|
|
|
await Promise.all(promises);
|
|
return results;
|
|
},
|
|
|
|
async open(downloadId) {
|
|
await DownloadMap.lazyInit();
|
|
|
|
let { download } = DownloadMap.fromId(
|
|
downloadId,
|
|
context.privateBrowsingAllowed
|
|
);
|
|
|
|
if (!download.succeeded) {
|
|
throw new ExtensionError("Download has not completed.");
|
|
}
|
|
|
|
return download.launch();
|
|
},
|
|
|
|
async show(downloadId) {
|
|
await DownloadMap.lazyInit();
|
|
|
|
const { download } = DownloadMap.fromId(
|
|
downloadId,
|
|
context.privateBrowsingAllowed
|
|
);
|
|
|
|
await download.showContainingDirectory();
|
|
|
|
return true;
|
|
},
|
|
|
|
async getFileIcon(downloadId, options) {
|
|
await DownloadMap.lazyInit();
|
|
|
|
const size = options?.size || 32;
|
|
const { download } = DownloadMap.fromId(
|
|
downloadId,
|
|
context.privateBrowsingAllowed
|
|
);
|
|
|
|
let pathPrefix = "";
|
|
let path;
|
|
|
|
if (download.succeeded) {
|
|
let file = FileUtils.File(download.target.path);
|
|
path = Services.io.newFileURI(file).spec;
|
|
} else {
|
|
path = PathUtils.filename(download.target.path);
|
|
pathPrefix = "//";
|
|
}
|
|
|
|
let windowlessBrowser =
|
|
Services.appShell.createWindowlessBrowser(true);
|
|
let systemPrincipal =
|
|
Services.scriptSecurityManager.getSystemPrincipal();
|
|
windowlessBrowser.docShell.createAboutBlankDocumentViewer(
|
|
systemPrincipal,
|
|
systemPrincipal
|
|
);
|
|
|
|
let canvas = windowlessBrowser.document.createElement("canvas");
|
|
let img = new windowlessBrowser.docShell.domWindow.Image(size, size);
|
|
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
|
|
img.src = `moz-icon:${pathPrefix}${path}?size=${size}`;
|
|
|
|
try {
|
|
await img.decode();
|
|
|
|
canvas.getContext("2d").drawImage(img, 0, 0, size, size);
|
|
|
|
let dataURL = canvas.toDataURL("image/png");
|
|
|
|
return dataURL;
|
|
} finally {
|
|
windowlessBrowser.close();
|
|
}
|
|
},
|
|
|
|
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"
|
|
),
|
|
},
|
|
};
|
|
}
|
|
};
|