From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- .../components/extensions/parent/ext-downloads.js | 1261 ++++++++++++++++++++ 1 file changed, 1261 insertions(+) create mode 100644 toolkit/components/extensions/parent/ext-downloads.js (limited to 'toolkit/components/extensions/parent/ext-downloads.js') diff --git a/toolkit/components/extensions/parent/ext-downloads.js b/toolkit/components/extensions/parent/ext-downloads.js new file mode 100644 index 0000000000..9cd96e0d65 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-downloads.js @@ -0,0 +1,1261 @@ +/* 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); + } + } + + 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?.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 input => 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 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 = 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"); + } + + 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 => { + let sanitized = DownloadPaths.sanitize(component, { + compressWhitespaces: false, + }); + 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.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 = 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, 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" + ), + }, + }; + } +}; -- cgit v1.2.3