"use strict"; // The ext-* files are imported into the same scopes. /* import-globals-from ext-toolkit.js */ XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", "resource://gre/modules/AppConstants.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"); var { EventEmitter, normalizeTime, } = ExtensionUtils; var { ignoreEvent, } = ExtensionCommon; const DOWNLOAD_ITEM_FIELDS = ["id", "url", "referrer", "filename", "incognito", "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 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; const PROMPTLESS_DOWNLOAD_PREF = "browser.download.useDownloadDir"; 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() { // Based on the code in summarizeDownloads() in DownloadsCommon.jsm 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) { 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.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"; } 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 betwen the numeric identifiers used in // the downloads WebExtension API and a Download object from the Downloads jsm. // 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) { 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) { // 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 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; }; }; 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 ExtensionAPI { getAPI(context) { let {extension} = context; return { downloads: { download(options) { let {filename} = options; if (filename && AppConstants.platform === "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 (path.components.some(component => component != DownloadPaths.sanitize(component))) { return Promise.reject({message: "filename must not contain illegal characters"}); } } 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(); } async function createTarget(downloadsDir) { if (!filename) { let uri = Services.io.newURI(options.url); if (uri instanceof Ci.nsIURL) { filename = DownloadPaths.sanitize(uri.fileName); } } 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) { return 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, isPrivate: options.incognito, }; 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 EventManager(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; fire.async(changes); } }; let registerPromise = DownloadMap.getDownloadList().then(() => { DownloadMap.on("change", handler); }); return () => { registerPromise.then(() => { DownloadMap.off("change", handler); }); }; }).api(), onCreated: new EventManager(context, "downloads.onCreated", fire => { const handler = (what, item) => { fire.async(item.serialize()); }; let registerPromise = DownloadMap.getDownloadList().then(() => { DownloadMap.on("create", handler); }); return () => { registerPromise.then(() => { DownloadMap.off("create", handler); }); }; }).api(), onErased: new EventManager(context, "downloads.onErased", fire => { const handler = (what, item) => { fire.async(item.id); }; let registerPromise = DownloadMap.getDownloadList().then(() => { DownloadMap.on("erase", handler); }); return () => { registerPromise.then(() => { DownloadMap.off("erase", handler); }); }; }).api(), onDeterminingFilename: ignoreEvent(context, "downloads.onDeterminingFilename"), }, }; } };