/* 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", ]; 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.createAboutBlankContentViewer( 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" ), }, }; } };